mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-29 02:55:22 +02:00
Compare commits
2 Commits
v0.27.1
...
patches-im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4eb0bfea1 | ||
|
|
ce9ba60902 |
@@ -1,21 +0,0 @@
|
|||||||
# Dockerfile for DevContainer
|
|
||||||
FROM node:20.16.0-bullseye-slim
|
|
||||||
|
|
||||||
# Install essential packages
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
curl \
|
|
||||||
bash \
|
|
||||||
git \
|
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Set up PNPM
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
|
||||||
|
|
||||||
# Create workspace directory
|
|
||||||
WORKDIR /workspaces/dokploy
|
|
||||||
|
|
||||||
# Set up user permissions
|
|
||||||
USER node
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Dokploy development container",
|
|
||||||
"build": {
|
|
||||||
"dockerfile": "Dockerfile",
|
|
||||||
"context": ".."
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
|
||||||
"moby": true,
|
|
||||||
"version": "latest"
|
|
||||||
},
|
|
||||||
"ghcr.io/devcontainers/features/git:1": {
|
|
||||||
"ppa": true,
|
|
||||||
"version": "latest"
|
|
||||||
},
|
|
||||||
"ghcr.io/devcontainers/features/go:1": {
|
|
||||||
"version": "1.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"customizations": {
|
|
||||||
"vscode": {
|
|
||||||
"extensions": [
|
|
||||||
"ms-vscode.vscode-typescript-next",
|
|
||||||
"bradlc.vscode-tailwindcss",
|
|
||||||
"ms-vscode.vscode-json",
|
|
||||||
"biomejs.biome",
|
|
||||||
"golang.go",
|
|
||||||
"redhat.vscode-xml",
|
|
||||||
"github.vscode-github-actions",
|
|
||||||
"github.copilot",
|
|
||||||
"github.copilot-chat"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"forwardPorts": [3000, 5432, 6379],
|
|
||||||
"portsAttributes": {
|
|
||||||
"3000": {
|
|
||||||
"label": "Dokploy App",
|
|
||||||
"onAutoForward": "notify"
|
|
||||||
},
|
|
||||||
"5432": {
|
|
||||||
"label": "PostgreSQL",
|
|
||||||
"onAutoForward": "silent"
|
|
||||||
},
|
|
||||||
"6379": {
|
|
||||||
"label": "Redis",
|
|
||||||
"onAutoForward": "silent"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"remoteUser": "node",
|
|
||||||
"workspaceFolder": "/workspaces/dokploy",
|
|
||||||
"runArgs": ["--name", "dokploy-devcontainer"]
|
|
||||||
}
|
|
||||||
22
.github/workflows/pr-quality.yml
vendored
22
.github/workflows/pr-quality.yml
vendored
@@ -1,22 +0,0 @@
|
|||||||
|
|
||||||
name: PR Quality
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, reopened]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
anti-slop:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: peakoss/anti-slop@v0
|
|
||||||
with:
|
|
||||||
max-failures: 4
|
|
||||||
blocked-commit-authors: "claude,copilot"
|
|
||||||
require-description: true
|
|
||||||
min-account-age: 5
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,3 +44,6 @@ yarn-error.log*
|
|||||||
|
|
||||||
|
|
||||||
.db
|
.db
|
||||||
|
|
||||||
|
# Development environment
|
||||||
|
.devcontainer
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"redis": "4.7.0",
|
"redis": "4.7.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.16.0",
|
"@types/node": "^20.16.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ApplicationNested } from "@dokploy/server";
|
import type { ApplicationNested } from "@dokploy/server";
|
||||||
@@ -8,6 +9,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
|
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
|
||||||
|
|
||||||
|
// Mock constants to avoid load error
|
||||||
|
vi.mock("@dokploy/server/constants", () => ({
|
||||||
|
paths: () => ({
|
||||||
|
LOGS_PATH: "/tmp/dokploy-test-real/logs",
|
||||||
|
APPLICATIONS_PATH: "/tmp/dokploy-test-real/applications",
|
||||||
|
PATCH_REPOS_PATH: "/tmp/dokploy-test-real/patch-repos",
|
||||||
|
}),
|
||||||
|
IS_CLOUD: false,
|
||||||
|
docker: {},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock ONLY database and notifications
|
// Mock ONLY database and notifications
|
||||||
vi.mock("@dokploy/server/db", () => {
|
vi.mock("@dokploy/server/db", () => {
|
||||||
const createChainableMock = (): any => {
|
const createChainableMock = (): any => {
|
||||||
@@ -67,6 +79,16 @@ vi.mock("@dokploy/server/services/rollbacks", () => ({
|
|||||||
createRollback: vi.fn(),
|
createRollback: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/patch", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<
|
||||||
|
typeof import("@dokploy/server/services/patch")
|
||||||
|
>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
findPatchesByApplicationId: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// NOT mocked (executed for real):
|
// NOT mocked (executed for real):
|
||||||
// - execAsync
|
// - execAsync
|
||||||
// - cloneGitRepository
|
// - cloneGitRepository
|
||||||
@@ -78,6 +100,11 @@ import * as adminService from "@dokploy/server/services/admin";
|
|||||||
import * as applicationService from "@dokploy/server/services/application";
|
import * as applicationService from "@dokploy/server/services/application";
|
||||||
import { deployApplication } from "@dokploy/server/services/application";
|
import { deployApplication } from "@dokploy/server/services/application";
|
||||||
import * as deploymentService from "@dokploy/server/services/deployment";
|
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||||
|
import * as patchService from "@dokploy/server/services/patch";
|
||||||
|
import { generatePatch } from "@dokploy/server/services/patch";
|
||||||
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
const createMockApplication = (
|
const createMockApplication = (
|
||||||
overrides: Partial<ApplicationNested> = {},
|
overrides: Partial<ApplicationNested> = {},
|
||||||
@@ -474,6 +501,105 @@ describe(
|
|||||||
},
|
},
|
||||||
REAL_TEST_TIMEOUT,
|
REAL_TEST_TIMEOUT,
|
||||||
);
|
);
|
||||||
|
it(
|
||||||
|
"should REALLY apply patches from database during deployment",
|
||||||
|
async () => {
|
||||||
|
// 1. Setup local temporary git repo
|
||||||
|
const tempRepo = await mkdtemp(join(tmpdir(), "real-patch-repo-"));
|
||||||
|
// Helper for local git commands
|
||||||
|
const execLocal = async (cmd: string) => execAsync(cmd, { cwd: tempRepo });
|
||||||
|
|
||||||
|
await execLocal("git init");
|
||||||
|
await execLocal("git config user.email 'test@dokploy.com'");
|
||||||
|
await execLocal("git config user.name 'Dokploy Test'");
|
||||||
|
|
||||||
|
// Create a simple Dockerfile and server script
|
||||||
|
// We use a simple python server to verify output
|
||||||
|
await writeFile(join(tempRepo, "app.py"), "print('Original App')\n");
|
||||||
|
await writeFile(
|
||||||
|
join(tempRepo, "Dockerfile"),
|
||||||
|
"FROM python:3.9-slim\nCOPY app.py .\nCMD [\"python\", \"app.py\"]\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
await execLocal("git add .");
|
||||||
|
await execLocal("git commit -m 'Initial commit'");
|
||||||
|
// Ensure master/main branch exists (git init might create master or main depending on config)
|
||||||
|
// We force create a branch named 'main' to be consistent
|
||||||
|
await execLocal("git checkout -b main || git checkout main");
|
||||||
|
|
||||||
|
// 2. Mock Application to use this local repo
|
||||||
|
const patchAppName = `real-patch-app-${Date.now()}`;
|
||||||
|
const patchApp = createMockApplication({
|
||||||
|
appName: patchAppName,
|
||||||
|
buildType: "dockerfile",
|
||||||
|
customGitUrl: `file://${tempRepo}`,
|
||||||
|
customGitBranch: "main",
|
||||||
|
dockerfile: "Dockerfile",
|
||||||
|
});
|
||||||
|
currentAppName = patchAppName;
|
||||||
|
allTestAppNames.push(patchAppName);
|
||||||
|
|
||||||
|
// Setup standard mocks
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
patchApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
patchApp as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Generate a patch
|
||||||
|
// We modify the file, generate patch, and then reset.
|
||||||
|
const newContent = "print('Patched App')\n";
|
||||||
|
const patchContent = await generatePatch({
|
||||||
|
codePath: tempRepo,
|
||||||
|
filePath: "app.py",
|
||||||
|
newContent,
|
||||||
|
serverId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Mock patch service to return this patch
|
||||||
|
vi.mocked(patchService.findPatchesByApplicationId).mockResolvedValue([
|
||||||
|
{
|
||||||
|
patchId: "test-patch-1",
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
composeId: null,
|
||||||
|
filePath: "app.py",
|
||||||
|
content: patchContent,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`\n🚀 Testing deployment with patch: ${currentAppName}`);
|
||||||
|
|
||||||
|
// 5. Deploy
|
||||||
|
const result = await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Patch Test",
|
||||||
|
descriptionLog: "Testing patch application",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// 6. Verify Log contains "Applying patch"
|
||||||
|
const { stdout: logContent } = await execAsync(
|
||||||
|
`cat ${currentDeployment.logPath}`,
|
||||||
|
);
|
||||||
|
// The implementation logs "Applying patch: ..."
|
||||||
|
expect(logContent).toContain("Applying patch");
|
||||||
|
expect(logContent).toContain("app.py");
|
||||||
|
console.log("✅ Verified patch execution logs");
|
||||||
|
|
||||||
|
// 7. Verify the deployed image contains the patched code
|
||||||
|
// We run the image and check output
|
||||||
|
const { stdout: runOutput } = await execAsync(
|
||||||
|
`docker run --rm ${patchAppName}`,
|
||||||
|
);
|
||||||
|
expect(runOutput.trim()).toBe("Patched App");
|
||||||
|
console.log("✅ Verified patched output:", runOutput.trim());
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
REAL_TEST_TIMEOUT,
|
REAL_TEST_TIMEOUT,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,14 +83,6 @@ describe("GitHub Webhook Skip CI", () => {
|
|||||||
{ commits: [{ message: "[skip ci] test" }] },
|
{ commits: [{ message: "[skip ci] test" }] },
|
||||||
),
|
),
|
||||||
).toBe("[skip ci] test");
|
).toBe("[skip ci] test");
|
||||||
|
|
||||||
// Soft Serve
|
|
||||||
expect(
|
|
||||||
extractCommitMessage(
|
|
||||||
{ "x-softserve-event": "push" },
|
|
||||||
{ commits: [{ message: "[skip ci] test" }] },
|
|
||||||
),
|
|
||||||
).toBe("[skip ci] test");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle missing commit message", () => {
|
it("should handle missing commit message", () => {
|
||||||
@@ -107,9 +99,6 @@ describe("GitHub Webhook Skip CI", () => {
|
|||||||
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
|
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
|
||||||
"NEW COMMIT",
|
"NEW COMMIT",
|
||||||
);
|
);
|
||||||
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
|
|
||||||
"NEW COMMIT",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
extractBranchName,
|
|
||||||
extractCommitMessage,
|
|
||||||
extractHash,
|
|
||||||
getProviderByHeader,
|
|
||||||
} from "@/pages/api/deploy/[refreshToken]";
|
|
||||||
|
|
||||||
describe("Soft Serve Webhook", () => {
|
|
||||||
const mockSoftServeHeaders = {
|
|
||||||
"x-softserve-event": "push",
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMockBody = (message: string, hash: string, branch: string) => ({
|
|
||||||
event: "push",
|
|
||||||
ref: `refs/heads/${branch}`,
|
|
||||||
after: hash,
|
|
||||||
commits: [{ message: message }],
|
|
||||||
});
|
|
||||||
const message: string = "feat: add new feature";
|
|
||||||
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
|
|
||||||
const branch: string = "feat/add-new";
|
|
||||||
const goodWebhook = createMockBody(message, hash, branch);
|
|
||||||
|
|
||||||
it("should properly extract the provider name", () => {
|
|
||||||
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should properly extract the commit message", () => {
|
|
||||||
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should properly extract hash", () => {
|
|
||||||
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should properly extract branch name", () => {
|
|
||||||
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should gracefully handle invalid webhook", () => {
|
|
||||||
expect(getProviderByHeader({})).toBeNull();
|
|
||||||
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
|
|
||||||
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
|
|
||||||
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,7 +6,6 @@ import { paths } from "@dokploy/server/constants";
|
|||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const OUTPUT_BASE = "./__test__/drop/zips/output";
|
|
||||||
const { APPLICATIONS_PATH } = paths();
|
const { APPLICATIONS_PATH } = paths();
|
||||||
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||||
const actual = await importOriginal();
|
const actual = await importOriginal();
|
||||||
@@ -14,10 +13,7 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
...actual,
|
...actual,
|
||||||
paths: () => ({
|
paths: () => ({
|
||||||
// @ts-ignore
|
APPLICATIONS_PATH: "./__test__/drop/zips/output",
|
||||||
...actual.paths(),
|
|
||||||
BASE_PATH: OUTPUT_BASE,
|
|
||||||
APPLICATIONS_PATH: OUTPUT_BASE,
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -154,176 +150,6 @@ const baseApp: ApplicationNested = {
|
|||||||
ulimitsSwarm: null,
|
ulimitsSwarm: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
|
|
||||||
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
|
|
||||||
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
|
|
||||||
*/
|
|
||||||
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
|
|
||||||
baseApp.appName = "ghsa-rce";
|
|
||||||
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
|
|
||||||
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
|
|
||||||
const cronPayload = "* * * * * root id\n";
|
|
||||||
const placeholder = "x".repeat(traversalEntry.length);
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile(
|
|
||||||
"package.json",
|
|
||||||
Buffer.from('{"name": "app", "version": "1.0.0"}'),
|
|
||||||
);
|
|
||||||
zip.addFile("index.js", Buffer.from('console.log("Application");'));
|
|
||||||
zip.addFile(placeholder, Buffer.from(cronPayload));
|
|
||||||
let buf = Buffer.from(zip.toBuffer());
|
|
||||||
buf = Buffer.from(
|
|
||||||
buf.toString("binary").split(placeholder).join(traversalEntry),
|
|
||||||
"binary",
|
|
||||||
);
|
|
||||||
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
|
|
||||||
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
|
|
||||||
/Path traversal detected.*resolved path escapes output directory/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("security: existing symlink escape", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT write outside base when directory is a symlink", async () => {
|
|
||||||
const appName = "symlink-existing";
|
|
||||||
const output = path.join(APPLICATIONS_PATH, appName, "code");
|
|
||||||
await fs.mkdir(output, { recursive: true });
|
|
||||||
|
|
||||||
// outside target (attacker wants to write here)
|
|
||||||
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
|
|
||||||
await fs.mkdir(outside, { recursive: true });
|
|
||||||
|
|
||||||
// attacker-controlled symlink inside project
|
|
||||||
await fs.symlink(outside, path.join(output, "logs"));
|
|
||||||
|
|
||||||
// zip looks totally harmless
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
|
|
||||||
|
|
||||||
const file = new File([zip.toBuffer() as any], "exploit.zip");
|
|
||||||
|
|
||||||
await unzipDrop(file, { ...baseApp, appName });
|
|
||||||
|
|
||||||
// if vulnerable -> file exists outside sandbox
|
|
||||||
const escaped = await fs
|
|
||||||
.readFile(path.join(outside, "pwned.txt"), "utf8")
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
expect(escaped).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("security: zip symlink entry blocked", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects zip containing real symlink entry", async () => {
|
|
||||||
const appName = "zip-symlink";
|
|
||||||
|
|
||||||
const zipBuffer = await fs.readFile(
|
|
||||||
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const file = new File([zipBuffer as any], "exploit.zip");
|
|
||||||
|
|
||||||
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
|
|
||||||
/Dangerous node entries are not allowed/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("unzipDrop path under output (no traversal)", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
|
|
||||||
baseApp.appName = "cron-under-output";
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile(
|
|
||||||
"etc/cron.d/malicious-cron",
|
|
||||||
Buffer.from("* * * * * root id\n"),
|
|
||||||
);
|
|
||||||
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
|
|
||||||
const file = new File(
|
|
||||||
[zip.toBuffer() as unknown as ArrayBuffer],
|
|
||||||
"app.zip",
|
|
||||||
);
|
|
||||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
|
||||||
await unzipDrop(file, baseApp);
|
|
||||||
const content = await fs.readFile(
|
|
||||||
path.join(outputPath, "etc/cron.d/malicious-cron"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
expect(content).toBe("* * * * * root id\n");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
|
|
||||||
const appName = "sandbox-escape";
|
|
||||||
|
|
||||||
const base = APPLICATIONS_PATH.replace("/applications", "");
|
|
||||||
const output = path.join(APPLICATIONS_PATH, appName, "code");
|
|
||||||
|
|
||||||
await fs.mkdir(output, { recursive: true });
|
|
||||||
|
|
||||||
// attacker writes into traefik config inside base
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile(
|
|
||||||
"../../../traefik/dynamic/evil.yml",
|
|
||||||
Buffer.from("pwned: true"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const file = new File([zip.toBuffer() as any], "exploit.zip");
|
|
||||||
|
|
||||||
await unzipDrop(file, { ...baseApp, appName });
|
|
||||||
|
|
||||||
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
|
|
||||||
|
|
||||||
const exists = await fs
|
|
||||||
.readFile(escapedPath)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
expect(exists).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("unzipDrop using real zip files", () => {
|
describe("unzipDrop using real zip files", () => {
|
||||||
// const { APPLICATIONS_PATH } = paths();
|
// const { APPLICATIONS_PATH } = paths();
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -340,12 +166,14 @@ describe("unzipDrop using real zip files", () => {
|
|||||||
try {
|
try {
|
||||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||||
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
|
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
|
||||||
|
console.log(`Output Path: ${outputPath}`);
|
||||||
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
|
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
|
||||||
const file = new File([zipBuffer], "single.zip");
|
const file = new File([zipBuffer], "single.zip");
|
||||||
await unzipDrop(file, baseApp);
|
await unzipDrop(file, baseApp);
|
||||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||||
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
} finally {
|
} finally {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/etc/passwd
|
|
||||||
Binary file not shown.
106
apps/dokploy/__test__/patches/patch.integration.test.ts
Normal file
106
apps/dokploy/__test__/patches/patch.integration.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
|
||||||
|
import { generatePatch } from "@dokploy/server/services/patch";
|
||||||
|
import { describe, expect, it, afterEach } from "vitest";
|
||||||
|
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { exec } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execAsyncLocal = promisify(exec);
|
||||||
|
|
||||||
|
describe("Patch System Integration", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (tempDir) {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate a patch that can be successfully applied via git", async () => {
|
||||||
|
// Setup repo
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "dokploy-patch-test-"));
|
||||||
|
const fileName = "test.txt";
|
||||||
|
const filePath = join(tempDir, fileName);
|
||||||
|
|
||||||
|
await execAsyncLocal("git init", { cwd: tempDir });
|
||||||
|
await execAsyncLocal("git config user.email 'test@test.com'", { cwd: tempDir });
|
||||||
|
await execAsyncLocal("git config user.name 'Test'", { cwd: tempDir });
|
||||||
|
|
||||||
|
// Original content
|
||||||
|
await writeFile(filePath, "line1\nline2\n");
|
||||||
|
await execAsyncLocal(`git add ${fileName}`, { cwd: tempDir });
|
||||||
|
await execAsyncLocal("git commit -m 'init'", { cwd: tempDir });
|
||||||
|
|
||||||
|
// Generate patch (modify content)
|
||||||
|
const newContent = "line1\nline2\nline3\n";
|
||||||
|
const patchContent = await generatePatch({
|
||||||
|
codePath: tempDir,
|
||||||
|
filePath: fileName,
|
||||||
|
newContent,
|
||||||
|
serverId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify patch format
|
||||||
|
expect(patchContent.endsWith("\n")).toBe(true);
|
||||||
|
|
||||||
|
// Reset file (generatePatch does reset, but ensure it)
|
||||||
|
await execAsyncLocal("git checkout .", { cwd: tempDir });
|
||||||
|
const savedContent = await readFile(filePath, "utf-8");
|
||||||
|
expect(savedContent).toBe("line1\nline2\n");
|
||||||
|
|
||||||
|
// Apply patch verification
|
||||||
|
// We simulate what Deployment Service does: write patch to file and run git apply
|
||||||
|
const patchFile = join(tempDir, "changes.patch");
|
||||||
|
await writeFile(patchFile, patchContent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execAsyncLocal(`git apply --whitespace=fix ${patchFile}`, { cwd: tempDir });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Git apply failed:", e.message);
|
||||||
|
console.log("Patch content:", JSON.stringify(patchContent));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliedContent = await readFile(filePath, "utf-8");
|
||||||
|
expect(appliedContent).toBe(newContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle files created without trailing newline", async () => {
|
||||||
|
// Setup repo
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "dokploy-patch-test-noline-"));
|
||||||
|
const fileName = "noline.txt";
|
||||||
|
const filePath = join(tempDir, fileName);
|
||||||
|
|
||||||
|
await execAsyncLocal("git init", { cwd: tempDir });
|
||||||
|
await execAsyncLocal("git config user.email 'test@test.com'", { cwd: tempDir });
|
||||||
|
await execAsyncLocal("git config user.name 'Test'", { cwd: tempDir });
|
||||||
|
|
||||||
|
// Original content WITHOUT newline
|
||||||
|
await writeFile(filePath, "line1");
|
||||||
|
await execAsyncLocal(`git add ${fileName}`, { cwd: tempDir });
|
||||||
|
await execAsyncLocal("git commit -m 'init'", { cwd: tempDir });
|
||||||
|
|
||||||
|
// Generate patch
|
||||||
|
const newContent = "line1\nline2";
|
||||||
|
const patchContent = await generatePatch({
|
||||||
|
codePath: tempDir,
|
||||||
|
filePath: fileName,
|
||||||
|
newContent,
|
||||||
|
serverId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify patch format
|
||||||
|
expect(patchContent.endsWith("\n")).toBe(true);
|
||||||
|
|
||||||
|
// Apply patch
|
||||||
|
const patchFile = join(tempDir, "changes.patch");
|
||||||
|
await writeFile(patchFile, patchContent);
|
||||||
|
|
||||||
|
await execAsyncLocal(`git apply --whitespace=fix ${patchFile}`, { cwd: tempDir });
|
||||||
|
|
||||||
|
const appliedContent = await readFile(filePath, "utf-8");
|
||||||
|
expect(appliedContent).toBe(newContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -275,51 +275,3 @@ test("CertificateType on websecure entrypoint", async () => {
|
|||||||
|
|
||||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||||
});
|
});
|
||||||
|
|
||||||
/** IDN/Punycode */
|
|
||||||
|
|
||||||
test("Internationalized domain name is converted to punycode", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, host: "тест.рф" },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// тест.рф in punycode is xn--e1aybc.xn--p1ai
|
|
||||||
expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)");
|
|
||||||
expect(router.rule).not.toContain("тест.рф");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ASCII domain remains unchanged", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, host: "example.com" },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.rule).toContain("Host(`example.com`)");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, host: "сайт.ru" },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// сайт in punycode is xn--80aswg
|
|
||||||
expect(router.rule).toContain("Host(`xn--80aswg.ru`)");
|
|
||||||
expect(router.rule).not.toContain("сайт");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, host: "app.тест.рф" },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai
|
|
||||||
expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)");
|
|
||||||
expect(router.rule).not.toContain("тест.рф");
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const BASE = "/base";
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
|
||||||
const actual =
|
|
||||||
await importOriginal<typeof import("@dokploy/server/constants")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
paths: () => ({
|
|
||||||
...actual.paths(),
|
|
||||||
BASE_PATH: BASE,
|
|
||||||
LOGS_PATH: `${BASE}/logs`,
|
|
||||||
APPLICATIONS_PATH: `${BASE}/applications`,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import after mock so paths() uses our BASE
|
|
||||||
const { readValidDirectory } = await import("@dokploy/server");
|
|
||||||
|
|
||||||
describe("readValidDirectory (path traversal)", () => {
|
|
||||||
it("returns true when directory is exactly BASE_PATH", () => {
|
|
||||||
expect(readValidDirectory(BASE)).toBe(true);
|
|
||||||
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true when directory is under BASE_PATH", () => {
|
|
||||||
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
|
|
||||||
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
|
|
||||||
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for path traversal escaping base (absolute)", () => {
|
|
||||||
expect(readValidDirectory("/etc/passwd")).toBe(false);
|
|
||||||
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
|
|
||||||
expect(readValidDirectory("/tmp/outside")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when resolved path escapes base via ..", () => {
|
|
||||||
// Resolved: /etc/passwd (outside /base)
|
|
||||||
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
|
|
||||||
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
|
|
||||||
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true when .. stays within base", () => {
|
|
||||||
// e.g. /base/logs/../applications -> /base/applications (still under /base)
|
|
||||||
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
|
|
||||||
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts serverId for remote base path", () => {
|
|
||||||
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
|
|
||||||
expect(readValidDirectory(BASE, "server-1")).toBe(true);
|
|
||||||
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for null/undefined-like paths that resolve outside", () => {
|
|
||||||
// Paths that might resolve to cwd or root
|
|
||||||
expect(readValidDirectory(".")).toBe(false);
|
|
||||||
expect(readValidDirectory("..")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
|
|
||||||
expect(readValidDirectory(`${BASE}/`)).toBe(true);
|
|
||||||
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
|
|
||||||
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when path looks like base but is a sibling or prefix", () => {
|
|
||||||
expect(readValidDirectory("/base-evil")).toBe(false);
|
|
||||||
expect(readValidDirectory("/bas")).toBe(false);
|
|
||||||
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for empty string (resolves to cwd)", () => {
|
|
||||||
expect(readValidDirectory("")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
isValidContainerId,
|
|
||||||
isValidSearch,
|
|
||||||
isValidSince,
|
|
||||||
isValidTail,
|
|
||||||
} from "../../server/wss/utils";
|
|
||||||
|
|
||||||
describe("isValidTail (docker-container-logs)", () => {
|
|
||||||
it("accepts valid numeric tail values", () => {
|
|
||||||
expect(isValidTail("0")).toBe(true);
|
|
||||||
expect(isValidTail("1")).toBe(true);
|
|
||||||
expect(isValidTail("100")).toBe(true);
|
|
||||||
expect(isValidTail("10000")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects tail above 10000", () => {
|
|
||||||
expect(isValidTail("10001")).toBe(false);
|
|
||||||
expect(isValidTail("99999")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-numeric tail", () => {
|
|
||||||
expect(isValidTail("")).toBe(false);
|
|
||||||
expect(isValidTail("abc")).toBe(false);
|
|
||||||
expect(isValidTail("10a")).toBe(false);
|
|
||||||
expect(isValidTail("-1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects command injection payloads in tail", () => {
|
|
||||||
expect(isValidTail("10; whoami; #")).toBe(false);
|
|
||||||
expect(isValidTail("100 | cat /etc/passwd")).toBe(false);
|
|
||||||
expect(isValidTail("$(id)")).toBe(false);
|
|
||||||
expect(isValidTail("`id`")).toBe(false);
|
|
||||||
expect(isValidTail("100\nid")).toBe(false);
|
|
||||||
expect(isValidTail("100 && id")).toBe(false);
|
|
||||||
expect(isValidTail("100; env | grep DATABASE")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isValidSince (docker-container-logs)", () => {
|
|
||||||
it("accepts 'all'", () => {
|
|
||||||
expect(isValidSince("all")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts valid duration format (number + s|m|h|d)", () => {
|
|
||||||
expect(isValidSince("5s")).toBe(true);
|
|
||||||
expect(isValidSince("10m")).toBe(true);
|
|
||||||
expect(isValidSince("1h")).toBe(true);
|
|
||||||
expect(isValidSince("2d")).toBe(true);
|
|
||||||
expect(isValidSince("0s")).toBe(true);
|
|
||||||
expect(isValidSince("999d")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid duration format", () => {
|
|
||||||
expect(isValidSince("")).toBe(false);
|
|
||||||
expect(isValidSince("5")).toBe(false);
|
|
||||||
expect(isValidSince("s")).toBe(false);
|
|
||||||
expect(isValidSince("5x")).toBe(false);
|
|
||||||
expect(isValidSince("5sec")).toBe(false);
|
|
||||||
expect(isValidSince("5 m")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects command injection payloads in since", () => {
|
|
||||||
expect(isValidSince("5s; whoami")).toBe(false);
|
|
||||||
expect(isValidSince("all; id")).toBe(false);
|
|
||||||
expect(isValidSince("1m$(id)")).toBe(false);
|
|
||||||
expect(isValidSince("1m | cat /etc/passwd")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isValidSearch (docker-container-logs)", () => {
|
|
||||||
it("accepts empty string", () => {
|
|
||||||
expect(isValidSearch("")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts only alphanumeric, space, dot, underscore, hyphen", () => {
|
|
||||||
expect(isValidSearch("error")).toBe(true);
|
|
||||||
expect(isValidSearch("foo bar")).toBe(true);
|
|
||||||
expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true);
|
|
||||||
expect(isValidSearch("")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects strings longer than 500 chars", () => {
|
|
||||||
expect(isValidSearch("a".repeat(501))).toBe(false);
|
|
||||||
expect(isValidSearch("a".repeat(500))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects control characters and non-printable", () => {
|
|
||||||
expect(isValidSearch("foo\nbar")).toBe(false);
|
|
||||||
expect(isValidSearch("foo\rbar")).toBe(false);
|
|
||||||
expect(isValidSearch("\x00")).toBe(false);
|
|
||||||
expect(isValidSearch("a\x19b")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects command injection vectors in search (search is concatenated into shell)", () => {
|
|
||||||
// Double-quoted context (SSH line 99): $ and ` execute
|
|
||||||
expect(isValidSearch("$(whoami)")).toBe(false);
|
|
||||||
expect(isValidSearch("`id`")).toBe(false);
|
|
||||||
expect(isValidSearch("$(id)")).toBe(false);
|
|
||||||
// Single-quoted context (local line 153): ' breaks out
|
|
||||||
expect(isValidSearch("'$(whoami)'")).toBe(false);
|
|
||||||
expect(isValidSearch("error'")).toBe(false);
|
|
||||||
expect(isValidSearch("'; whoami; #")).toBe(false);
|
|
||||||
// Other shell-metacharacters
|
|
||||||
expect(isValidSearch("error; id")).toBe(false);
|
|
||||||
expect(isValidSearch("a|b")).toBe(false);
|
|
||||||
expect(isValidSearch('error"')).toBe(false);
|
|
||||||
expect(isValidSearch("a&b")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isValidContainerId (docker-container-logs)", () => {
|
|
||||||
it("accepts valid hex container IDs", () => {
|
|
||||||
expect(isValidContainerId("a".repeat(12))).toBe(true);
|
|
||||||
expect(isValidContainerId("abc123def456")).toBe(true);
|
|
||||||
expect(isValidContainerId("a".repeat(64))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts valid container names", () => {
|
|
||||||
expect(isValidContainerId("my-container")).toBe(true);
|
|
||||||
expect(isValidContainerId("app_1")).toBe(true);
|
|
||||||
expect(isValidContainerId("service.name")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects command injection in container ID", () => {
|
|
||||||
expect(isValidContainerId("dummy; whoami")).toBe(false);
|
|
||||||
expect(isValidContainerId("$(id)")).toBe(false);
|
|
||||||
expect(isValidContainerId("`id`")).toBe(false);
|
|
||||||
expect(isValidContainerId("container|cat /etc/passwd")).toBe(false);
|
|
||||||
expect(isValidContainerId("x; env | grep DATABASE")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -105,14 +105,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
|
|
||||||
const modeData =
|
const modeData =
|
||||||
formData.type === "Replicated"
|
formData.type === "Replicated"
|
||||||
? {
|
? { Replicated: { Replicas: formData.Replicas } }
|
||||||
Replicated: {
|
|
||||||
Replicas:
|
|
||||||
formData.Replicas !== undefined && formData.Replicas !== ""
|
|
||||||
? Number(formData.Replicas)
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: { Global: {} };
|
: { Global: {} };
|
||||||
|
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ban } from "lucide-react";
|
import { Paintbrush } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -35,7 +35,7 @@ export const CancelQueues = ({ id, type }: Props) => {
|
|||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
||||||
Cancel Queues
|
Cancel Queues
|
||||||
<Ban className="size-4" />
|
<Paintbrush className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { Paintbrush } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
type: "application" | "compose";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ClearDeployments = ({ id, type }: Props) => {
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync, isLoading } =
|
|
||||||
type === "application"
|
|
||||||
? api.application.clearDeployments.useMutation()
|
|
||||||
: api.compose.clearDeployments.useMutation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="outline" className="w-fit" isLoading={isLoading}>
|
|
||||||
Clear deployments
|
|
||||||
<Paintbrush className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Are you sure you want to clear old deployments?
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will delete all old deployment records and logs, keeping only
|
|
||||||
the active deployment (the most recent successful one).
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
applicationId: id || "",
|
|
||||||
composeId: id || "",
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Old deployments cleared successfully");
|
|
||||||
await utils.deployment.allByType.invalidate({
|
|
||||||
id,
|
|
||||||
type: type as "application" | "compose",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
Settings,
|
Settings,
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -26,7 +25,6 @@ import {
|
|||||||
import { api, type RouterOutputs } from "@/utils/api";
|
import { api, type RouterOutputs } from "@/utils/api";
|
||||||
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
||||||
import { CancelQueues } from "./cancel-queues";
|
import { CancelQueues } from "./cancel-queues";
|
||||||
import { ClearDeployments } from "./clear-deployments";
|
|
||||||
import { KillBuild } from "./kill-build";
|
import { KillBuild } from "./kill-build";
|
||||||
import { RefreshToken } from "./refresh-token";
|
import { RefreshToken } from "./refresh-token";
|
||||||
import { ShowDeployment } from "./show-deployment";
|
import { ShowDeployment } from "./show-deployment";
|
||||||
@@ -79,8 +77,6 @@ export const ShowDeployments = ({
|
|||||||
api.rollback.rollback.useMutation();
|
api.rollback.rollback.useMutation();
|
||||||
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
||||||
api.deployment.killProcess.useMutation();
|
api.deployment.killProcess.useMutation();
|
||||||
const { mutateAsync: removeDeployment, isLoading: isRemovingDeployment } =
|
|
||||||
api.deployment.removeDeployment.useMutation();
|
|
||||||
|
|
||||||
// Cancel deployment mutations
|
// Cancel deployment mutations
|
||||||
const {
|
const {
|
||||||
@@ -148,9 +144,6 @@ export const ShowDeployments = ({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center flex-wrap gap-2">
|
<div className="flex flex-row items-center flex-wrap gap-2">
|
||||||
{(type === "application" || type === "compose") && (
|
|
||||||
<ClearDeployments id={id} type={type} />
|
|
||||||
)}
|
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<KillBuild id={id} type={type} />
|
<KillBuild id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
@@ -259,8 +252,6 @@ export const ShowDeployments = ({
|
|||||||
const isExpanded = expandedDescriptions.has(
|
const isExpanded = expandedDescriptions.has(
|
||||||
deployment.deploymentId,
|
deployment.deploymentId,
|
||||||
);
|
);
|
||||||
const canDelete =
|
|
||||||
deployment.status === "done" || deployment.status === "error";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -379,33 +370,6 @@ export const ShowDeployments = ({
|
|||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{canDelete && (
|
|
||||||
<DialogAction
|
|
||||||
title="Delete Deployment"
|
|
||||||
description="Are you sure you want to delete this deployment? This action cannot be undone."
|
|
||||||
type="default"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await removeDeployment({
|
|
||||||
deploymentId: deployment.deploymentId,
|
|
||||||
});
|
|
||||||
toast.success("Deployment deleted successfully");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Error deleting deployment");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
isLoading={isRemovingDeployment}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{deployment?.rollback &&
|
{deployment?.rollback &&
|
||||||
deployment.status === "done" &&
|
deployment.status === "done" &&
|
||||||
type === "application" && (
|
type === "application" && (
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./show-patches";
|
||||||
|
export * from "./patch-editor";
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { ArrowLeft, ChevronRight, File, Folder, Loader2, Save } from "lucide-react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import type { RouterOutputs } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
applicationId?: string;
|
||||||
|
composeId?: string;
|
||||||
|
repoPath: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirectoryEntry = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
children?: DirectoryEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PatchEditor = ({
|
||||||
|
applicationId,
|
||||||
|
composeId,
|
||||||
|
repoPath,
|
||||||
|
onClose,
|
||||||
|
}: Props) => {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
const [fileContent, setFileContent] = useState<string>("");
|
||||||
|
const [originalContent, setOriginalContent] = useState<string>("");
|
||||||
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Fetch directory tree
|
||||||
|
const { data: directories, isLoading: isDirLoading } =
|
||||||
|
api.patch.readRepoDirectories.useQuery(
|
||||||
|
{ applicationId, composeId, repoPath },
|
||||||
|
{ enabled: !!repoPath },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save mutation
|
||||||
|
const saveAsPatch = api.patch.saveFileAsPatch.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setIsSaving(false);
|
||||||
|
if (result.deleted) {
|
||||||
|
toast.success("No changes - patch removed");
|
||||||
|
} else {
|
||||||
|
toast.success("Patch saved");
|
||||||
|
}
|
||||||
|
setOriginalContent(fileContent);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setIsSaving(false);
|
||||||
|
toast.error("Failed to save patch");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read file content when selected
|
||||||
|
const { data: fileData, isFetching: isFileLoading } =
|
||||||
|
api.patch.readRepoFile.useQuery(
|
||||||
|
{
|
||||||
|
applicationId,
|
||||||
|
composeId,
|
||||||
|
repoPath,
|
||||||
|
filePath: selectedFile || "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!selectedFile,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setFileContent(data.content);
|
||||||
|
setOriginalContent(data.content);
|
||||||
|
if (data.patchError) {
|
||||||
|
toast.error(data.patchErrorMessage || "Failed to apply patch");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileSelect = (filePath: string) => {
|
||||||
|
setSelectedFile(filePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFolder = (path: string) => {
|
||||||
|
setExpandedFolders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(path)) {
|
||||||
|
next.delete(path);
|
||||||
|
} else {
|
||||||
|
next.add(path);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
saveAsPatch.mutate({
|
||||||
|
applicationId,
|
||||||
|
composeId,
|
||||||
|
repoPath,
|
||||||
|
filePath: selectedFile,
|
||||||
|
content: fileContent,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = fileContent !== originalContent;
|
||||||
|
|
||||||
|
const renderTree = useCallback(
|
||||||
|
(entries: DirectoryEntry[], depth = 0) => {
|
||||||
|
return entries
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Directories first, then alphabetically
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return a.type === "directory" ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
})
|
||||||
|
.map((entry) => {
|
||||||
|
const isExpanded = expandedFolders.has(entry.path);
|
||||||
|
const isSelected = selectedFile === entry.path;
|
||||||
|
|
||||||
|
if (entry.type === "directory") {
|
||||||
|
return (
|
||||||
|
<div key={entry.path}>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleFolder(entry.path)}
|
||||||
|
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors`}
|
||||||
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-4 w-4 transition-transform ${
|
||||||
|
isExpanded ? "rotate-90" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<Folder className="h-4 w-4 text-blue-500" />
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && entry.children && (
|
||||||
|
<div>{renderTree(entry.children, depth + 1)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.path}
|
||||||
|
onClick={() => handleFileSelect(entry.path)}
|
||||||
|
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${
|
||||||
|
isSelected ? "bg-muted" : ""
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${depth * 12 + 28}px` }}
|
||||||
|
>
|
||||||
|
<File className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[expandedFolders, selectedFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-background overflow-hidden">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Edit File</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{selectedFile
|
||||||
|
? `Editing: ${selectedFile}`
|
||||||
|
: "Select a file from the tree to edit"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedFile && (
|
||||||
|
<Button onClick={handleSave} disabled={isSaving || !hasChanges}>
|
||||||
|
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Patch
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
|
||||||
|
{/* File Tree */}
|
||||||
|
<div className="border-r h-full overflow-hidden">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-2">
|
||||||
|
{isDirLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : directories ? (
|
||||||
|
renderTree(directories)
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground p-4">
|
||||||
|
No files found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
{/* Editor */}
|
||||||
|
<div className="h-full overflow-hidden relative">
|
||||||
|
{isFileLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : selectedFile ? (
|
||||||
|
<CodeEditor
|
||||||
|
value={fileContent}
|
||||||
|
onChange={(value) => setFileContent(value || "")}
|
||||||
|
className="h-full w-full"
|
||||||
|
wrapperClassName="h-full"
|
||||||
|
lineWrapping
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
Select a file to edit
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { AlertCircle, ChevronRight, File, Folder, Loader2, Power, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import type { RouterOutputs } from "@/utils/api";
|
||||||
|
import { PatchEditor } from "./patch-editor";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
applicationId?: string;
|
||||||
|
composeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Patch = RouterOutputs["patch"]["byApplicationId"][number];
|
||||||
|
|
||||||
|
export const ShowPatches = ({ applicationId, composeId }: Props) => {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
const [repoPath, setRepoPath] = useState<string | null>(null);
|
||||||
|
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
// Fetch patches
|
||||||
|
// Fetch patches
|
||||||
|
const { data: appPatches, isLoading: isAppPatchesLoading } =
|
||||||
|
api.patch.byApplicationId.useQuery(
|
||||||
|
{ applicationId: applicationId! },
|
||||||
|
{ enabled: !!applicationId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: composePatches, isLoading: isComposePatchesLoading } =
|
||||||
|
api.patch.byComposeId.useQuery(
|
||||||
|
{ composeId: composeId! },
|
||||||
|
{ enabled: !!composeId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const patches = applicationId ? appPatches : composePatches;
|
||||||
|
const isPatchesLoading = applicationId
|
||||||
|
? isAppPatchesLoading
|
||||||
|
: isComposePatchesLoading;
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const deletePatch = api.patch.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Patch deleted");
|
||||||
|
if (applicationId) {
|
||||||
|
utils.patch.byApplicationId.invalidate({ applicationId });
|
||||||
|
} else if (composeId) {
|
||||||
|
utils.patch.byComposeId.invalidate({ composeId });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to delete patch");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const togglePatch = api.patch.toggleEnabled.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Patch updated");
|
||||||
|
if (applicationId) {
|
||||||
|
utils.patch.byApplicationId.invalidate({ applicationId });
|
||||||
|
} else if (composeId) {
|
||||||
|
utils.patch.byComposeId.invalidate({ composeId });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to update patch");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensureRepo = api.patch.ensureRepo.useMutation();
|
||||||
|
|
||||||
|
const handleOpenEditor = async () => {
|
||||||
|
setIsLoadingRepo(true);
|
||||||
|
const toastId = toast.loading("Syncing repository...");
|
||||||
|
ensureRepo.mutate(
|
||||||
|
{ applicationId, composeId },
|
||||||
|
{
|
||||||
|
onSuccess: (path) => {
|
||||||
|
setRepoPath(path);
|
||||||
|
setIsLoadingRepo(false);
|
||||||
|
toast.dismiss(toastId);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setIsLoadingRepo(false);
|
||||||
|
toast.dismiss(toastId);
|
||||||
|
toast.error("Failed to load repository");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePatch = (patchId: string) => {
|
||||||
|
deletePatch.mutate({ patchId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePatch = (patchId: string, enabled: boolean) => {
|
||||||
|
togglePatch.mutate({ patchId, enabled });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseEditor = () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setRepoPath(null);
|
||||||
|
if (applicationId) {
|
||||||
|
utils.patch.byApplicationId.invalidate({ applicationId });
|
||||||
|
} else if (composeId) {
|
||||||
|
utils.patch.byComposeId.invalidate({ composeId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (repoPath) {
|
||||||
|
return (
|
||||||
|
<PatchEditor
|
||||||
|
applicationId={applicationId}
|
||||||
|
composeId={composeId}
|
||||||
|
repoPath={repoPath}
|
||||||
|
onClose={handleCloseEditor}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Patches</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Apply code patches to your repository during build. Patches are applied after
|
||||||
|
cloning the repository and before building.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
|
||||||
|
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Create Patch
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isPatchesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : !patches || patches.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>No patches</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
No patches have been created for this application yet. Click "Create Patch"
|
||||||
|
to add modifications to your code during build.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>File Path</TableHead>
|
||||||
|
<TableHead className="w-[100px]">Enabled</TableHead>
|
||||||
|
<TableHead className="w-[80px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{patches.map((patch: Patch) => (
|
||||||
|
<TableRow key={patch.patchId}>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<File className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{patch.filePath}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
checked={patch.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleTogglePatch(patch.patchId, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeletePatch(patch.patchId)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
PushoverIcon,
|
PushoverIcon,
|
||||||
ResendIcon,
|
ResendIcon,
|
||||||
SlackIcon,
|
SlackIcon,
|
||||||
TeamsIcon,
|
|
||||||
TelegramIcon,
|
TelegramIcon,
|
||||||
} from "@/components/icons/notification-icons";
|
} from "@/components/icons/notification-icons";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -165,12 +164,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
|||||||
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
||||||
})
|
})
|
||||||
.merge(notificationBaseSchema),
|
.merge(notificationBaseSchema),
|
||||||
z
|
|
||||||
.object({
|
|
||||||
type: z.literal("teams"),
|
|
||||||
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
|
||||||
})
|
|
||||||
.merge(notificationBaseSchema),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const notificationsMap = {
|
export const notificationsMap = {
|
||||||
@@ -190,10 +183,6 @@ export const notificationsMap = {
|
|||||||
icon: <LarkIcon className="text-muted-foreground" />,
|
icon: <LarkIcon className="text-muted-foreground" />,
|
||||||
label: "Lark",
|
label: "Lark",
|
||||||
},
|
},
|
||||||
teams: {
|
|
||||||
icon: <TeamsIcon className="text-muted-foreground" />,
|
|
||||||
label: "Microsoft Teams",
|
|
||||||
},
|
|
||||||
email: {
|
email: {
|
||||||
icon: <Mail size={29} className="text-muted-foreground" />,
|
icon: <Mail size={29} className="text-muted-foreground" />,
|
||||||
label: "Email",
|
label: "Email",
|
||||||
@@ -255,8 +244,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
api.notification.testNtfyConnection.useMutation();
|
api.notification.testNtfyConnection.useMutation();
|
||||||
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
||||||
api.notification.testLarkConnection.useMutation();
|
api.notification.testLarkConnection.useMutation();
|
||||||
const { mutateAsync: testTeamsConnection, isLoading: isLoadingTeams } =
|
|
||||||
api.notification.testTeamsConnection.useMutation();
|
|
||||||
|
|
||||||
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
||||||
api.notification.testCustomConnection.useMutation();
|
api.notification.testCustomConnection.useMutation();
|
||||||
@@ -291,9 +278,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
const larkMutation = notificationId
|
const larkMutation = notificationId
|
||||||
? api.notification.updateLark.useMutation()
|
? api.notification.updateLark.useMutation()
|
||||||
: api.notification.createLark.useMutation();
|
: api.notification.createLark.useMutation();
|
||||||
const teamsMutation = notificationId
|
|
||||||
? api.notification.updateTeams.useMutation()
|
|
||||||
: api.notification.createTeams.useMutation();
|
|
||||||
const pushoverMutation = notificationId
|
const pushoverMutation = notificationId
|
||||||
? api.notification.updatePushover.useMutation()
|
? api.notification.updatePushover.useMutation()
|
||||||
: api.notification.createPushover.useMutation();
|
: api.notification.createPushover.useMutation();
|
||||||
@@ -369,7 +353,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
webhookUrl: notification.discord?.webhookUrl,
|
webhookUrl: notification.discord?.webhookUrl,
|
||||||
decoration: notification.discord?.decoration ?? undefined,
|
decoration: notification.discord?.decoration || undefined,
|
||||||
name: notification.name,
|
name: notification.name,
|
||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
serverThreshold: notification.serverThreshold,
|
serverThreshold: notification.serverThreshold,
|
||||||
@@ -416,7 +400,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
appToken: notification.gotify?.appToken,
|
appToken: notification.gotify?.appToken,
|
||||||
decoration: notification.gotify?.decoration ?? undefined,
|
decoration: notification.gotify?.decoration || undefined,
|
||||||
priority: notification.gotify?.priority,
|
priority: notification.gotify?.priority,
|
||||||
serverUrl: notification.gotify?.serverUrl,
|
serverUrl: notification.gotify?.serverUrl,
|
||||||
name: notification.name,
|
name: notification.name,
|
||||||
@@ -451,19 +435,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
serverThreshold: notification.serverThreshold,
|
serverThreshold: notification.serverThreshold,
|
||||||
});
|
});
|
||||||
} else if (notification.notificationType === "teams") {
|
|
||||||
form.reset({
|
|
||||||
appBuildError: notification.appBuildError,
|
|
||||||
appDeploy: notification.appDeploy,
|
|
||||||
dokployRestart: notification.dokployRestart,
|
|
||||||
databaseBackup: notification.databaseBackup,
|
|
||||||
volumeBackup: notification.volumeBackup,
|
|
||||||
type: notification.notificationType,
|
|
||||||
webhookUrl: notification.teams?.webhookUrl,
|
|
||||||
name: notification.name,
|
|
||||||
dockerCleanup: notification.dockerCleanup,
|
|
||||||
serverThreshold: notification.serverThreshold,
|
|
||||||
});
|
|
||||||
} else if (notification.notificationType === "custom") {
|
} else if (notification.notificationType === "custom") {
|
||||||
form.reset({
|
form.reset({
|
||||||
appBuildError: notification.appBuildError,
|
appBuildError: notification.appBuildError,
|
||||||
@@ -517,7 +488,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
gotify: gotifyMutation,
|
gotify: gotifyMutation,
|
||||||
ntfy: ntfyMutation,
|
ntfy: ntfyMutation,
|
||||||
lark: larkMutation,
|
lark: larkMutation,
|
||||||
teams: teamsMutation,
|
|
||||||
custom: customMutation,
|
custom: customMutation,
|
||||||
pushover: pushoverMutation,
|
pushover: pushoverMutation,
|
||||||
};
|
};
|
||||||
@@ -660,20 +630,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
larkId: notification?.larkId || "",
|
larkId: notification?.larkId || "",
|
||||||
serverThreshold: serverThreshold,
|
serverThreshold: serverThreshold,
|
||||||
});
|
});
|
||||||
} else if (data.type === "teams") {
|
|
||||||
promise = teamsMutation.mutateAsync({
|
|
||||||
appBuildError: appBuildError,
|
|
||||||
appDeploy: appDeploy,
|
|
||||||
dokployRestart: dokployRestart,
|
|
||||||
databaseBackup: databaseBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
|
||||||
webhookUrl: data.webhookUrl,
|
|
||||||
name: data.name,
|
|
||||||
dockerCleanup: dockerCleanup,
|
|
||||||
notificationId: notificationId || "",
|
|
||||||
teamsId: notification?.teamsId || "",
|
|
||||||
serverThreshold: serverThreshold,
|
|
||||||
});
|
|
||||||
} else if (data.type === "custom") {
|
} else if (data.type === "custom") {
|
||||||
// Convert headers array to object
|
// Convert headers array to object
|
||||||
const headersRecord =
|
const headersRecord =
|
||||||
@@ -1509,32 +1465,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === "teams" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="webhookUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Webhook URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="https://xxx.webhook.office.com/webhookb2/..."
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Incoming Webhook URL from a Teams channel. Add an
|
|
||||||
Incoming Webhook in your channel settings to get the
|
|
||||||
URL.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{type === "pushover" && (
|
{type === "pushover" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1850,7 +1780,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
isLoadingGotify ||
|
isLoadingGotify ||
|
||||||
isLoadingNtfy ||
|
isLoadingNtfy ||
|
||||||
isLoadingLark ||
|
isLoadingLark ||
|
||||||
isLoadingTeams ||
|
|
||||||
isLoadingCustom ||
|
isLoadingCustom ||
|
||||||
isLoadingPushover
|
isLoadingPushover
|
||||||
}
|
}
|
||||||
@@ -1912,10 +1841,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
await testLarkConnection({
|
await testLarkConnection({
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
});
|
});
|
||||||
} else if (data.type === "teams") {
|
|
||||||
await testTeamsConnection({
|
|
||||||
webhookUrl: data.webhookUrl,
|
|
||||||
});
|
|
||||||
} else if (data.type === "custom") {
|
} else if (data.type === "custom") {
|
||||||
const headersRecord =
|
const headersRecord =
|
||||||
data.headers && data.headers.length > 0
|
data.headers && data.headers.length > 0
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
NtfyIcon,
|
NtfyIcon,
|
||||||
ResendIcon,
|
ResendIcon,
|
||||||
SlackIcon,
|
SlackIcon,
|
||||||
TeamsIcon,
|
|
||||||
TelegramIcon,
|
TelegramIcon,
|
||||||
} from "@/components/icons/notification-icons";
|
} from "@/components/icons/notification-icons";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
@@ -38,7 +37,7 @@ export const ShowNotifications = () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Add your providers to receive notifications, like Discord, Slack,
|
Add your providers to receive notifications, like Discord, Slack,
|
||||||
Telegram, Teams, Email, Resend, Lark.
|
Telegram, Email, Resend, Lark.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
@@ -113,11 +112,6 @@ export const ShowNotifications = () => {
|
|||||||
<LarkIcon className="size-7 text-muted-foreground" />
|
<LarkIcon className="size-7 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{notification.notificationType === "teams" && (
|
|
||||||
<div className="flex items-center justify-center rounded-lg">
|
|
||||||
<TeamsIcon className="size-7 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{notification.name}
|
{notification.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Loader2, Palette, User } from "lucide-react";
|
import { Loader2, Palette, User } from "lucide-react";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -72,6 +73,7 @@ export const ProfileForm = () => {
|
|||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = api.user.update.useMutation();
|
} = api.user.update.useMutation();
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -155,10 +157,10 @@ export const ProfileForm = () => {
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
<User className="size-6 text-muted-foreground self-center" />
|
<User className="size-6 text-muted-foreground self-center" />
|
||||||
Account
|
{t("settings.profile.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Change the details of your profile here.
|
{t("settings.profile.description")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,9 +213,12 @@ export const ProfileForm = () => {
|
|||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>{t("settings.profile.email")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Email" {...field} />
|
<Input
|
||||||
|
placeholder={t("settings.profile.email")}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -228,7 +233,7 @@ export const ProfileForm = () => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Current Password"
|
placeholder={t("settings.profile.password")}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
/>
|
/>
|
||||||
@@ -242,11 +247,13 @@ export const ProfileForm = () => {
|
|||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>
|
||||||
|
{t("settings.profile.password")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
placeholder={t("settings.profile.password")}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
/>
|
/>
|
||||||
@@ -261,7 +268,9 @@ export const ProfileForm = () => {
|
|||||||
name="image"
|
name="image"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Avatar</FormLabel>
|
<FormLabel>
|
||||||
|
{t("settings.profile.avatar")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
onValueChange={(e) => {
|
onValueChange={(e) => {
|
||||||
@@ -445,7 +454,7 @@ export const ProfileForm = () => {
|
|||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button type="submit" isLoading={isUpdating}>
|
<Button type="submit" isLoading={isUpdating}>
|
||||||
Save
|
{t("settings.common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from "next-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
|
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -16,6 +17,7 @@ import { TerminalModal } from "../../web-server/terminal-modal";
|
|||||||
import { GPUSupportModal } from "../gpu-support-modal";
|
import { GPUSupportModal } from "../gpu-support-modal";
|
||||||
|
|
||||||
export const ShowDokployActions = () => {
|
export const ShowDokployActions = () => {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
const { mutateAsync: reloadServer, isLoading } =
|
const { mutateAsync: reloadServer, isLoading } =
|
||||||
api.settings.reloadServer.useMutation();
|
api.settings.reloadServer.useMutation();
|
||||||
|
|
||||||
@@ -28,11 +30,13 @@ export const ShowDokployActions = () => {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild disabled={isLoading}>
|
<DropdownMenuTrigger asChild disabled={isLoading}>
|
||||||
<Button isLoading={isLoading} variant="outline">
|
<Button isLoading={isLoading} variant="outline">
|
||||||
Server
|
{t("settings.server.webServer.server.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56" align="start">
|
<DropdownMenuContent className="w-56" align="start">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
{t("settings.server.webServer.actions")}
|
||||||
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -47,17 +51,17 @@ export const ShowDokployActions = () => {
|
|||||||
}}
|
}}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<span>Reload</span>
|
<span>{t("settings.server.webServer.reload")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<TerminalModal serverId="local">
|
<TerminalModal serverId="local">
|
||||||
<span>Terminal</span>
|
<span>{t("settings.common.enterTerminal")}</span>
|
||||||
</TerminalModal>
|
</TerminalModal>
|
||||||
<ShowModalLogs appName="dokploy">
|
<ShowModalLogs appName="dokploy">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
View Logs
|
{t("settings.server.webServer.watchLogs")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</ShowModalLogs>
|
</ShowModalLogs>
|
||||||
<GPUSupportModal />
|
<GPUSupportModal />
|
||||||
@@ -66,7 +70,7 @@ export const ShowDokployActions = () => {
|
|||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
Update Server IP
|
{t("settings.server.webServer.updateServerIp")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</UpdateServerIp>
|
</UpdateServerIp>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from "next-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,7 @@ interface Props {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
export const ShowStorageActions = ({ serverId }: Props) => {
|
export const ShowStorageActions = ({ serverId }: Props) => {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
|
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
|
||||||
api.settings.cleanAll.useMutation();
|
api.settings.cleanAll.useMutation();
|
||||||
|
|
||||||
@@ -40,6 +42,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
isLoading: cleanStoppedContainersIsLoading,
|
isLoading: cleanStoppedContainersIsLoading,
|
||||||
} = api.settings.cleanStoppedContainers.useMutation();
|
} = api.settings.cleanStoppedContainers.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: cleanPatchRepos, isLoading: cleanPatchReposIsLoading } =
|
||||||
|
api.patch.cleanPatchRepos.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -49,7 +54,8 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
cleanDockerBuilderIsLoading ||
|
cleanDockerBuilderIsLoading ||
|
||||||
cleanUnusedImagesIsLoading ||
|
cleanUnusedImagesIsLoading ||
|
||||||
cleanUnusedVolumesIsLoading ||
|
cleanUnusedVolumesIsLoading ||
|
||||||
cleanStoppedContainersIsLoading
|
cleanStoppedContainersIsLoading ||
|
||||||
|
cleanPatchReposIsLoading
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -58,15 +64,18 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
cleanDockerBuilderIsLoading ||
|
cleanDockerBuilderIsLoading ||
|
||||||
cleanUnusedImagesIsLoading ||
|
cleanUnusedImagesIsLoading ||
|
||||||
cleanUnusedVolumesIsLoading ||
|
cleanUnusedVolumesIsLoading ||
|
||||||
cleanStoppedContainersIsLoading
|
cleanStoppedContainersIsLoading ||
|
||||||
|
cleanPatchReposIsLoading
|
||||||
}
|
}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
Space
|
{t("settings.server.webServer.storage.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-64" align="start">
|
<DropdownMenuContent className="w-64" align="start">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
{t("settings.server.webServer.actions")}
|
||||||
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -83,7 +92,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Clean unused images</span>
|
<span>
|
||||||
|
{t("settings.server.webServer.storage.cleanUnusedImages")}
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
@@ -99,7 +110,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Clean unused volumes</span>
|
<span>
|
||||||
|
{t("settings.server.webServer.storage.cleanUnusedVolumes")}
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -116,7 +129,26 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Clean stopped containers</span>
|
<span>
|
||||||
|
{t("settings.server.webServer.storage.cleanStoppedContainers")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onClick={async () => {
|
||||||
|
await cleanPatchRepos({
|
||||||
|
serverId: serverId,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Cleaned Patch Caches");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error cleaning Patch Caches");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Clean Patch Caches</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -133,7 +165,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Clean Docker Builder & System</span>
|
<span>
|
||||||
|
{t("settings.server.webServer.storage.cleanDockerBuilder")}
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{!serverId && (
|
{!serverId && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -148,7 +182,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Clean Monitoring</span>
|
<span>
|
||||||
|
{t("settings.server.webServer.storage.cleanMonitoring")}
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -166,7 +202,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Clean all</span>
|
<span>{t("settings.server.webServer.storage.cleanAll")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from "next-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
@@ -21,6 +22,7 @@ interface Props {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
export const ShowTraefikActions = ({ serverId }: Props) => {
|
export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
|
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
|
||||||
api.settings.reloadTraefik.useMutation();
|
api.settings.reloadTraefik.useMutation();
|
||||||
|
|
||||||
@@ -73,11 +75,13 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
}
|
}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
Traefik
|
{t("settings.server.webServer.traefik.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56" align="start">
|
<DropdownMenuContent className="w-56" align="start">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
{t("settings.server.webServer.actions")}
|
||||||
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -96,7 +100,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
disabled={isReloadHealthCheckExecuting}
|
disabled={isReloadHealthCheckExecuting}
|
||||||
>
|
>
|
||||||
<span>Reload</span>
|
<span>{t("settings.server.webServer.reload")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<ShowModalLogs
|
<ShowModalLogs
|
||||||
appName="dokploy-traefik"
|
appName="dokploy-traefik"
|
||||||
@@ -107,7 +111,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
View Logs
|
{t("settings.server.webServer.watchLogs")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</ShowModalLogs>
|
</ShowModalLogs>
|
||||||
<EditTraefikEnv serverId={serverId}>
|
<EditTraefikEnv serverId={serverId}>
|
||||||
@@ -115,7 +119,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<span>Modify Environment</span>
|
<span>{t("settings.server.webServer.traefik.modifyEnv")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</EditTraefikEnv>
|
</EditTraefikEnv>
|
||||||
|
|
||||||
@@ -172,7 +176,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<span>Additional Port Mappings</span>
|
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</ManageTraefikPorts>
|
</ManageTraefikPorts>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Pencil, PlusIcon } from "lucide-react";
|
import { Pencil, PlusIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -62,6 +63,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: canCreateMoreServers, refetch } =
|
const { data: canCreateMoreServers, refetch } =
|
||||||
@@ -362,7 +365,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
name="ipAddress"
|
name="ipAddress"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>IP Address</FormLabel>
|
<FormLabel>{t("settings.terminal.ipAddress")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="192.168.1.100" {...field} />
|
<Input placeholder="192.168.1.100" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -376,7 +379,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
name="port"
|
name="port"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Port</FormLabel>
|
<FormLabel>{t("settings.terminal.port")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="22"
|
placeholder="22"
|
||||||
@@ -406,7 +409,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
name="username"
|
name="username"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>{t("settings.terminal.username")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="root" {...field} />
|
<Input placeholder="root" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
@@ -51,6 +52,7 @@ import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
|||||||
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
||||||
|
|
||||||
export const ShowServers = () => {
|
export const ShowServers = () => {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const query = router.query;
|
const query = router.query;
|
||||||
const { data, refetch, isLoading } = api.server.all.useQuery();
|
const { data, refetch, isLoading } = api.server.all.useQuery();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { GlobeIcon } from "lucide-react";
|
import { GlobeIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -65,6 +66,7 @@ const addServerDomain = z
|
|||||||
type AddServerDomain = z.infer<typeof addServerDomain>;
|
type AddServerDomain = z.infer<typeof addServerDomain>;
|
||||||
|
|
||||||
export const WebDomain = () => {
|
export const WebDomain = () => {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isLoading } =
|
||||||
api.settings.assignDomainServer.useMutation();
|
api.settings.assignDomainServer.useMutation();
|
||||||
@@ -117,10 +119,10 @@ export const WebDomain = () => {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
<GlobeIcon className="size-6 text-muted-foreground self-center" />
|
<GlobeIcon className="size-6 text-muted-foreground self-center" />
|
||||||
Server Domain
|
{t("settings.server.domain.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Add a domain to your server application.
|
{t("settings.server.domain.description")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -149,7 +151,9 @@ export const WebDomain = () => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Domain</FormLabel>
|
<FormLabel>
|
||||||
|
{t("settings.server.domain.form.domain")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -169,7 +173,9 @@ export const WebDomain = () => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Let's Encrypt Email</FormLabel>
|
<FormLabel>
|
||||||
|
{t("settings.server.domain.form.letsEncryptEmail")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -210,20 +216,32 @@ export const WebDomain = () => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="md:col-span-2">
|
||||||
<FormLabel>Certificate Provider</FormLabel>
|
<FormLabel>
|
||||||
|
{t("settings.server.domain.form.certificate.label")}
|
||||||
|
</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a certificate" />
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"settings.server.domain.form.certificate.placeholder",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={"none"}>None</SelectItem>
|
<SelectItem value={"none"}>
|
||||||
|
{t(
|
||||||
|
"settings.server.domain.form.certificateOptions.none",
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value={"letsencrypt"}>
|
<SelectItem value={"letsencrypt"}>
|
||||||
Let's Encrypt
|
{t(
|
||||||
|
"settings.server.domain.form.certificateOptions.letsencrypt",
|
||||||
|
)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -236,7 +254,7 @@ export const WebDomain = () => {
|
|||||||
|
|
||||||
<div className="flex w-full justify-end col-span-2">
|
<div className="flex w-full justify-end col-span-2">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
Save
|
{t("settings.common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ServerIcon } from "lucide-react";
|
import { ServerIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -14,6 +15,7 @@ import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
|
|||||||
import { UpdateServer } from "./web-server/update-server";
|
import { UpdateServer } from "./web-server/update-server";
|
||||||
|
|
||||||
export const WebServer = () => {
|
export const WebServer = () => {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
const { data: webServerSettings } =
|
const { data: webServerSettings } =
|
||||||
api.settings.getWebServerSettings.useQuery();
|
api.settings.getWebServerSettings.useQuery();
|
||||||
|
|
||||||
@@ -27,16 +29,18 @@ export const WebServer = () => {
|
|||||||
<CardHeader className="">
|
<CardHeader className="">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
<ServerIcon className="size-6 text-muted-foreground self-center" />
|
<ServerIcon className="size-6 text-muted-foreground self-center" />
|
||||||
Web Server
|
{t("settings.server.webServer.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Reload or clean the web server.</CardDescription>
|
<CardDescription>
|
||||||
|
{t("settings.server.webServer.description")}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{/* <CardHeader>
|
{/* <CardHeader>
|
||||||
<CardTitle className="text-xl">
|
<CardTitle className="text-xl">
|
||||||
Web Server
|
{t("settings.server.webServer.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Reload or clean the web server.
|
{t("settings.server.webServer.description")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader> */}
|
</CardHeader> */}
|
||||||
<CardContent className="space-y-6 py-6 border-t">
|
<CardContent className="space-y-6 py-6 border-t">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +52,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LocalServerConfig = ({ onSave }: Props) => {
|
const LocalServerConfig = ({ onSave }: Props) => {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: getLocalServerData(),
|
defaultValues: getLocalServerData(),
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
@@ -74,7 +77,9 @@ const LocalServerConfig = ({ onSave }: Props) => {
|
|||||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
<span className="dark:hover:text-white">Connection settings</span>
|
<span className="dark:hover:text-white">
|
||||||
|
{t("settings.terminal.connectionSettings")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
@@ -91,7 +96,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
|
|||||||
name="port"
|
name="port"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Port</FormLabel>
|
<FormLabel>{t("settings.terminal.port")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
@@ -119,7 +124,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
|
|||||||
name="username"
|
name="username"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>{t("settings.terminal.username")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="root" {...field} />
|
<Input placeholder="root" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -137,7 +142,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
|
|||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
disabled={!form.formState.isDirty}
|
disabled={!form.formState.isDirty}
|
||||||
>
|
>
|
||||||
Save
|
{t("settings.common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
|
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -55,6 +56,7 @@ const TraefikPortsSchema = z.object({
|
|||||||
type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
|
type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
|
||||||
|
|
||||||
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm<TraefikPortsForm>({
|
const form = useForm<TraefikPortsForm>({
|
||||||
@@ -82,7 +84,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
isExecuting: isHealthCheckExecuting,
|
isExecuting: isHealthCheckExecuting,
|
||||||
} = useHealthCheckAfterMutation({
|
} = useHealthCheckAfterMutation({
|
||||||
initialDelay: 5000,
|
initialDelay: 5000,
|
||||||
successMessage: "Ports updated successfully",
|
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refetchPorts();
|
refetchPorts();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -127,12 +129,14 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
<DialogContent className="sm:max-w-3xl">
|
<DialogContent className="sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
Additional Port Mappings
|
{t("settings.server.webServer.traefik.managePorts")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-base w-full">
|
<DialogDescription className="text-base w-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
Add or remove additional ports for Traefik
|
{t(
|
||||||
|
"settings.server.webServer.traefik.managePortsDescription",
|
||||||
|
)}
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "}
|
{fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "}
|
||||||
configured
|
configured
|
||||||
@@ -175,7 +179,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-sm font-medium text-muted-foreground">
|
<FormLabel className="text-sm font-medium text-muted-foreground">
|
||||||
Target Port
|
{t(
|
||||||
|
"settings.server.webServer.traefik.targetPort",
|
||||||
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -204,7 +210,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-sm font-medium text-muted-foreground">
|
<FormLabel className="text-sm font-medium text-muted-foreground">
|
||||||
Published Port
|
{t(
|
||||||
|
"settings.server.webServer.traefik.publishedPort",
|
||||||
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -135,9 +135,7 @@ export const UpdateServer = ({
|
|||||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
|
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
|
||||||
<Server className="h-4 w-4 text-muted-foreground" />
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{dokployVersion}{" "}
|
{dokployVersion} | {releaseTag}
|
||||||
{(releaseTag === "canary" || releaseTag === "feature") &&
|
|
||||||
`(${releaseTag})`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -88,35 +88,6 @@ export const DiscordIcon = ({ className }: Props) => {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export const TeamsIcon = ({ className }: Props) => {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="26"
|
|
||||||
height="36"
|
|
||||||
viewBox="0 0 512 476"
|
|
||||||
className={cn("size-9", className)}
|
|
||||||
>
|
|
||||||
<g>
|
|
||||||
<rect x="116" y="50" width="280" height="276" rx="64" fill="#6264A7" />
|
|
||||||
<rect x="236" y="138" width="180" height="224" rx="60" fill="#5059C9" />
|
|
||||||
<circle cx="122" cy="332" r="80" fill="#B2B4D3" />
|
|
||||||
<circle cx="370" cy="364" r="64" fill="#A6A7DC" />
|
|
||||||
<text
|
|
||||||
x="180"
|
|
||||||
y="270"
|
|
||||||
fill="#fff"
|
|
||||||
font-family="Segoe UI, Arial, sans-serif"
|
|
||||||
font-size="110"
|
|
||||||
font-weight="bold"
|
|
||||||
>
|
|
||||||
T
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LarkIcon = ({ className }: Props) => {
|
export const LarkIcon = ({ className }: Props) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -630,137 +630,135 @@ function SidebarLogo() {
|
|||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
|
className="rounded-lg"
|
||||||
align="start"
|
align="start"
|
||||||
side={isMobile ? "bottom" : "right"}
|
side={isMobile ? "bottom" : "right"}
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground shrink-0">
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
Organizations
|
Organizations
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<div className="overflow-y-auto overflow-x-hidden min-h-0 -mx-1 px-1">
|
{organizations?.map((org) => {
|
||||||
{organizations?.map((org) => {
|
const isDefault = org.members?.[0]?.isDefault ?? false;
|
||||||
const isDefault = org.members?.[0]?.isDefault ?? false;
|
return (
|
||||||
return (
|
<div
|
||||||
<div
|
className="flex flex-row justify-between"
|
||||||
className="flex flex-row justify-between"
|
key={org.name}
|
||||||
key={org.name}
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
await authClient.organization.setActive({
|
||||||
|
organizationId: org.id,
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
className="w-full gap-2 p-2"
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<div className="flex flex-col gap-1">
|
||||||
onClick={async () => {
|
<div className="flex items-center gap-2">
|
||||||
await authClient.organization.setActive({
|
{org.name}
|
||||||
organizationId: org.id,
|
|
||||||
});
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
|
||||||
className="w-full gap-2 p-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{org.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex size-6 items-center justify-center rounded-sm border">
|
|
||||||
<Logo
|
|
||||||
className={cn(
|
|
||||||
"transition-all",
|
|
||||||
state === "collapsed" ? "size-6" : "size-10",
|
|
||||||
)}
|
|
||||||
logoUrl={org.logo ?? undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"group",
|
|
||||||
isDefault
|
|
||||||
? "hover:bg-yellow-500/10"
|
|
||||||
: "hover:bg-blue-500/10",
|
|
||||||
)}
|
|
||||||
isLoading={isSettingDefault && !isDefault}
|
|
||||||
disabled={isDefault}
|
|
||||||
onClick={async (e) => {
|
|
||||||
if (isDefault) return;
|
|
||||||
e.stopPropagation();
|
|
||||||
await setDefaultOrganization({
|
|
||||||
organizationId: org.id,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
refetch();
|
|
||||||
toast.success("Default organization updated");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(
|
|
||||||
error?.message ||
|
|
||||||
"Error setting default organization",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
isDefault
|
|
||||||
? "Default organization"
|
|
||||||
: "Set as default"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isDefault ? (
|
|
||||||
<Star
|
|
||||||
fill="#eab308"
|
|
||||||
stroke="#eab308"
|
|
||||||
className="size-4 text-yellow-500"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Star
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{org.ownerId === session?.user?.id && (
|
|
||||||
<>
|
|
||||||
<AddOrganization organizationId={org.id} />
|
|
||||||
<DialogAction
|
|
||||||
title="Delete Organization"
|
|
||||||
description="Are you sure you want to delete this organization?"
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
await deleteOrganization({
|
|
||||||
organizationId: org.id,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
refetch();
|
|
||||||
toast.success(
|
|
||||||
"Organization deleted successfully",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(
|
|
||||||
error?.message ||
|
|
||||||
"Error deleting organization",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-red-500/10"
|
|
||||||
isLoading={isRemoving}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||||
|
<Logo
|
||||||
|
className={cn(
|
||||||
|
"transition-all",
|
||||||
|
state === "collapsed" ? "size-6" : "size-10",
|
||||||
|
)}
|
||||||
|
logoUrl={org.logo ?? undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"group",
|
||||||
|
isDefault
|
||||||
|
? "hover:bg-yellow-500/10"
|
||||||
|
: "hover:bg-blue-500/10",
|
||||||
|
)}
|
||||||
|
isLoading={isSettingDefault && !isDefault}
|
||||||
|
disabled={isDefault}
|
||||||
|
onClick={async (e) => {
|
||||||
|
if (isDefault) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
await setDefaultOrganization({
|
||||||
|
organizationId: org.id,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success("Default organization updated");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
error?.message ||
|
||||||
|
"Error setting default organization",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
isDefault
|
||||||
|
? "Default organization"
|
||||||
|
: "Set as default"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isDefault ? (
|
||||||
|
<Star
|
||||||
|
fill="#eab308"
|
||||||
|
stroke="#eab308"
|
||||||
|
className="size-4 text-yellow-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Star
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{org.ownerId === session?.user?.id && (
|
||||||
|
<>
|
||||||
|
<AddOrganization organizationId={org.id} />
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Organization"
|
||||||
|
description="Are you sure you want to delete this organization?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteOrganization({
|
||||||
|
organizationId: org.id,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
"Organization deleted successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
error?.message ||
|
||||||
|
"Error deleting organization",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
{(user?.role === "owner" ||
|
{(user?.role === "owner" ||
|
||||||
user?.role === "admin" ||
|
user?.role === "admin" ||
|
||||||
isCloud) && (
|
isCloud) && (
|
||||||
|
|||||||
@@ -10,9 +10,18 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { Languages } from "@/lib/languages";
|
||||||
import { getFallbackAvatarInitials } from "@/lib/utils";
|
import { getFallbackAvatarInitials } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import useLocale from "@/utils/hooks/use-locale";
|
||||||
import { ModeToggle } from "../ui/modeToggle";
|
import { ModeToggle } from "../ui/modeToggle";
|
||||||
import { SidebarMenuButton } from "../ui/sidebar";
|
import { SidebarMenuButton } from "../ui/sidebar";
|
||||||
|
|
||||||
@@ -23,6 +32,7 @@ export const UserNav = () => {
|
|||||||
const { data } = api.user.get.useQuery();
|
const { data } = api.user.get.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
|
const { locale, setLocale } = useLocale();
|
||||||
// const { mutateAsync } = api.auth.logout.useMutation();
|
// const { mutateAsync } = api.auth.logout.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -145,19 +155,39 @@ export const UserNav = () => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<div className="flex items-center justify-between px-2 py-1.5">
|
||||||
className="cursor-pointer"
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
className="cursor-pointer"
|
||||||
await authClient.signOut().then(() => {
|
onClick={async () => {
|
||||||
router.push("/");
|
await authClient.signOut().then(() => {
|
||||||
});
|
router.push("/");
|
||||||
// await mutateAsync().then(() => {
|
});
|
||||||
// router.push("/");
|
// await mutateAsync().then(() => {
|
||||||
// });
|
// router.push("/");
|
||||||
}}
|
// });
|
||||||
>
|
}}
|
||||||
Log out
|
>
|
||||||
</DropdownMenuItem>
|
Log out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<div className="w-32">
|
||||||
|
<Select
|
||||||
|
onValueChange={setLocale}
|
||||||
|
defaultValue={locale}
|
||||||
|
value={locale}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Language" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.values(Languages).map((language) => (
|
||||||
|
<SelectItem key={language.code} value={language.code}>
|
||||||
|
{language.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import type { FieldArrayPath } from "react-hook-form";
|
import type { FieldArrayPath } from "react-hook-form";
|
||||||
import { useFieldArray, useForm, useWatch } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useUrl } from "@/utils/hooks/use-url";
|
|
||||||
|
|
||||||
const DEFAULT_SCOPES = ["openid", "email", "profile"];
|
const DEFAULT_SCOPES = ["openid", "email", "profile"];
|
||||||
|
|
||||||
@@ -59,7 +58,6 @@ const oidcProviderSchema = z.object({
|
|||||||
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
|
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
|
||||||
|
|
||||||
interface RegisterOidcDialogProps {
|
interface RegisterOidcDialogProps {
|
||||||
providerId?: string;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,86 +70,16 @@ const formDefaultValues = {
|
|||||||
scopes: [...DEFAULT_SCOPES],
|
scopes: [...DEFAULT_SCOPES],
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseOidcConfig(oidcConfig: string | null): {
|
export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
|
||||||
clientId?: string;
|
|
||||||
clientSecret?: string;
|
|
||||||
scopes?: string[];
|
|
||||||
} | null {
|
|
||||||
if (!oidcConfig) return null;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(oidcConfig) as {
|
|
||||||
clientId?: string;
|
|
||||||
clientSecret?: string;
|
|
||||||
scopes?: string[];
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
clientId: parsed.clientId,
|
|
||||||
clientSecret: parsed.clientSecret,
|
|
||||||
scopes: Array.isArray(parsed.scopes) ? parsed.scopes : undefined,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RegisterOidcDialog({
|
|
||||||
providerId,
|
|
||||||
children,
|
|
||||||
}: RegisterOidcDialogProps) {
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const { mutateAsync, isLoading } = api.sso.register.useMutation();
|
||||||
const { data } = api.sso.one.useQuery(
|
|
||||||
{ providerId: providerId ?? "" },
|
|
||||||
{ enabled: !!providerId && open },
|
|
||||||
);
|
|
||||||
const registerMutation = api.sso.register.useMutation();
|
|
||||||
const updateMutation = api.sso.update.useMutation();
|
|
||||||
|
|
||||||
const isEdit = !!providerId;
|
|
||||||
const mutateAsync = isEdit
|
|
||||||
? updateMutation.mutateAsync
|
|
||||||
: registerMutation.mutateAsync;
|
|
||||||
const isLoading = isEdit
|
|
||||||
? updateMutation.isLoading
|
|
||||||
: registerMutation.isLoading;
|
|
||||||
|
|
||||||
const form = useForm<OidcProviderForm>({
|
const form = useForm<OidcProviderForm>({
|
||||||
resolver: zodResolver(oidcProviderSchema),
|
resolver: zodResolver(oidcProviderSchema),
|
||||||
defaultValues: formDefaultValues,
|
defaultValues: formDefaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const watchedProviderId = useWatch({
|
|
||||||
control: form.control,
|
|
||||||
name: "providerId",
|
|
||||||
defaultValue: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const baseURL = useUrl();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data || !open) return;
|
|
||||||
const domains = data.domain
|
|
||||||
? data.domain
|
|
||||||
.split(",")
|
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: [""];
|
|
||||||
if (domains.length === 0) domains.push("");
|
|
||||||
const oidc = parseOidcConfig(data.oidcConfig);
|
|
||||||
form.reset({
|
|
||||||
providerId: data.providerId,
|
|
||||||
issuer: data.issuer,
|
|
||||||
domains,
|
|
||||||
clientId: oidc?.clientId ?? "",
|
|
||||||
clientSecret: oidc?.clientSecret ?? "",
|
|
||||||
scopes:
|
|
||||||
oidc?.scopes && oidc.scopes.length > 0
|
|
||||||
? oidc.scopes
|
|
||||||
: [...DEFAULT_SCOPES],
|
|
||||||
});
|
|
||||||
}, [data, open, form]);
|
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: "domains" as FieldArrayPath<OidcProviderForm>,
|
name: "domains" as FieldArrayPath<OidcProviderForm>,
|
||||||
@@ -202,11 +130,7 @@ export function RegisterOidcDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(
|
toast.success("OIDC provider registered successfully");
|
||||||
isEdit
|
|
||||||
? "OIDC provider updated successfully"
|
|
||||||
: "OIDC provider registered successfully",
|
|
||||||
);
|
|
||||||
form.reset(formDefaultValues);
|
form.reset(formDefaultValues);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
await utils.sso.listProviders.invalidate();
|
await utils.sso.listProviders.invalidate();
|
||||||
@@ -222,13 +146,11 @@ export function RegisterOidcDialog({
|
|||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>Register OIDC provider</DialogTitle>
|
||||||
{isEdit ? "Update OIDC provider" : "Register OIDC provider"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isEdit
|
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
|
||||||
? "Change issuer, domains, client settings or scopes. Provider ID cannot be changed."
|
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
|
||||||
: "Add any OIDC-compliant identity provider (e.g. Okta, Azure AD, Google Workspace, Auth0, Keycloak). Discovery will fill endpoints from the issuer URL when possible."}
|
from the issuer URL when possible.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -240,28 +162,11 @@ export function RegisterOidcDialog({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Provider ID</FormLabel>
|
<FormLabel>Provider ID</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="e.g. okta or my-idp" {...field} />
|
||||||
placeholder="e.g. okta or my-idp"
|
|
||||||
{...field}
|
|
||||||
readOnly={isEdit}
|
|
||||||
className={isEdit ? "bg-muted" : undefined}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Unique identifier; used in callback URL path.
|
Unique identifier; used in callback URL path.
|
||||||
{isEdit && " Cannot be changed when editing."}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
{baseURL && (
|
|
||||||
<div className="rounded-md bg-muted px-3 py-2 text-xs">
|
|
||||||
<p className="font-medium text-muted-foreground">
|
|
||||||
Callback URL (configure in your IdP)
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 break-all font-mono">
|
|
||||||
{baseURL}/api/auth/sso/callback/
|
|
||||||
{watchedProviderId?.trim() || "..."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -436,7 +341,7 @@ export function RegisterOidcDialog({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" isLoading={isLoading}>
|
<Button type="submit" isLoading={isLoading}>
|
||||||
{isEdit ? "Update provider" : "Register provider"}
|
Register provider
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,12 +3,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
|
||||||
type FieldArrayPath,
|
|
||||||
useFieldArray,
|
|
||||||
useForm,
|
|
||||||
useWatch,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -33,7 +28,6 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useUrl } from "@/utils/hooks/use-url";
|
|
||||||
|
|
||||||
const domainsArraySchema = z
|
const domainsArraySchema = z
|
||||||
.array(z.string().trim())
|
.array(z.string().trim())
|
||||||
@@ -64,7 +58,6 @@ const samlProviderSchema = z.object({
|
|||||||
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
|
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
|
||||||
|
|
||||||
interface RegisterSamlDialogProps {
|
interface RegisterSamlDialogProps {
|
||||||
providerId?: string;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,83 +70,24 @@ const formDefaultValues: SamlProviderForm = {
|
|||||||
idpMetadataXml: "",
|
idpMetadataXml: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseSamlConfig(samlConfig: string | null): {
|
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
||||||
entryPoint?: string;
|
|
||||||
cert?: string;
|
|
||||||
idpMetadataXml?: string;
|
|
||||||
} | null {
|
|
||||||
if (!samlConfig) return null;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(samlConfig) as {
|
|
||||||
entryPoint?: string;
|
|
||||||
cert?: string;
|
|
||||||
idpMetadata?: { metadata?: string };
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
entryPoint: parsed.entryPoint,
|
|
||||||
cert: parsed.cert,
|
|
||||||
idpMetadataXml: parsed.idpMetadata?.metadata,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RegisterSamlDialog({
|
|
||||||
providerId,
|
|
||||||
children,
|
|
||||||
}: RegisterSamlDialogProps) {
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const { mutateAsync, isLoading } = api.sso.register.useMutation();
|
||||||
|
|
||||||
const { data } = api.sso.one.useQuery(
|
const [baseURL, setBaseURL] = useState("");
|
||||||
{ providerId: providerId ?? "" },
|
|
||||||
{ enabled: !!providerId && open },
|
|
||||||
);
|
|
||||||
const registerMutation = api.sso.register.useMutation();
|
|
||||||
const updateMutation = api.sso.update.useMutation();
|
|
||||||
|
|
||||||
const isEdit = !!providerId;
|
useEffect(() => {
|
||||||
const mutateAsync = isEdit
|
if (typeof window !== "undefined") {
|
||||||
? updateMutation.mutateAsync
|
setBaseURL(window.location.origin);
|
||||||
: registerMutation.mutateAsync;
|
}
|
||||||
const isLoading = isEdit
|
}, []);
|
||||||
? updateMutation.isLoading
|
|
||||||
: registerMutation.isLoading;
|
|
||||||
|
|
||||||
const baseURL = useUrl();
|
|
||||||
|
|
||||||
const form = useForm<SamlProviderForm>({
|
const form = useForm<SamlProviderForm>({
|
||||||
resolver: zodResolver(samlProviderSchema),
|
resolver: zodResolver(samlProviderSchema),
|
||||||
defaultValues: formDefaultValues,
|
defaultValues: formDefaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data || !open) return;
|
|
||||||
const domains = data.domain
|
|
||||||
? data.domain
|
|
||||||
.split(",")
|
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: [""];
|
|
||||||
if (domains.length === 0) domains.push("");
|
|
||||||
const saml = parseSamlConfig(data.samlConfig);
|
|
||||||
form.reset({
|
|
||||||
providerId: data.providerId,
|
|
||||||
issuer: data.issuer,
|
|
||||||
domains,
|
|
||||||
entryPoint: saml?.entryPoint ?? "",
|
|
||||||
cert: saml?.cert ?? "",
|
|
||||||
idpMetadataXml: saml?.idpMetadataXml ?? "",
|
|
||||||
});
|
|
||||||
}, [data, open, form]);
|
|
||||||
|
|
||||||
const watchedProviderId = useWatch({
|
|
||||||
control: form.control,
|
|
||||||
name: "providerId",
|
|
||||||
defaultValue: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: "domains" as FieldArrayPath<SamlProviderForm>,
|
name: "domains" as FieldArrayPath<SamlProviderForm>,
|
||||||
@@ -199,11 +133,7 @@ export function RegisterSamlDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(
|
toast.success("SAML provider registered successfully");
|
||||||
isEdit
|
|
||||||
? "SAML provider updated successfully"
|
|
||||||
: "SAML provider registered successfully",
|
|
||||||
);
|
|
||||||
form.reset(formDefaultValues);
|
form.reset(formDefaultValues);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
await utils.sso.listProviders.invalidate();
|
await utils.sso.listProviders.invalidate();
|
||||||
@@ -219,13 +149,10 @@ export function RegisterSamlDialog({
|
|||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>Register SAML provider</DialogTitle>
|
||||||
{isEdit ? "Update SAML provider" : "Register SAML provider"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isEdit
|
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
|
||||||
? "Change issuer, domains, entry point or certificate. Provider ID cannot be changed."
|
OneLogin). You need the IdP's SSO URL and signing certificate.
|
||||||
: "Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML, OneLogin). You need the IdP's SSO URL and signing certificate."}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -240,26 +167,8 @@ export function RegisterSamlDialog({
|
|||||||
<Input
|
<Input
|
||||||
placeholder="e.g. okta-saml or azure-saml"
|
placeholder="e.g. okta-saml or azure-saml"
|
||||||
{...field}
|
{...field}
|
||||||
readOnly={isEdit}
|
|
||||||
className={isEdit ? "bg-muted" : undefined}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{isEdit && (
|
|
||||||
<FormDescription>
|
|
||||||
Cannot be changed when editing.
|
|
||||||
</FormDescription>
|
|
||||||
)}
|
|
||||||
{baseURL && (
|
|
||||||
<div className="rounded-md bg-muted px-3 py-2 text-xs">
|
|
||||||
<p className="font-medium text-muted-foreground">
|
|
||||||
Callback URL (configure in your IdP)
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 break-all font-mono">
|
|
||||||
{baseURL}/api/auth/sso/saml2/callback/
|
|
||||||
{watchedProviderId?.trim() || "..."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -408,7 +317,7 @@ export function RegisterSamlDialog({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" isLoading={isLoading}>
|
<Button type="submit" isLoading={isLoading}>
|
||||||
{isEdit ? "Update provider" : "Register provider"}
|
Register provider
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Eye, Loader2, LogIn, Trash2 } from "lucide-react";
|
||||||
Eye,
|
import { useEffect, useState } from "react";
|
||||||
Loader2,
|
|
||||||
LogIn,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Shield,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -29,9 +21,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useUrl } from "@/utils/hooks/use-url";
|
|
||||||
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||||
|
|
||||||
@@ -77,107 +67,29 @@ export const SSOSettings = () => {
|
|||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [detailsProvider, setDetailsProvider] =
|
const [detailsProvider, setDetailsProvider] =
|
||||||
useState<ProviderForDetails | null>(null);
|
useState<ProviderForDetails | null>(null);
|
||||||
const baseURL = useUrl();
|
const [baseURL, setBaseURL] = useState("");
|
||||||
const [manageOriginsOpen, setManageOriginsOpen] = useState(false);
|
|
||||||
const [editingOrigin, setEditingOrigin] = useState<string | null>(null);
|
useEffect(() => {
|
||||||
const [editingValue, setEditingValue] = useState("");
|
if (typeof window !== "undefined") {
|
||||||
const [newOriginInput, setNewOriginInput] = useState("");
|
setBaseURL(window.location.origin);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
|
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
|
||||||
const { data: trustedOrigins = [] } = api.sso.getTrustedOrigins.useQuery(
|
|
||||||
undefined,
|
|
||||||
{ enabled: manageOriginsOpen },
|
|
||||||
);
|
|
||||||
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
|
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
|
||||||
api.sso.deleteProvider.useMutation();
|
api.sso.deleteProvider.useMutation();
|
||||||
const { mutateAsync: addTrustedOrigin, isLoading: isAddingOrigin } =
|
|
||||||
api.sso.addTrustedOrigin.useMutation();
|
|
||||||
const { mutateAsync: removeTrustedOrigin, isLoading: isRemovingOrigin } =
|
|
||||||
api.sso.removeTrustedOrigin.useMutation();
|
|
||||||
const { mutateAsync: updateTrustedOrigin, isLoading: isUpdatingOrigin } =
|
|
||||||
api.sso.updateTrustedOrigin.useMutation();
|
|
||||||
|
|
||||||
const handleAddOrigin = async () => {
|
|
||||||
const value = newOriginInput.trim();
|
|
||||||
if (!value) return;
|
|
||||||
try {
|
|
||||||
await addTrustedOrigin({ origin: value });
|
|
||||||
toast.success("Trusted origin added");
|
|
||||||
setNewOriginInput("");
|
|
||||||
await utils.sso.getTrustedOrigins.invalidate();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(
|
|
||||||
err instanceof Error ? err.message : "Failed to add trusted origin",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveOrigin = async (origin: string) => {
|
|
||||||
try {
|
|
||||||
await removeTrustedOrigin({ origin });
|
|
||||||
toast.success("Trusted origin removed");
|
|
||||||
if (editingOrigin === origin) setEditingOrigin(null);
|
|
||||||
await utils.sso.getTrustedOrigins.invalidate();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(
|
|
||||||
err instanceof Error ? err.message : "Failed to remove trusted origin",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartEdit = (origin: string) => {
|
|
||||||
setEditingOrigin(origin);
|
|
||||||
setEditingValue(origin);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEdit = async () => {
|
|
||||||
if (editingOrigin == null || !editingValue.trim()) {
|
|
||||||
setEditingOrigin(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await updateTrustedOrigin({
|
|
||||||
oldOrigin: editingOrigin,
|
|
||||||
newOrigin: editingValue.trim(),
|
|
||||||
});
|
|
||||||
toast.success("Trusted origin updated");
|
|
||||||
setEditingOrigin(null);
|
|
||||||
setEditingValue("");
|
|
||||||
await utils.sso.getTrustedOrigins.invalidate();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(
|
|
||||||
err instanceof Error ? err.message : "Failed to update trusted origin",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
setEditingOrigin(null);
|
|
||||||
setEditingValue("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<LogIn className="size-6 text-muted-foreground" />
|
||||||
<LogIn className="size-6 text-muted-foreground" />
|
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
|
||||||
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription>
|
|
||||||
Configure OIDC or SAML identity providers for enterprise sign-in.
|
|
||||||
Users can sign in with their organization's IdP.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<CardDescription>
|
||||||
variant="outline"
|
Configure OIDC or SAML identity providers for enterprise sign-in.
|
||||||
size="sm"
|
Users can sign in with their organization's IdP.
|
||||||
onClick={() => setManageOriginsOpen(true)}
|
</CardDescription>
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<Shield className="mr-2 size-4" />
|
|
||||||
Manage origins
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -265,22 +177,6 @@ export const SSOSettings = () => {
|
|||||||
<Eye className="mr-1 size-3" />
|
<Eye className="mr-1 size-3" />
|
||||||
View details
|
View details
|
||||||
</Button>
|
</Button>
|
||||||
{isOidc && (
|
|
||||||
<RegisterOidcDialog providerId={provider.providerId}>
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
<Pencil className="mr-1 size-3" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</RegisterOidcDialog>
|
|
||||||
)}
|
|
||||||
{isSaml && (
|
|
||||||
<RegisterSamlDialog providerId={provider.providerId}>
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
<Pencil className="mr-1 size-3" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</RegisterSamlDialog>
|
|
||||||
)}
|
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Remove SSO provider"
|
title="Remove SSO provider"
|
||||||
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
|
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
|
||||||
@@ -360,7 +256,8 @@ export const SSOSettings = () => {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>SSO provider details</DialogTitle>
|
<DialogTitle>SSO provider details</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Use Edit to change provider settings (OIDC or SAML).
|
View-only. To change settings, remove this provider and add it
|
||||||
|
again with the new values.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-3 py-2">
|
<div className="grid gap-3 py-2">
|
||||||
@@ -469,128 +366,6 @@ export const SSOSettings = () => {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={manageOriginsOpen} onOpenChange={setManageOriginsOpen}>
|
|
||||||
<DialogContent className="sm:max-w-[480px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="size-5" />
|
|
||||||
Trusted origins
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Manage allowed origins for SSO callbacks. Add, edit, or remove
|
|
||||||
origins for your account.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-sm font-medium">Current origins</span>
|
|
||||||
{trustedOrigins.length === 0 ? (
|
|
||||||
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
|
|
||||||
No trusted origins yet. Add one below.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
{trustedOrigins.map((origin) => (
|
|
||||||
<li
|
|
||||||
key={origin}
|
|
||||||
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
|
|
||||||
>
|
|
||||||
{editingOrigin === origin ? (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
value={editingValue}
|
|
||||||
onChange={(e) => setEditingValue(e.target.value)}
|
|
||||||
placeholder="https://..."
|
|
||||||
className="flex-1 font-mono text-sm"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSaveEdit}
|
|
||||||
disabled={!editingValue.trim() || isUpdatingOrigin}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleCancelEdit}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="flex-1 break-all font-mono text-sm">
|
|
||||||
{origin}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-8 shrink-0"
|
|
||||||
onClick={() => handleStartEdit(origin)}
|
|
||||||
>
|
|
||||||
<Pencil className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<DialogAction
|
|
||||||
title="Remove trusted origin"
|
|
||||||
description={`Remove "${origin}" from trusted origins?`}
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => handleRemoveOrigin(origin)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-8 shrink-0 text-destructive hover:text-destructive"
|
|
||||||
disabled={isRemovingOrigin}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-sm font-medium">Add trusted origin</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={newOriginInput}
|
|
||||||
onChange={(e) => setNewOriginInput(e.target.value)}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
className="font-mono text-sm"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleAddOrigin();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleAddOrigin}
|
|
||||||
disabled={!newOriginInput.trim() || isAddingOrigin}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 size-4" />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setManageOriginsOpen(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "sso_provider" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;
|
|
||||||
15
apps/dokploy/drizzle/0143_cute_forge.sql
Normal file
15
apps/dokploy/drizzle/0143_cute_forge.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "patch" (
|
||||||
|
"patchId" text PRIMARY KEY NOT NULL,
|
||||||
|
"filePath" text NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"createdAt" text NOT NULL,
|
||||||
|
"updatedAt" text,
|
||||||
|
"applicationId" text,
|
||||||
|
"composeId" text,
|
||||||
|
CONSTRAINT "patch_filepath_application_unique" UNIQUE("filePath","applicationId"),
|
||||||
|
CONSTRAINT "patch_filepath_compose_unique" UNIQUE("filePath","composeId")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "patch" ADD CONSTRAINT "patch_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "patch" ADD CONSTRAINT "patch_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
ALTER TYPE "public"."notificationType" ADD VALUE 'teams';--> statement-breakpoint
|
|
||||||
CREATE TABLE "teams" (
|
|
||||||
"teamsId" text PRIMARY KEY NOT NULL,
|
|
||||||
"webhookUrl" text NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "notification" ADD COLUMN "teamsId" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_teamsId_teams_teamsId_fk" FOREIGN KEY ("teamsId") REFERENCES "public"."teams"("teamsId") ON DELETE cascade ON UPDATE no action;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "c03ebeca-bf0f-4d72-8b4f-9a4dccb9f143",
|
"id": "3c3f9c63-32c2-479a-aa45-9726bed6281e",
|
||||||
"prevId": "fce8c149-40a8-4279-a432-cfa7538666c6",
|
"prevId": "fce8c149-40a8-4279-a432-cfa7538666c6",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
@@ -4929,6 +4929,112 @@
|
|||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
"isRLSEnabled": false
|
"isRLSEnabled": false
|
||||||
},
|
},
|
||||||
|
"public.patch": {
|
||||||
|
"name": "patch",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"patchId": {
|
||||||
|
"name": "patchId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"filePath": {
|
||||||
|
"name": "filePath",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"applicationId": {
|
||||||
|
"name": "applicationId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"composeId": {
|
||||||
|
"name": "composeId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"patch_applicationId_application_applicationId_fk": {
|
||||||
|
"name": "patch_applicationId_application_applicationId_fk",
|
||||||
|
"tableFrom": "patch",
|
||||||
|
"tableTo": "application",
|
||||||
|
"columnsFrom": [
|
||||||
|
"applicationId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"applicationId"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"patch_composeId_compose_composeId_fk": {
|
||||||
|
"name": "patch_composeId_compose_composeId_fk",
|
||||||
|
"tableFrom": "patch",
|
||||||
|
"tableTo": "compose",
|
||||||
|
"columnsFrom": [
|
||||||
|
"composeId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"composeId"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"patch_filepath_application_unique": {
|
||||||
|
"name": "patch_filepath_application_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"filePath",
|
||||||
|
"applicationId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"patch_filepath_compose_unique": {
|
||||||
|
"name": "patch_filepath_compose_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"filePath",
|
||||||
|
"composeId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
"public.port": {
|
"public.port": {
|
||||||
"name": "port",
|
"name": "port",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
@@ -6471,13 +6577,6 @@
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1006,15 +1006,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 143,
|
"idx": 143,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1770961667210,
|
"when": 1770756316554,
|
||||||
"tag": "0143_brown_ultron",
|
"tag": "0143_cute_forge",
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 144,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1771297084611,
|
|
||||||
"tag": "0144_odd_gunslinger",
|
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
29
apps/dokploy/lib/languages.ts
Normal file
29
apps/dokploy/lib/languages.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Sorted list based off of population of the country / speakers of the language.
|
||||||
|
*/
|
||||||
|
export const Languages = {
|
||||||
|
english: { code: "en", name: "English" },
|
||||||
|
spanish: { code: "es", name: "Español" },
|
||||||
|
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
|
||||||
|
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
|
||||||
|
portuguese: { code: "pt-br", name: "Português" },
|
||||||
|
russian: { code: "ru", name: "Русский" },
|
||||||
|
japanese: { code: "ja", name: "日本語" },
|
||||||
|
german: { code: "de", name: "Deutsch" },
|
||||||
|
korean: { code: "ko", name: "한국어" },
|
||||||
|
french: { code: "fr", name: "Français" },
|
||||||
|
turkish: { code: "tr", name: "Türkçe" },
|
||||||
|
italian: { code: "it", name: "Italiano" },
|
||||||
|
polish: { code: "pl", name: "Polski" },
|
||||||
|
ukrainian: { code: "uk", name: "Українська" },
|
||||||
|
persian: { code: "fa", name: "فارسی" },
|
||||||
|
dutch: { code: "nl", name: "Nederlands" },
|
||||||
|
indonesian: { code: "id", name: "Bahasa Indonesia" },
|
||||||
|
kazakh: { code: "kz", name: "Қазақ" },
|
||||||
|
norwegian: { code: "no", name: "Norsk" },
|
||||||
|
azerbaijani: { code: "az", name: "Azərbaycan" },
|
||||||
|
malayalam: { code: "ml", name: "മലയാളം" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Language = keyof typeof Languages;
|
||||||
|
export type LanguageCode = (typeof Languages)[keyof typeof Languages]["code"];
|
||||||
@@ -10,6 +10,15 @@ const nextConfig = {
|
|||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
transpilePackages: ["@dokploy/server"],
|
transpilePackages: ["@dokploy/server"],
|
||||||
|
/**
|
||||||
|
* If you are using `appDir` then you must comment the below `i18n` config out.
|
||||||
|
*
|
||||||
|
* @see https://github.com/vercel/next.js/issues/41980
|
||||||
|
*/
|
||||||
|
i18n: {
|
||||||
|
locales: ["en"],
|
||||||
|
defaultLocale: "en",
|
||||||
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.27.1",
|
"version": "v0.27.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -41,13 +41,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resend": "^6.0.2",
|
"resend": "^6.0.2",
|
||||||
"@better-auth/sso": "1.4.18",
|
"@better-auth/sso": "1.4.18",
|
||||||
"@ai-sdk/anthropic": "^3.0.44",
|
"@ai-sdk/anthropic": "^2.0.5",
|
||||||
"@ai-sdk/azure": "^3.0.30",
|
"@ai-sdk/azure": "^2.0.16",
|
||||||
"@ai-sdk/cohere": "^3.0.21",
|
"@ai-sdk/cohere": "^2.0.4",
|
||||||
"@ai-sdk/deepinfra": "^2.0.34",
|
"@ai-sdk/deepinfra": "^1.0.10",
|
||||||
"@ai-sdk/mistral": "^3.0.20",
|
"@ai-sdk/mistral": "^2.0.7",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^2.0.16",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^1.0.10",
|
||||||
"@codemirror/autocomplete": "^6.18.6",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
"@xterm/addon-clipboard": "0.1.0",
|
"@xterm/addon-clipboard": "0.1.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"ai": "^6.0.86",
|
"ai": "^5.0.17",
|
||||||
"ai-sdk-ollama": "^3.7.0",
|
"ai-sdk-ollama": "^0.5.1",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "1.4.18",
|
"better-auth": "1.4.18",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
@@ -113,6 +113,7 @@
|
|||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"fancy-ansi": "^0.1.3",
|
"fancy-ansi": "^0.1.3",
|
||||||
|
"i18next": "^23.16.8",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
@@ -120,6 +121,7 @@
|
|||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
|
"next-i18next": "^15.4.2",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
"node-os-utils": "2.0.1",
|
"node-os-utils": "2.0.1",
|
||||||
@@ -137,6 +139,7 @@
|
|||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
|
"react-i18next": "^15.5.2",
|
||||||
"react-markdown": "^9.1.0",
|
"react-markdown": "^9.1.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
@@ -144,7 +147,7 @@
|
|||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
"stripe": "17.2.0",
|
"stripe": "17.2.0",
|
||||||
"superjson": "^2.2.2",
|
"superjson": "^2.2.2",
|
||||||
"swagger-ui-react": "^5.31.1",
|
"swagger-ui-react": "^5.22.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"toml": "3.0.0",
|
"toml": "3.0.0",
|
||||||
@@ -153,7 +156,7 @@
|
|||||||
"ws": "8.16.0",
|
"ws": "8.16.0",
|
||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"yaml": "2.8.1",
|
"yaml": "2.8.1",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.32",
|
||||||
"zod-form-data": "^2.0.7",
|
"zod-form-data": "^2.0.7",
|
||||||
"semver": "7.7.3"
|
"semver": "7.7.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import type { NextPage } from "next";
|
|||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
import { appWithTranslation } from "next-i18next";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import NextTopLoader from "nextjs-toploader";
|
import NextTopLoader from "nextjs-toploader";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { Languages } from "@/lib/languages";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
@@ -56,4 +58,14 @@ const MyApp = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api.withTRPC(MyApp);
|
export default api.withTRPC(
|
||||||
|
appWithTranslation(MyApp, {
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: "en",
|
||||||
|
locales: Object.values(Languages).map((language) => language.code),
|
||||||
|
localeDetection: false,
|
||||||
|
},
|
||||||
|
fallbackLng: "en",
|
||||||
|
keySeparator: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -152,10 +152,6 @@ export default async function handler(
|
|||||||
normalizedCommits = req.body?.commits?.flatMap(
|
normalizedCommits = req.body?.commits?.flatMap(
|
||||||
(commit: any) => commit.modified,
|
(commit: any) => commit.modified,
|
||||||
);
|
);
|
||||||
} else if (provider === "soft-serve") {
|
|
||||||
normalizedCommits = req.body?.commits?.flatMap(
|
|
||||||
(commit: any) => commit.modified,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
@@ -443,13 +439,6 @@ export const extractCommitMessage = (headers: any, body: any) => {
|
|||||||
: "NEW COMMIT";
|
: "NEW COMMIT";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft Serve
|
|
||||||
if (headers["x-softserve-event"]) {
|
|
||||||
return body.commits && body.commits.length > 0
|
|
||||||
? body.commits[0].message
|
|
||||||
: "NEW COMMIT";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (headers["user-agent"]?.includes("Go-http-client")) {
|
if (headers["user-agent"]?.includes("Go-http-client")) {
|
||||||
if (body.push_data && body.repository) {
|
if (body.push_data && body.repository) {
|
||||||
return `DockerHub image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
|
return `DockerHub image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
|
||||||
@@ -487,11 +476,6 @@ export const extractHash = (headers: any, body: any) => {
|
|||||||
return body.after || "NEW COMMIT";
|
return body.after || "NEW COMMIT";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft Serve
|
|
||||||
if (headers["x-softserve-event"]) {
|
|
||||||
return body.after || "NEW COMMIT";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -500,10 +484,7 @@ export const extractBranchName = (headers: any, body: any) => {
|
|||||||
return body?.ref?.replace("refs/heads/", "");
|
return body?.ref?.replace("refs/heads/", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (headers["x-gitlab-event"]) {
|
||||||
headers["x-gitlab-event"] ||
|
|
||||||
headers["x-softserve-event"]?.includes("push")
|
|
||||||
) {
|
|
||||||
return body?.ref ? body?.ref.replace("refs/heads/", "") : null;
|
return body?.ref ? body?.ref.replace("refs/heads/", "") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,10 +512,6 @@ export const getProviderByHeader = (headers: any) => {
|
|||||||
return "bitbucket";
|
return "bitbucket";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (headers["x-softserve-event"]) {
|
|
||||||
return "soft-serve";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { ShowPreviewDeployments } from "@/components/dashboard/application/previ
|
|||||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
||||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||||
|
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||||
@@ -248,6 +249,9 @@ const Service = (
|
|||||||
Volume Backups
|
Volume Backups
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
|
{data?.sourceType !== "docker" && (
|
||||||
|
<TabsTrigger value="patches">Patches</TabsTrigger>
|
||||||
|
)}
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
@@ -359,6 +363,11 @@ const Service = (
|
|||||||
<ShowDomains id={applicationId} type="application" />
|
<ShowDomains id={applicationId} type="application" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="patches" className="w-full">
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<ShowPatches applicationId={applicationId} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<AddCommand applicationId={applicationId} />
|
<AddCommand applicationId={applicationId} />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { ShowDomains } from "@/components/dashboard/application/domains/show-dom
|
|||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||||
|
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
|
||||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||||
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
@@ -237,6 +238,9 @@ const Service = (
|
|||||||
Volume Backups
|
Volume Backups
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
|
{data?.sourceType !== "raw" && (
|
||||||
|
<TabsTrigger value="patches">Patches</TabsTrigger>
|
||||||
|
)}
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
@@ -361,6 +365,12 @@ const Service = (
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="patches" className="w-full">
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<ShowPatches composeId={composeId} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<AddCommandCompose composeId={composeId} />
|
<AddCommandCompose composeId={composeId} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import superjson from "superjson";
|
|||||||
import { AiForm } from "@/components/dashboard/settings/ai-form";
|
import { AiForm } from "@/components/dashboard/settings/ai-form";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
|
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
return (
|
return (
|
||||||
@@ -25,6 +26,7 @@ export async function getServerSideProps(
|
|||||||
) {
|
) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
|
const locale = getLocale(req.cookies);
|
||||||
|
|
||||||
const helpers = createServerSideHelpers({
|
const helpers = createServerSideHelpers({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
@@ -53,6 +55,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
|
...(await serverSideTranslations(locale, ["settings"])),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
|||||||
import { LicenseKeySettings } from "@/components/proprietary/license-keys/license-key";
|
import { LicenseKeySettings } from "@/components/proprietary/license-keys/license-key";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
|
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +35,7 @@ export async function getServerSideProps(
|
|||||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
) {
|
) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
|
const locale = await getLocale(req.cookies);
|
||||||
const { user, session } = await validateRequest(ctx.req);
|
const { user, session } = await validateRequest(ctx.req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
@@ -68,6 +70,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
|
...(await serverSideTranslations(locale, ["settings"])),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ProfileForm } from "@/components/dashboard/settings/profile/profile-for
|
|||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const { data } = api.user.get.useQuery();
|
const { data } = api.user.get.useQuery();
|
||||||
@@ -36,6 +37,7 @@ export async function getServerSideProps(
|
|||||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
) {
|
) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
|
const locale = getLocale(req.cookies);
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
|
|
||||||
const helpers = createServerSideHelpers({
|
const helpers = createServerSideHelpers({
|
||||||
@@ -65,6 +67,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
|
...(await serverSideTranslations(locale, ["settings"])),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const { data: user } = api.user.get.useQuery();
|
const { data: user } = api.user.get.useQuery();
|
||||||
@@ -41,6 +42,7 @@ export async function getServerSideProps(
|
|||||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
) {
|
) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
|
const locale = await getLocale(req.cookies);
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
@@ -83,6 +85,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
|
...(await serverSideTranslations(locale, ["settings"])),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import superjson from "superjson";
|
|||||||
import { ShowServers } from "@/components/dashboard/settings/servers/show-servers";
|
import { ShowServers } from "@/components/dashboard/settings/servers/show-servers";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
|
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
return (
|
return (
|
||||||
@@ -24,6 +25,7 @@ export async function getServerSideProps(
|
|||||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
) {
|
) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
|
const locale = await getLocale(req.cookies);
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
@@ -59,6 +61,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
|
...(await serverSideTranslations(locale, ["settings"])),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-featu
|
|||||||
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
|
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
|
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
return (
|
return (
|
||||||
@@ -42,6 +43,7 @@ Page.getLayout = (page: ReactElement) => {
|
|||||||
|
|
||||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
|
const locale = await getLocale(req.cookies);
|
||||||
const { user, session } = await validateRequest(ctx.req);
|
const { user, session } = await validateRequest(ctx.req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
@@ -76,6 +78,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
|
...(await serverSideTranslations(locale, ["settings"])),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
setIsLoginLoading(false);
|
setIsLoginLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTwoFactorSubmit = async (e: React.FormEvent) => {
|
const onTwoFactorSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (twoFactorCode.length !== 6) {
|
if (twoFactorCode.length !== 6) {
|
||||||
@@ -253,6 +254,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
onChange={setTwoFactorCode}
|
onChange={setTwoFactorCode}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
pattern={REGEXP_ONLY_DIGITS}
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
>
|
>
|
||||||
<InputOTPGroup>
|
<InputOTPGroup>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { mountRouter } from "./routers/mount";
|
|||||||
import { mysqlRouter } from "./routers/mysql";
|
import { mysqlRouter } from "./routers/mysql";
|
||||||
import { notificationRouter } from "./routers/notification";
|
import { notificationRouter } from "./routers/notification";
|
||||||
import { organizationRouter } from "./routers/organization";
|
import { organizationRouter } from "./routers/organization";
|
||||||
|
import { patchRouter } from "./routers/patch";
|
||||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
import { ssoRouter } from "./routers/proprietary/sso";
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { portRouter } from "./routers/port";
|
import { portRouter } from "./routers/port";
|
||||||
@@ -90,6 +91,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
rollback: rollbackRouter,
|
rollback: rollbackRouter,
|
||||||
volumeBackups: volumeBackupsRouter,
|
volumeBackups: volumeBackupsRouter,
|
||||||
environment: environmentRouter,
|
environment: environmentRouter,
|
||||||
|
patch: patchRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
addNewService,
|
addNewService,
|
||||||
checkServiceAccess,
|
checkServiceAccess,
|
||||||
clearOldDeployments,
|
|
||||||
createApplication,
|
createApplication,
|
||||||
deleteAllMiddlewares,
|
deleteAllMiddlewares,
|
||||||
findApplicationById,
|
findApplicationById,
|
||||||
@@ -747,23 +746,6 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
await cleanQueuesByApplication(input.applicationId);
|
await cleanQueuesByApplication(input.applicationId);
|
||||||
}),
|
}),
|
||||||
clearDeployments: protectedProcedure
|
|
||||||
.input(apiFindOneApplication)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
const application = await findApplicationById(input.applicationId);
|
|
||||||
if (
|
|
||||||
application.environment.project.organizationId !==
|
|
||||||
ctx.session.activeOrganizationId
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
message:
|
|
||||||
"You are not authorized to clear deployments for this application",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await clearOldDeployments(application.appName, application.serverId);
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
killBuild: protectedProcedure
|
killBuild: protectedProcedure
|
||||||
.input(apiFindOneApplication)
|
.input(apiFindOneApplication)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
addDomainToCompose,
|
addDomainToCompose,
|
||||||
addNewService,
|
addNewService,
|
||||||
checkServiceAccess,
|
checkServiceAccess,
|
||||||
clearOldDeployments,
|
|
||||||
cloneCompose,
|
cloneCompose,
|
||||||
createCommand,
|
createCommand,
|
||||||
createCompose,
|
createCompose,
|
||||||
@@ -264,23 +263,6 @@ export const composeRouter = createTRPCRouter({
|
|||||||
await cleanQueuesByCompose(input.composeId);
|
await cleanQueuesByCompose(input.composeId);
|
||||||
return { success: true, message: "Queues cleaned successfully" };
|
return { success: true, message: "Queues cleaned successfully" };
|
||||||
}),
|
}),
|
||||||
clearDeployments: protectedProcedure
|
|
||||||
.input(apiFindCompose)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
const compose = await findComposeById(input.composeId);
|
|
||||||
if (
|
|
||||||
compose.environment.project.organizationId !==
|
|
||||||
ctx.session.activeOrganizationId
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
message:
|
|
||||||
"You are not authorized to clear deployments for this compose",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await clearOldDeployments(compose.appName, compose.serverId);
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
killBuild: protectedProcedure
|
killBuild: protectedProcedure
|
||||||
.input(apiFindCompose)
|
.input(apiFindCompose)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
findComposeById,
|
findComposeById,
|
||||||
findDeploymentById,
|
findDeploymentById,
|
||||||
findServerById,
|
findServerById,
|
||||||
removeDeployment,
|
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -108,14 +107,4 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
}),
|
}),
|
||||||
|
|
||||||
removeDeployment: protectedProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
deploymentId: z.string().min(1),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
return await removeDeployment(input.deploymentId);
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
createPushoverNotification,
|
createPushoverNotification,
|
||||||
createResendNotification,
|
createResendNotification,
|
||||||
createSlackNotification,
|
createSlackNotification,
|
||||||
createTeamsNotification,
|
|
||||||
createTelegramNotification,
|
createTelegramNotification,
|
||||||
findNotificationById,
|
findNotificationById,
|
||||||
getWebServerSettings,
|
getWebServerSettings,
|
||||||
@@ -24,7 +23,6 @@ import {
|
|||||||
sendResendNotification,
|
sendResendNotification,
|
||||||
sendServerThresholdNotifications,
|
sendServerThresholdNotifications,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTeamsNotification,
|
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
updateCustomNotification,
|
updateCustomNotification,
|
||||||
updateDiscordNotification,
|
updateDiscordNotification,
|
||||||
@@ -35,7 +33,6 @@ import {
|
|||||||
updatePushoverNotification,
|
updatePushoverNotification,
|
||||||
updateResendNotification,
|
updateResendNotification,
|
||||||
updateSlackNotification,
|
updateSlackNotification,
|
||||||
updateTeamsNotification,
|
|
||||||
updateTelegramNotification,
|
updateTelegramNotification,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -58,7 +55,6 @@ import {
|
|||||||
apiCreatePushover,
|
apiCreatePushover,
|
||||||
apiCreateResend,
|
apiCreateResend,
|
||||||
apiCreateSlack,
|
apiCreateSlack,
|
||||||
apiCreateTeams,
|
|
||||||
apiCreateTelegram,
|
apiCreateTelegram,
|
||||||
apiFindOneNotification,
|
apiFindOneNotification,
|
||||||
apiTestCustomConnection,
|
apiTestCustomConnection,
|
||||||
@@ -70,7 +66,6 @@ import {
|
|||||||
apiTestPushoverConnection,
|
apiTestPushoverConnection,
|
||||||
apiTestResendConnection,
|
apiTestResendConnection,
|
||||||
apiTestSlackConnection,
|
apiTestSlackConnection,
|
||||||
apiTestTeamsConnection,
|
|
||||||
apiTestTelegramConnection,
|
apiTestTelegramConnection,
|
||||||
apiUpdateCustom,
|
apiUpdateCustom,
|
||||||
apiUpdateDiscord,
|
apiUpdateDiscord,
|
||||||
@@ -81,7 +76,6 @@ import {
|
|||||||
apiUpdatePushover,
|
apiUpdatePushover,
|
||||||
apiUpdateResend,
|
apiUpdateResend,
|
||||||
apiUpdateSlack,
|
apiUpdateSlack,
|
||||||
apiUpdateTeams,
|
|
||||||
apiUpdateTelegram,
|
apiUpdateTelegram,
|
||||||
notifications,
|
notifications,
|
||||||
server,
|
server,
|
||||||
@@ -419,7 +413,6 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
pushover: true,
|
pushover: true,
|
||||||
teams: true,
|
|
||||||
},
|
},
|
||||||
orderBy: desc(notifications.createdAt),
|
orderBy: desc(notifications.createdAt),
|
||||||
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
|
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
|
||||||
@@ -712,61 +705,6 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
createTeams: adminProcedure
|
|
||||||
.input(apiCreateTeams)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
try {
|
|
||||||
return await createTeamsNotification(
|
|
||||||
input,
|
|
||||||
ctx.session.activeOrganizationId,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Error creating the notification",
|
|
||||||
cause: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
updateTeams: adminProcedure
|
|
||||||
.input(apiUpdateTeams)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
try {
|
|
||||||
const notification = await findNotificationById(input.notificationId);
|
|
||||||
if (
|
|
||||||
IS_CLOUD &&
|
|
||||||
notification.organizationId !== ctx.session.activeOrganizationId
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
message: "You are not authorized to update this notification",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return await updateTeamsNotification({
|
|
||||||
...input,
|
|
||||||
organizationId: ctx.session.activeOrganizationId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
testTeamsConnection: adminProcedure
|
|
||||||
.input(apiTestTeamsConnection)
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
try {
|
|
||||||
await sendTeamsNotification(input, {
|
|
||||||
title: "🤚 Test Notification",
|
|
||||||
facts: [{ name: "Message", value: "Hi, From Dokploy 👋" }],
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
|
||||||
cause: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
createPushover: adminProcedure
|
createPushover: adminProcedure
|
||||||
.input(apiCreatePushover)
|
.input(apiCreatePushover)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
502
apps/dokploy/server/api/routers/patch.ts
Normal file
502
apps/dokploy/server/api/routers/patch.ts
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
import {
|
||||||
|
checkServiceAccess,
|
||||||
|
cleanPatchRepos,
|
||||||
|
createPatch,
|
||||||
|
deletePatch,
|
||||||
|
ensurePatchRepo,
|
||||||
|
findApplicationById,
|
||||||
|
findComposeById,
|
||||||
|
findPatchById,
|
||||||
|
findPatchesByApplicationId,
|
||||||
|
findPatchesByComposeId,
|
||||||
|
findPatchByFilePath,
|
||||||
|
generatePatch,
|
||||||
|
readPatchRepoDirectory,
|
||||||
|
readPatchRepoFile,
|
||||||
|
updatePatch,
|
||||||
|
} from "@dokploy/server";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
adminProcedure,
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
} from "@/server/api/trpc";
|
||||||
|
import {
|
||||||
|
apiCreatePatch,
|
||||||
|
apiDeletePatch,
|
||||||
|
apiFindPatch,
|
||||||
|
apiFindPatchesByApplicationId,
|
||||||
|
apiFindPatchesByComposeId,
|
||||||
|
apiTogglePatchEnabled,
|
||||||
|
apiUpdatePatch,
|
||||||
|
} from "@/server/db/schema";
|
||||||
|
|
||||||
|
// Helper to get git config from application
|
||||||
|
const getApplicationGitConfig = (app: Awaited<ReturnType<typeof findApplicationById>>) => {
|
||||||
|
switch (app.sourceType) {
|
||||||
|
case "github":
|
||||||
|
return {
|
||||||
|
gitUrl: `https://github.com/${app.owner}/${app.repository}.git`,
|
||||||
|
gitBranch: app.branch || "main",
|
||||||
|
sshKeyId: null,
|
||||||
|
};
|
||||||
|
case "gitlab":
|
||||||
|
return {
|
||||||
|
gitUrl: `https://gitlab.com/${app.gitlabOwner}/${app.gitlabRepository}.git`,
|
||||||
|
gitBranch: app.gitlabBranch || "main",
|
||||||
|
sshKeyId: null,
|
||||||
|
};
|
||||||
|
case "gitea":
|
||||||
|
return {
|
||||||
|
gitUrl: app.gitea?.gitUrl
|
||||||
|
? `${app.gitea.gitUrl}/${app.giteaOwner}/${app.giteaRepository}.git`
|
||||||
|
: "",
|
||||||
|
gitBranch: app.giteaBranch || "main",
|
||||||
|
sshKeyId: null,
|
||||||
|
};
|
||||||
|
case "bitbucket":
|
||||||
|
return {
|
||||||
|
gitUrl: `https://bitbucket.org/${app.bitbucketOwner}/${app.bitbucketRepository}.git`,
|
||||||
|
gitBranch: app.bitbucketBranch || "main",
|
||||||
|
sshKeyId: null,
|
||||||
|
};
|
||||||
|
case "git":
|
||||||
|
return {
|
||||||
|
gitUrl: app.customGitUrl || "",
|
||||||
|
gitBranch: app.customGitBranch || "main",
|
||||||
|
sshKeyId: app.customGitSSHKeyId,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get git config from compose
|
||||||
|
const getComposeGitConfig = (compose: Awaited<ReturnType<typeof findComposeById>>) => {
|
||||||
|
switch (compose.sourceType) {
|
||||||
|
case "github":
|
||||||
|
return {
|
||||||
|
gitUrl: `https://github.com/${compose.owner}/${compose.repository}.git`,
|
||||||
|
gitBranch: compose.branch || "main",
|
||||||
|
sshKeyId: null,
|
||||||
|
};
|
||||||
|
case "gitlab":
|
||||||
|
return {
|
||||||
|
gitUrl: `https://gitlab.com/${compose.gitlabOwner}/${compose.gitlabRepository}.git`,
|
||||||
|
gitBranch: compose.gitlabBranch || "main",
|
||||||
|
sshKeyId: null,
|
||||||
|
};
|
||||||
|
case "gitea":
|
||||||
|
return {
|
||||||
|
gitUrl: compose.gitea?.gitUrl
|
||||||
|
? `${compose.gitea.gitUrl}/${compose.giteaOwner}/${compose.giteaRepository}.git`
|
||||||
|
: "",
|
||||||
|
gitBranch: compose.giteaBranch || "main",
|
||||||
|
sshKeyId: null,
|
||||||
|
};
|
||||||
|
case "bitbucket":
|
||||||
|
return {
|
||||||
|
gitUrl: `https://bitbucket.org/${compose.bitbucketOwner}/${compose.bitbucketRepository}.git`,
|
||||||
|
gitBranch: compose.bitbucketBranch || "main",
|
||||||
|
sshKeyId: null,
|
||||||
|
};
|
||||||
|
case "git":
|
||||||
|
return {
|
||||||
|
gitUrl: compose.customGitUrl || "",
|
||||||
|
gitBranch: compose.customGitBranch || "main",
|
||||||
|
sshKeyId: compose.customGitSSHKeyId,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const patchRouter = createTRPCRouter({
|
||||||
|
// CRUD Operations
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(apiCreatePatch)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
// Verify access
|
||||||
|
if (input.applicationId) {
|
||||||
|
const app = await findApplicationById(input.applicationId);
|
||||||
|
if (
|
||||||
|
app.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this application",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ctx.user.role === "member") {
|
||||||
|
await checkServiceAccess(
|
||||||
|
ctx.user.id,
|
||||||
|
input.applicationId,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
"access",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (input.composeId) {
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
if (
|
||||||
|
compose.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this compose",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await createPatch(input);
|
||||||
|
}),
|
||||||
|
|
||||||
|
one: protectedProcedure
|
||||||
|
.input(apiFindPatch)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return await findPatchById(input.patchId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
byApplicationId: protectedProcedure
|
||||||
|
.input(apiFindPatchesByApplicationId)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const app = await findApplicationById(input.applicationId);
|
||||||
|
if (
|
||||||
|
app.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this application",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await findPatchesByApplicationId(input.applicationId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
byComposeId: protectedProcedure
|
||||||
|
.input(apiFindPatchesByComposeId)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
if (
|
||||||
|
compose.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this compose",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await findPatchesByComposeId(input.composeId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(apiUpdatePatch)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { patchId, ...data } = input;
|
||||||
|
return await updatePatch(patchId, data);
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(apiDeletePatch)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return await deletePatch(input.patchId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
toggleEnabled: protectedProcedure
|
||||||
|
.input(apiTogglePatchEnabled)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return await updatePatch(input.patchId, { enabled: input.enabled });
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Repository Operations
|
||||||
|
ensureRepo: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
applicationId: z.string().optional(),
|
||||||
|
composeId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (input.applicationId) {
|
||||||
|
const app = await findApplicationById(input.applicationId);
|
||||||
|
if (
|
||||||
|
app.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this application",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitConfig = getApplicationGitConfig(app);
|
||||||
|
if (!gitConfig || !gitConfig.gitUrl) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Application does not have a git source configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ensurePatchRepo({
|
||||||
|
appName: app.appName,
|
||||||
|
type: "application",
|
||||||
|
gitUrl: gitConfig.gitUrl,
|
||||||
|
gitBranch: gitConfig.gitBranch,
|
||||||
|
sshKeyId: gitConfig.sshKeyId,
|
||||||
|
serverId: app.serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.composeId) {
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
if (
|
||||||
|
compose.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this compose",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitConfig = getComposeGitConfig(compose);
|
||||||
|
if (!gitConfig || !gitConfig.gitUrl) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Compose does not have a git source configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ensurePatchRepo({
|
||||||
|
appName: compose.appName,
|
||||||
|
type: "compose",
|
||||||
|
gitUrl: gitConfig.gitUrl,
|
||||||
|
gitBranch: gitConfig.gitBranch,
|
||||||
|
sshKeyId: gitConfig.sshKeyId,
|
||||||
|
serverId: compose.serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Either applicationId or composeId must be provided",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
readRepoDirectories: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
applicationId: z.string().optional(),
|
||||||
|
composeId: z.string().optional(),
|
||||||
|
repoPath: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
if (input.applicationId) {
|
||||||
|
const app = await findApplicationById(input.applicationId);
|
||||||
|
if (
|
||||||
|
app.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this application",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await readPatchRepoDirectory(input.repoPath, app.serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.composeId) {
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
if (
|
||||||
|
compose.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this compose",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await readPatchRepoDirectory(input.repoPath, compose.serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Either applicationId or composeId must be provided",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
readRepoFile: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
applicationId: z.string().optional(),
|
||||||
|
composeId: z.string().optional(),
|
||||||
|
repoPath: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
let serverId: string | null = null;
|
||||||
|
let patchContent: string | undefined;
|
||||||
|
|
||||||
|
if (input.applicationId) {
|
||||||
|
const app = await findApplicationById(input.applicationId);
|
||||||
|
if (
|
||||||
|
app.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this application",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
serverId = app.serverId;
|
||||||
|
|
||||||
|
// Check if patch exists for this file
|
||||||
|
const existingPatch = await findPatchByFilePath(
|
||||||
|
input.filePath,
|
||||||
|
input.applicationId,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (existingPatch?.enabled) {
|
||||||
|
patchContent = existingPatch.content;
|
||||||
|
}
|
||||||
|
} else if (input.composeId) {
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
if (
|
||||||
|
compose.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this compose",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
serverId = compose.serverId;
|
||||||
|
|
||||||
|
// Check if patch exists for this file
|
||||||
|
const existingPatch = await findPatchByFilePath(
|
||||||
|
input.filePath,
|
||||||
|
undefined,
|
||||||
|
input.composeId,
|
||||||
|
);
|
||||||
|
if (existingPatch?.enabled) {
|
||||||
|
patchContent = existingPatch.content;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Either applicationId or composeId must be provided",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await readPatchRepoFile(
|
||||||
|
input.repoPath,
|
||||||
|
input.filePath,
|
||||||
|
patchContent,
|
||||||
|
serverId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
saveFileAsPatch: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
applicationId: z.string().optional(),
|
||||||
|
composeId: z.string().optional(),
|
||||||
|
repoPath: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
let serverId: string | null = null;
|
||||||
|
|
||||||
|
if (input.applicationId) {
|
||||||
|
const app = await findApplicationById(input.applicationId);
|
||||||
|
if (
|
||||||
|
app.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this application",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
serverId = app.serverId;
|
||||||
|
} else if (input.composeId) {
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
if (
|
||||||
|
compose.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this compose",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
serverId = compose.serverId;
|
||||||
|
} else {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Either applicationId or composeId must be provided",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate patch diff
|
||||||
|
const patchContent = await generatePatch({
|
||||||
|
codePath: input.repoPath,
|
||||||
|
filePath: input.filePath,
|
||||||
|
newContent: input.content,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!patchContent.trim()) {
|
||||||
|
// No changes - remove existing patch if any
|
||||||
|
const existingPatch = await findPatchByFilePath(
|
||||||
|
input.filePath,
|
||||||
|
input.applicationId,
|
||||||
|
input.composeId,
|
||||||
|
);
|
||||||
|
if (existingPatch) {
|
||||||
|
await deletePatch(existingPatch.patchId);
|
||||||
|
}
|
||||||
|
return { deleted: true, patchId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if patch exists
|
||||||
|
const existingPatch = await findPatchByFilePath(
|
||||||
|
input.filePath,
|
||||||
|
input.applicationId,
|
||||||
|
input.composeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingPatch) {
|
||||||
|
// Update existing patch
|
||||||
|
await updatePatch(existingPatch.patchId, { content: patchContent });
|
||||||
|
return { deleted: false, patchId: existingPatch.patchId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new patch
|
||||||
|
const newPatch = await createPatch({
|
||||||
|
filePath: input.filePath,
|
||||||
|
content: patchContent,
|
||||||
|
enabled: true,
|
||||||
|
applicationId: input.applicationId,
|
||||||
|
composeId: input.composeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { deleted: false, patchId: newPatch.patchId };
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
cleanPatchRepos: adminProcedure
|
||||||
|
.input(z.object({ serverId: z.string().optional() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await cleanPatchRepos(input.serverId);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { user } from "@dokploy/server/db/schema";
|
import { user } from "@dokploy/server/db/schema";
|
||||||
import { hasValidLicense, validateLicenseKey } from "@dokploy/server/index";
|
import { validateLicenseKey } from "@dokploy/server/index";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -184,7 +184,18 @@ export const licenseKeyRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
haveValidLicenseKey: adminProcedure.query(async ({ ctx }) => {
|
haveValidLicenseKey: adminProcedure.query(async ({ ctx }) => {
|
||||||
return await hasValidLicense(ctx.session.activeOrganizationId);
|
const currentUserId = ctx.user.id;
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, currentUserId),
|
||||||
|
columns: {
|
||||||
|
enableEnterpriseFeatures: true,
|
||||||
|
isValidEnterpriseLicense: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return !!(
|
||||||
|
currentUser?.enableEnterpriseFeatures &&
|
||||||
|
currentUser?.isValidEnterpriseLicense
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
updateEnterpriseSettings: adminProcedure
|
updateEnterpriseSettings: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { normalizeTrustedOrigin } from "@dokploy/server";
|
|||||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
|
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
|
||||||
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
|
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
|
||||||
import {
|
import { requestToHeaders } from "@dokploy/server/index";
|
||||||
getOrganizationOwnerId,
|
|
||||||
requestToHeaders,
|
|
||||||
} from "@dokploy/server/index";
|
|
||||||
import { auth } from "@dokploy/server/lib/auth";
|
import { auth } from "@dokploy/server/lib/auth";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
@@ -58,148 +55,9 @@ export const ssoRouter = createTRPCRouter({
|
|||||||
samlConfig: true,
|
samlConfig: true,
|
||||||
organizationId: true,
|
organizationId: true,
|
||||||
},
|
},
|
||||||
orderBy: [asc(ssoProvider.createdAt)],
|
|
||||||
});
|
});
|
||||||
return providers;
|
return providers;
|
||||||
}),
|
}),
|
||||||
getTrustedOrigins: enterpriseProcedure.query(async ({ ctx }) => {
|
|
||||||
const ownerId = await getOrganizationOwnerId(
|
|
||||||
ctx.session.activeOrganizationId,
|
|
||||||
);
|
|
||||||
if (!ownerId) return [];
|
|
||||||
const ownerUser = await db.query.user.findFirst({
|
|
||||||
where: eq(user.id, ownerId),
|
|
||||||
columns: { trustedOrigins: true },
|
|
||||||
});
|
|
||||||
return ownerUser?.trustedOrigins ?? [];
|
|
||||||
}),
|
|
||||||
one: enterpriseProcedure
|
|
||||||
.input(z.object({ providerId: z.string().min(1) }))
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const provider = await db.query.ssoProvider.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(ssoProvider.providerId, input.providerId),
|
|
||||||
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
|
||||||
eq(ssoProvider.userId, ctx.session.userId),
|
|
||||||
),
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
providerId: true,
|
|
||||||
issuer: true,
|
|
||||||
domain: true,
|
|
||||||
oidcConfig: true,
|
|
||||||
samlConfig: true,
|
|
||||||
organizationId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!provider) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message:
|
|
||||||
"SSO provider not found or you do not have permission to access it",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return provider;
|
|
||||||
}),
|
|
||||||
update: enterpriseProcedure
|
|
||||||
.input(ssoProviderBodySchema)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const existing = await db.query.ssoProvider.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(ssoProvider.providerId, input.providerId),
|
|
||||||
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
|
||||||
eq(ssoProvider.userId, ctx.session.userId),
|
|
||||||
),
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
issuer: true,
|
|
||||||
domain: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message:
|
|
||||||
"SSO provider not found or you do not have permission to update it",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const providers = await db.query.ssoProvider.findMany({
|
|
||||||
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
|
||||||
columns: { providerId: true, domain: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const provider of providers) {
|
|
||||||
if (provider.providerId === input.providerId) continue;
|
|
||||||
const providerDomains = provider.domain
|
|
||||||
.split(",")
|
|
||||||
.map((d) => d.trim().toLowerCase());
|
|
||||||
for (const domain of input.domains) {
|
|
||||||
if (providerDomains.includes(domain)) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: `Domain ${domain} is already registered for another provider`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const issuerChanged =
|
|
||||||
normalizeTrustedOrigin(existing.issuer) !==
|
|
||||||
normalizeTrustedOrigin(input.issuer);
|
|
||||||
if (issuerChanged) {
|
|
||||||
const ownerId = await getOrganizationOwnerId(
|
|
||||||
ctx.session.activeOrganizationId,
|
|
||||||
);
|
|
||||||
if (!ownerId) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "Organization owner not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const ownerUser = await db.query.user.findFirst({
|
|
||||||
where: eq(user.id, ownerId),
|
|
||||||
columns: { trustedOrigins: true },
|
|
||||||
});
|
|
||||||
const trustedOrigins = ownerUser?.trustedOrigins ?? [];
|
|
||||||
const newOrigin = normalizeTrustedOrigin(input.issuer);
|
|
||||||
const isInTrustedOrigins = trustedOrigins.some(
|
|
||||||
(o) => o.toLowerCase() === newOrigin.toLowerCase(),
|
|
||||||
);
|
|
||||||
if (!isInTrustedOrigins) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message:
|
|
||||||
"The new Issuer URL is not in the organization's trusted origins list. Please add it in Manage origins before saving.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = input.domains.join(",");
|
|
||||||
const updateBody: {
|
|
||||||
issuer: string;
|
|
||||||
domain: string;
|
|
||||||
oidcConfig?: (typeof input)["oidcConfig"];
|
|
||||||
samlConfig?: (typeof input)["samlConfig"];
|
|
||||||
} = {
|
|
||||||
issuer: input.issuer,
|
|
||||||
domain,
|
|
||||||
};
|
|
||||||
if (input.oidcConfig != null) {
|
|
||||||
updateBody.oidcConfig = input.oidcConfig;
|
|
||||||
}
|
|
||||||
if (input.samlConfig != null) {
|
|
||||||
updateBody.samlConfig = input.samlConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
await auth.updateSSOProvider({
|
|
||||||
params: { providerId: input.providerId },
|
|
||||||
body: updateBody,
|
|
||||||
headers: requestToHeaders(ctx.req),
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
}),
|
|
||||||
deleteProvider: enterpriseProcedure
|
deleteProvider: enterpriseProcedure
|
||||||
.input(z.object({ providerId: z.string().min(1) }))
|
.input(z.object({ providerId: z.string().min(1) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -244,6 +102,24 @@ export const ssoRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, ctx.session.userId),
|
||||||
|
columns: {
|
||||||
|
trustedOrigins: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentUser?.trustedOrigins) {
|
||||||
|
const issuerOrigin = normalizeTrustedOrigin(providerToDelete.issuer);
|
||||||
|
const updatedOrigins = currentUser.trustedOrigins.filter(
|
||||||
|
(origin) => origin.toLowerCase() !== issuerOrigin.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({ trustedOrigins: updatedOrigins })
|
||||||
|
.where(eq(user.id, ctx.session.userId));
|
||||||
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
register: enterpriseProcedure
|
register: enterpriseProcedure
|
||||||
@@ -271,6 +147,25 @@ export const ssoRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const domain = input.domains.join(",");
|
const domain = input.domains.join(",");
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, ctx.session.userId),
|
||||||
|
columns: {
|
||||||
|
trustedOrigins: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingOrigins = currentUser?.trustedOrigins || [];
|
||||||
|
|
||||||
|
const issuerOrigin = normalizeTrustedOrigin(input.issuer);
|
||||||
|
|
||||||
|
const newOrigins = Array.from(
|
||||||
|
new Set([...existingOrigins, issuerOrigin]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({ trustedOrigins: newOrigins })
|
||||||
|
.where(eq(user.id, ctx.session.userId));
|
||||||
|
|
||||||
await auth.registerSSOProvider({
|
await auth.registerSSOProvider({
|
||||||
body: {
|
body: {
|
||||||
@@ -282,92 +177,4 @@ export const ssoRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
addTrustedOrigin: enterpriseProcedure
|
|
||||||
.input(z.object({ origin: z.string().min(1) }))
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const ownerId = await getOrganizationOwnerId(
|
|
||||||
ctx.session.activeOrganizationId,
|
|
||||||
);
|
|
||||||
if (!ownerId) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "Organization owner not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const normalized = normalizeTrustedOrigin(input.origin);
|
|
||||||
const ownerUser = await db.query.user.findFirst({
|
|
||||||
where: eq(user.id, ownerId),
|
|
||||||
columns: { trustedOrigins: true },
|
|
||||||
});
|
|
||||||
const existing = ownerUser?.trustedOrigins || [];
|
|
||||||
if (existing.some((o) => o.toLowerCase() === normalized.toLowerCase())) {
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
const next = Array.from(new Set([...existing, normalized]));
|
|
||||||
await db
|
|
||||||
.update(user)
|
|
||||||
.set({ trustedOrigins: next })
|
|
||||||
.where(eq(user.id, ownerId));
|
|
||||||
return { success: true };
|
|
||||||
}),
|
|
||||||
removeTrustedOrigin: enterpriseProcedure
|
|
||||||
.input(z.object({ origin: z.string().min(1) }))
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const ownerId = await getOrganizationOwnerId(
|
|
||||||
ctx.session.activeOrganizationId,
|
|
||||||
);
|
|
||||||
if (!ownerId) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "Organization owner not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const normalized = normalizeTrustedOrigin(input.origin);
|
|
||||||
const ownerUser = await db.query.user.findFirst({
|
|
||||||
where: eq(user.id, ownerId),
|
|
||||||
columns: { trustedOrigins: true },
|
|
||||||
});
|
|
||||||
const existing = ownerUser?.trustedOrigins || [];
|
|
||||||
const next = existing.filter(
|
|
||||||
(o) => o.toLowerCase() !== normalized.toLowerCase(),
|
|
||||||
);
|
|
||||||
await db
|
|
||||||
.update(user)
|
|
||||||
.set({ trustedOrigins: next })
|
|
||||||
.where(eq(user.id, ownerId));
|
|
||||||
return { success: true };
|
|
||||||
}),
|
|
||||||
updateTrustedOrigin: enterpriseProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
oldOrigin: z.string().min(1),
|
|
||||||
newOrigin: z.string().min(1),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const ownerId = await getOrganizationOwnerId(
|
|
||||||
ctx.session.activeOrganizationId,
|
|
||||||
);
|
|
||||||
if (!ownerId) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "Organization owner not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const oldNorm = normalizeTrustedOrigin(input.oldOrigin);
|
|
||||||
const newNorm = normalizeTrustedOrigin(input.newOrigin);
|
|
||||||
const ownerUser = await db.query.user.findFirst({
|
|
||||||
where: eq(user.id, ownerId),
|
|
||||||
columns: { trustedOrigins: true },
|
|
||||||
});
|
|
||||||
const existing = ownerUser?.trustedOrigins || [];
|
|
||||||
const next = existing.map((o) =>
|
|
||||||
o.toLowerCase() === oldNorm.toLowerCase() ? newNorm : o,
|
|
||||||
);
|
|
||||||
await db
|
|
||||||
.update(user)
|
|
||||||
.set({ trustedOrigins: next })
|
|
||||||
.where(eq(user.id, ownerId));
|
|
||||||
return { success: true };
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,17 +27,12 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
const products = await stripe.products.list({
|
const products = await stripe.products.list({
|
||||||
expand: ["data.default_price"],
|
expand: ["data.default_price"],
|
||||||
active: true,
|
active: true,
|
||||||
});
|
ids: [PRODUCT_MONTHLY_ID, PRODUCT_ANNUAL_ID],
|
||||||
|
|
||||||
const filteredProducts = products.data.filter((product) => {
|
|
||||||
return (
|
|
||||||
product.id === PRODUCT_MONTHLY_ID || product.id === PRODUCT_ANNUAL_ID
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!stripeCustomerId) {
|
if (!stripeCustomerId) {
|
||||||
return {
|
return {
|
||||||
products: filteredProducts,
|
products: products.data,
|
||||||
subscriptions: [],
|
subscriptions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -49,7 +44,7 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
products: filteredProducts,
|
products: products.data,
|
||||||
subscriptions: subscriptions.data,
|
subscriptions: subscriptions.data,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
* need to use are documented accordingly near the end.
|
* need to use are documented accordingly near the end.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { hasValidLicense } from "@dokploy/server/index";
|
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
@@ -240,11 +239,10 @@ export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
|
|||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasValidLicenseResult = await hasValidLicense(
|
if (
|
||||||
ctx.session.activeOrganizationId,
|
!ctx.user?.enableEnterpriseFeatures ||
|
||||||
);
|
!ctx.user.isValidEnterpriseLicense
|
||||||
|
) {
|
||||||
if (!hasValidLicenseResult) {
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Valid enterprise license required",
|
message: "Valid enterprise license required",
|
||||||
|
|||||||
@@ -3,13 +3,7 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
|
|||||||
import { spawn } from "node-pty";
|
import { spawn } from "node-pty";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import {
|
import { getShell, isValidContainerId } from "./utils";
|
||||||
getShell,
|
|
||||||
isValidContainerId,
|
|
||||||
isValidSearch,
|
|
||||||
isValidSince,
|
|
||||||
isValidTail,
|
|
||||||
} from "./utils";
|
|
||||||
|
|
||||||
export const setupDockerContainerLogsWebSocketServer = (
|
export const setupDockerContainerLogsWebSocketServer = (
|
||||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||||
@@ -36,9 +30,9 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
wssTerm.on("connection", async (ws, req) => {
|
wssTerm.on("connection", async (ws, req) => {
|
||||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||||
const containerId = url.searchParams.get("containerId");
|
const containerId = url.searchParams.get("containerId");
|
||||||
const tail = url.searchParams.get("tail") ?? "100";
|
const tail = url.searchParams.get("tail");
|
||||||
const search = url.searchParams.get("search") ?? "";
|
const search = url.searchParams.get("search");
|
||||||
const since = url.searchParams.get("since") ?? "all";
|
const since = url.searchParams.get("since");
|
||||||
const serverId = url.searchParams.get("serverId");
|
const serverId = url.searchParams.get("serverId");
|
||||||
const runType = url.searchParams.get("runType");
|
const runType = url.searchParams.get("runType");
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
@@ -54,21 +48,6 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidTail(tail)) {
|
|
||||||
ws.close(4000, "Invalid tail parameter");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidSince(since)) {
|
|
||||||
ws.close(4000, "Invalid since parameter");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search !== "" && !isValidSearch(search)) {
|
|
||||||
ws.close(4000, "Invalid search parameter");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user || !session) {
|
if (!user || !session) {
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type http from "node:http";
|
import type http from "node:http";
|
||||||
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
|
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
|
||||||
import { readValidDirectory } from "@dokploy/server/wss/utils";
|
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
|
import { readValidDirectory } from "./utils";
|
||||||
|
|
||||||
export const setupDeploymentLogsWebSocketServer = (
|
export const setupDeploymentLogsWebSocketServer = (
|
||||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||||
|
|||||||
@@ -15,37 +15,6 @@ export const isValidContainerId = (id: string): boolean => {
|
|||||||
return hexPattern.test(id) || (namePattern.test(id) && id.length <= 128);
|
return hexPattern.test(id) || (namePattern.test(id) && id.length <= 128);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the `tail` parameter for docker logs (number of lines, max 10000).
|
|
||||||
* Prevents command injection by allowing only digits.
|
|
||||||
*/
|
|
||||||
export const isValidTail = (tail: string): boolean => {
|
|
||||||
return (
|
|
||||||
/^\d+$/.test(tail) &&
|
|
||||||
Number.parseInt(tail, 10) <= 10000 &&
|
|
||||||
Number.parseInt(tail, 10) >= 0
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the `since` parameter for docker logs: "all" or duration like 5s, 10m, 1h, 2d.
|
|
||||||
* Prevents command injection by allowing only a strict format.
|
|
||||||
*/
|
|
||||||
export const isValidSince = (since: string): boolean => {
|
|
||||||
return since === "all" || /^\d+[smhd]$/.test(since);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the `search` parameter for log filtering.
|
|
||||||
* Search is concatenated into shell commands (SSH path: double quotes; local path: single quotes).
|
|
||||||
* Only allow alphanumeric, space, dot, underscore, hyphen to prevent $, `, ', " from enabling command injection.
|
|
||||||
* Max length 500.
|
|
||||||
*/
|
|
||||||
export const isValidSearch = (search: string): boolean => {
|
|
||||||
// Space only (not \s) to reject \n, \r, \t and other control chars
|
|
||||||
return /^[a-zA-Z0-9 ._-]{0,500}$/.test(search);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that the shell is one of the allowed shells.
|
* Validates that the shell is one of the allowed shells.
|
||||||
*/
|
*/
|
||||||
@@ -63,6 +32,20 @@ export const isValidShell = (shell: string): boolean => {
|
|||||||
return allowedShells.includes(shell);
|
return allowedShells.includes(shell);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const readValidDirectory = (
|
||||||
|
directory: string,
|
||||||
|
serverId?: string | null,
|
||||||
|
) => {
|
||||||
|
const { BASE_PATH } = paths(!!serverId);
|
||||||
|
|
||||||
|
const resolvedBase = path.resolve(BASE_PATH);
|
||||||
|
const resolvedDir = path.resolve(directory);
|
||||||
|
|
||||||
|
return (
|
||||||
|
resolvedDir === resolvedBase ||
|
||||||
|
resolvedDir.startsWith(resolvedBase + path.sep)
|
||||||
|
);
|
||||||
|
};
|
||||||
export const getShell = () => {
|
export const getShell = () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return "NO_AVAILABLE";
|
return "NO_AVAILABLE";
|
||||||
|
|||||||
@@ -39,7 +39,8 @@
|
|||||||
"**/*.js",
|
"**/*.js",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
"env.js",
|
"env.js",
|
||||||
"next.config.mjs"
|
"next.config.mjs",
|
||||||
|
"next-i18next.config.mjs"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
|||||||
16
apps/dokploy/utils/hooks/use-locale.ts
Normal file
16
apps/dokploy/utils/hooks/use-locale.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Cookies from "js-cookie";
|
||||||
|
import type { LanguageCode } from "@/lib/languages";
|
||||||
|
|
||||||
|
export default function useLocale() {
|
||||||
|
const currentLocale = (Cookies.get("DOKPLOY_LOCALE") ?? "en") as LanguageCode;
|
||||||
|
|
||||||
|
const setLocale = (locale: LanguageCode) => {
|
||||||
|
Cookies.set("DOKPLOY_LOCALE", locale, { expires: 365 });
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale: currentLocale,
|
||||||
|
setLocale,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
apps/dokploy/utils/i18n.ts
Normal file
23
apps/dokploy/utils/i18n.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { NextApiRequestCookies } from "next/dist/server/api-utils";
|
||||||
|
|
||||||
|
export function getLocale(cookies: NextApiRequestCookies) {
|
||||||
|
const locale = cookies.DOKPLOY_LOCALE ?? "en";
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { serverSideTranslations as originalServerSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
|
import { Languages } from "@/lib/languages";
|
||||||
|
|
||||||
|
export const serverSideTranslations = (
|
||||||
|
locale: string,
|
||||||
|
namespaces = ["common"],
|
||||||
|
) =>
|
||||||
|
originalServerSideTranslations(locale, namespaces, {
|
||||||
|
fallbackLng: "en",
|
||||||
|
keySeparator: false,
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: "en",
|
||||||
|
locales: Object.values(Languages).map((language) => language.code),
|
||||||
|
localeDetection: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.16.0",
|
"@types/node": "^20.16.0",
|
||||||
|
|||||||
42829
openapi.json
42829
openapi.json
File diff suppressed because it is too large
Load Diff
@@ -43,10 +43,5 @@
|
|||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/react": "18.3.5",
|
"@types/react": "18.3.5",
|
||||||
"@types/react-dom": "18.3.0"
|
"@types/react-dom": "18.3.0"
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"esbuild": "0.20.2"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
# Debug build OOM – orden para probar
|
|
||||||
|
|
||||||
Ejecuta desde `packages/server` (o `pnpm --filter=@dokploy/server run <script>` desde la raíz).
|
|
||||||
|
|
||||||
1. **`pnpm run build:debug:noEmit`**
|
|
||||||
Solo typecheck, no escribe archivos.
|
|
||||||
- Si hace **OOM** → el problema es el análisis de tipos (ej. zod u otras libs).
|
|
||||||
- Si **pasa** → el problema está en emit (JS o `.d.ts`).
|
|
||||||
|
|
||||||
2. **`pnpm run build:debug:noEmit:8gb`**
|
|
||||||
Mismo que el anterior pero con 8GB de heap.
|
|
||||||
- Si con 8GB **pasa** y sin 8GB **no** → el typecheck necesita más memoria.
|
|
||||||
|
|
||||||
3. **`pnpm run build:debug:noDecl`**
|
|
||||||
Compila solo JS (sin `declaration`).
|
|
||||||
- Si hace **OOM** → el problema es emitir JS.
|
|
||||||
- Si **pasa** → el problema es generar `.d.ts`.
|
|
||||||
|
|
||||||
4. **`pnpm run build:debug:declOnly`**
|
|
||||||
Solo genera declaraciones (`.d.ts`).
|
|
||||||
- Si hace **OOM** → el cuello de botella son las declaraciones.
|
|
||||||
|
|
||||||
5. **`pnpm run build:debug:full`**
|
|
||||||
Build completo con `--extendedDiagnostics` (imprime estadísticas al final).
|
|
||||||
- Para ver en qué paso se va la memoria si no has localizado antes.
|
|
||||||
|
|
||||||
Con eso sabes si el OOM viene de: typecheck, emit JS o emit declarations, y puedes elegir fix (más memoria, esbuild para JS, o no emitir declarations).
|
|
||||||
@@ -30,13 +30,13 @@
|
|||||||
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
|
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.44",
|
"@ai-sdk/anthropic": "^2.0.5",
|
||||||
"@ai-sdk/azure": "^3.0.30",
|
"@ai-sdk/azure": "^2.0.16",
|
||||||
"@ai-sdk/cohere": "^3.0.21",
|
"@ai-sdk/cohere": "^2.0.4",
|
||||||
"@ai-sdk/deepinfra": "^2.0.34",
|
"@ai-sdk/deepinfra": "^1.0.10",
|
||||||
"@ai-sdk/mistral": "^3.0.20",
|
"@ai-sdk/mistral": "^2.0.7",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^2.0.16",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^1.0.10",
|
||||||
"@better-auth/utils": "0.3.0",
|
"@better-auth/utils": "0.3.0",
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^8.4.1",
|
||||||
"@octokit/auth-app": "^6.1.3",
|
"@octokit/auth-app": "^6.1.3",
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@react-email/components": "^0.0.21",
|
"@react-email/components": "^0.0.21",
|
||||||
"@better-auth/sso": "1.4.18",
|
"@better-auth/sso":"1.4.18",
|
||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^10.45.2",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"ai": "^6.0.86",
|
"ai": "^5.0.17",
|
||||||
"ai-sdk-ollama": "^3.7.0",
|
"ai-sdk-ollama": "^0.5.1",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "1.4.18",
|
"better-auth": "1.4.18",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
@@ -81,11 +81,11 @@
|
|||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
"toml": "3.0.0",
|
"toml": "3.0.0",
|
||||||
"ws": "8.16.0",
|
"ws": "8.16.0",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.32",
|
||||||
"semver": "7.7.3"
|
"semver": "7.7.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@better-auth/cli": "1.4.18",
|
"@better-auth/cli": "1.4.18",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
|
|||||||
@@ -32,5 +32,6 @@ export const paths = (isServer = false) => {
|
|||||||
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
||||||
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
|
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
|
||||||
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
|
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
|
||||||
|
PATCH_REPOS_PATH: `${BASE_PATH}/patch-repos`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { gitea } from "./gitea";
|
|||||||
import { github } from "./github";
|
import { github } from "./github";
|
||||||
import { gitlab } from "./gitlab";
|
import { gitlab } from "./gitlab";
|
||||||
import { mounts } from "./mount";
|
import { mounts } from "./mount";
|
||||||
|
import { patch } from "./patch";
|
||||||
import { ports } from "./port";
|
import { ports } from "./port";
|
||||||
import { previewDeployments } from "./preview-deployments";
|
import { previewDeployments } from "./preview-deployments";
|
||||||
import { redirects } from "./redirects";
|
import { redirects } from "./redirects";
|
||||||
@@ -286,6 +287,7 @@ export const applicationsRelations = relations(
|
|||||||
references: [registry.registryId],
|
references: [registry.registryId],
|
||||||
relationName: "applicationRollbackRegistry",
|
relationName: "applicationRollbackRegistry",
|
||||||
}),
|
}),
|
||||||
|
patches: many(patch),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { gitea } from "./gitea";
|
|||||||
import { github } from "./github";
|
import { github } from "./github";
|
||||||
import { gitlab } from "./gitlab";
|
import { gitlab } from "./gitlab";
|
||||||
import { mounts } from "./mount";
|
import { mounts } from "./mount";
|
||||||
|
import { patch } from "./patch";
|
||||||
import { schedules } from "./schedule";
|
import { schedules } from "./schedule";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import { applicationStatus, triggerType } from "./shared";
|
import { applicationStatus, triggerType } from "./shared";
|
||||||
@@ -143,6 +144,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
|||||||
}),
|
}),
|
||||||
backups: many(backups),
|
backups: many(backups),
|
||||||
schedules: many(schedules),
|
schedules: many(schedules),
|
||||||
|
patches: many(patch),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(compose, {
|
const createSchema = createInsertSchema(compose, {
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ const schema = createInsertSchema(deployments, {
|
|||||||
previewDeploymentId: z.string(),
|
previewDeploymentId: z.string(),
|
||||||
buildServerId: z.string(),
|
buildServerId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateDeployment = schema
|
export const apiCreateDeployment = schema
|
||||||
.pick({
|
.pick({
|
||||||
title: true,
|
title: true,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export * from "./mongo";
|
|||||||
export * from "./mount";
|
export * from "./mount";
|
||||||
export * from "./mysql";
|
export * from "./mysql";
|
||||||
export * from "./notification";
|
export * from "./notification";
|
||||||
|
export * from "./patch";
|
||||||
export * from "./port";
|
export * from "./port";
|
||||||
export * from "./postgres";
|
export * from "./postgres";
|
||||||
export * from "./preview-deployments";
|
export * from "./preview-deployments";
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const notificationType = pgEnum("notificationType", [
|
|||||||
"pushover",
|
"pushover",
|
||||||
"custom",
|
"custom",
|
||||||
"lark",
|
"lark",
|
||||||
"teams",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const notifications = pgTable("notification", {
|
export const notifications = pgTable("notification", {
|
||||||
@@ -73,9 +72,6 @@ export const notifications = pgTable("notification", {
|
|||||||
pushoverId: text("pushoverId").references(() => pushover.pushoverId, {
|
pushoverId: text("pushoverId").references(() => pushover.pushoverId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
teamsId: text("teamsId").references(() => teams.teamsId, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
organizationId: text("organizationId")
|
organizationId: text("organizationId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
@@ -183,14 +179,6 @@ export const pushover = pgTable("pushover", {
|
|||||||
expire: integer("expire"),
|
expire: integer("expire"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const teams = pgTable("teams", {
|
|
||||||
teamsId: text("teamsId")
|
|
||||||
.notNull()
|
|
||||||
.primaryKey()
|
|
||||||
.$defaultFn(() => nanoid()),
|
|
||||||
webhookUrl: text("webhookUrl").notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||||
slack: one(slack, {
|
slack: one(slack, {
|
||||||
fields: [notifications.slackId],
|
fields: [notifications.slackId],
|
||||||
@@ -232,10 +220,6 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
|
|||||||
fields: [notifications.pushoverId],
|
fields: [notifications.pushoverId],
|
||||||
references: [pushover.pushoverId],
|
references: [pushover.pushoverId],
|
||||||
}),
|
}),
|
||||||
teams: one(teams, {
|
|
||||||
fields: [notifications.teamsId],
|
|
||||||
references: [teams.teamsId],
|
|
||||||
}),
|
|
||||||
organization: one(organization, {
|
organization: one(organization, {
|
||||||
fields: [notifications.organizationId],
|
fields: [notifications.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
@@ -523,32 +507,6 @@ export const apiTestLarkConnection = apiCreateLark.pick({
|
|||||||
webhookUrl: true,
|
webhookUrl: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateTeams = notificationsSchema
|
|
||||||
.pick({
|
|
||||||
appBuildError: true,
|
|
||||||
databaseBackup: true,
|
|
||||||
volumeBackup: true,
|
|
||||||
dokployRestart: true,
|
|
||||||
name: true,
|
|
||||||
appDeploy: true,
|
|
||||||
dockerCleanup: true,
|
|
||||||
serverThreshold: true,
|
|
||||||
})
|
|
||||||
.extend({
|
|
||||||
webhookUrl: z.string().min(1),
|
|
||||||
})
|
|
||||||
.required();
|
|
||||||
|
|
||||||
export const apiUpdateTeams = apiCreateTeams.partial().extend({
|
|
||||||
notificationId: z.string().min(1),
|
|
||||||
teamsId: z.string().min(1),
|
|
||||||
organizationId: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const apiTestTeamsConnection = apiCreateTeams.pick({
|
|
||||||
webhookUrl: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const apiCreatePushover = notificationsSchema
|
export const apiCreatePushover = notificationsSchema
|
||||||
.pick({
|
.pick({
|
||||||
appBuildError: true,
|
appBuildError: true,
|
||||||
|
|||||||
95
packages/server/src/db/schema/patch.ts
Normal file
95
packages/server/src/db/schema/patch.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { boolean, pgTable, text, unique } from "drizzle-orm/pg-core";
|
||||||
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { applications } from "./application";
|
||||||
|
import { compose } from "./compose";
|
||||||
|
|
||||||
|
export const patch = pgTable(
|
||||||
|
"patch",
|
||||||
|
{
|
||||||
|
patchId: text("patchId")
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
filePath: text("filePath").notNull(),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
createdAt: text("createdAt")
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
|
updatedAt: text("updatedAt").$defaultFn(() => new Date().toISOString()),
|
||||||
|
// Relations - one of these must be set
|
||||||
|
applicationId: text("applicationId").references(
|
||||||
|
() => applications.applicationId,
|
||||||
|
{ onDelete: "cascade" },
|
||||||
|
),
|
||||||
|
composeId: text("composeId").references(() => compose.composeId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
// Unique constraint: one patch per file per application/compose
|
||||||
|
unique("patch_filepath_application_unique").on(
|
||||||
|
table.filePath,
|
||||||
|
table.applicationId,
|
||||||
|
),
|
||||||
|
unique("patch_filepath_compose_unique").on(table.filePath, table.composeId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const patchRelations = relations(patch, ({ one }) => ({
|
||||||
|
application: one(applications, {
|
||||||
|
fields: [patch.applicationId],
|
||||||
|
references: [applications.applicationId],
|
||||||
|
}),
|
||||||
|
compose: one(compose, {
|
||||||
|
fields: [patch.composeId],
|
||||||
|
references: [compose.composeId],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createSchema = createInsertSchema(patch, {
|
||||||
|
filePath: z.string().min(1),
|
||||||
|
content: z.string(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
applicationId: z.string().optional(),
|
||||||
|
composeId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiCreatePatch = createSchema.pick({
|
||||||
|
filePath: true,
|
||||||
|
content: true,
|
||||||
|
enabled: true,
|
||||||
|
applicationId: true,
|
||||||
|
composeId: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiFindPatch = z.object({
|
||||||
|
patchId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiFindPatchesByApplicationId = z.object({
|
||||||
|
applicationId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiFindPatchesByComposeId = z.object({
|
||||||
|
composeId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiUpdatePatch = createSchema
|
||||||
|
.partial()
|
||||||
|
.extend({
|
||||||
|
patchId: z.string().min(1),
|
||||||
|
})
|
||||||
|
.omit({ applicationId: true, composeId: true });
|
||||||
|
|
||||||
|
export const apiDeletePatch = z.object({
|
||||||
|
patchId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiTogglePatchEnabled = z.object({
|
||||||
|
patchId: z.string().min(1),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
import { pgTable, text } from "drizzle-orm/pg-core";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { organization } from "./account";
|
import { organization } from "./account";
|
||||||
import { user } from "./user";
|
import { user } from "./user";
|
||||||
@@ -15,7 +15,6 @@ export const ssoProvider = pgTable("sso_provider", {
|
|||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
domain: text("domain").notNull(),
|
domain: text("domain").notNull(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ export * from "./services/mongo";
|
|||||||
export * from "./services/mount";
|
export * from "./services/mount";
|
||||||
export * from "./services/mysql";
|
export * from "./services/mysql";
|
||||||
export * from "./services/notification";
|
export * from "./services/notification";
|
||||||
|
export * from "./services/patch";
|
||||||
|
export * from "./services/patch-repo";
|
||||||
export * from "./services/port";
|
export * from "./services/port";
|
||||||
export * from "./services/postgres";
|
export * from "./services/postgres";
|
||||||
export * from "./services/preview-deployment";
|
export * from "./services/preview-deployment";
|
||||||
export * from "./services/project";
|
export * from "./services/project";
|
||||||
export * from "./services/proprietary/license-key";
|
|
||||||
export * from "./services/proprietary/sso";
|
export * from "./services/proprietary/sso";
|
||||||
export * from "./services/redirect";
|
export * from "./services/redirect";
|
||||||
export * from "./services/redis";
|
export * from "./services/redis";
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
|||||||
import { sendEmail } from "../verification/send-verification-email";
|
import { sendEmail } from "../verification/send-verification-email";
|
||||||
import { getPublicIpWithFallback } from "../wss/utils";
|
import { getPublicIpWithFallback } from "../wss/utils";
|
||||||
|
|
||||||
const trustedProviders = process.env?.TRUSTED_PROVIDERS?.split(",") || [];
|
|
||||||
|
|
||||||
const { handler, api } = betterAuth({
|
const { handler, api } = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
@@ -45,14 +43,17 @@ const { handler, api } = betterAuth({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(IS_CLOUD
|
||||||
account: {
|
? {
|
||||||
accountLinking: {
|
account: {
|
||||||
enabled: true,
|
accountLinking: {
|
||||||
trustedProviders: ["github", "google", ...(trustedProviders || [])],
|
enabled: true,
|
||||||
allowDifferentEmails: true,
|
trustedProviders: ["github", "google"],
|
||||||
},
|
allowDifferentEmails: true,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
appName: "Dokploy",
|
appName: "Dokploy",
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
github: {
|
github: {
|
||||||
@@ -347,7 +348,6 @@ export const auth = {
|
|||||||
handler,
|
handler,
|
||||||
createApiKey: api.createApiKey,
|
createApiKey: api.createApiKey,
|
||||||
registerSSOProvider: api.registerSSOProvider,
|
registerSSOProvider: api.registerSSOProvider,
|
||||||
updateSSOProvider: api.updateSSOProvider,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateRequest = async (request: IncomingMessage) => {
|
export const validateRequest = async (request: IncomingMessage) => {
|
||||||
|
|||||||
@@ -2,31 +2,13 @@ import { db } from "@dokploy/server/db";
|
|||||||
import { ai } from "@dokploy/server/db/schema";
|
import { ai } from "@dokploy/server/db/schema";
|
||||||
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { generateText, Output } from "ai";
|
import { generateObject } from "ai";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { IS_CLOUD } from "../constants";
|
import { IS_CLOUD } from "../constants";
|
||||||
import { findServerById } from "./server";
|
import { findServerById } from "./server";
|
||||||
import { getWebServerSettings } from "./web-server-settings";
|
import { getWebServerSettings } from "./web-server-settings";
|
||||||
|
|
||||||
interface SuggestionItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
shortDescription: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SuggestionsOutput {
|
|
||||||
suggestions: SuggestionItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DockerOutput {
|
|
||||||
dockerCompose: string;
|
|
||||||
envVariables: Array<{ name: string; value: string }>;
|
|
||||||
domains: Array<{ host: string; port: number; serviceName: string }>;
|
|
||||||
configFiles?: Array<{ content: string; filePath: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
||||||
const aiSettings = await db.query.ai.findMany({
|
const aiSettings = await db.query.ai.findMany({
|
||||||
where: eq(ai.organizationId, organizationId),
|
where: eq(ai.organizationId, organizationId),
|
||||||
@@ -78,7 +60,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const suggestVariants = async ({
|
export const suggestVariants = async ({
|
||||||
organizationId: _organizationId,
|
organizationId,
|
||||||
aiId,
|
aiId,
|
||||||
input,
|
input,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -108,177 +90,173 @@ export const suggestVariants = async ({
|
|||||||
ip = "127.0.0.1";
|
ip = "127.0.0.1";
|
||||||
}
|
}
|
||||||
|
|
||||||
const suggestionsSchema = z.object({
|
const { object } = await generateObject({
|
||||||
suggestions: z.array(
|
|
||||||
z.object({
|
|
||||||
id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
shortDescription: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const suggestionsResult = await generateText({
|
|
||||||
model,
|
model,
|
||||||
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
|
output: "object",
|
||||||
output: Output.object({ schema: suggestionsSchema }),
|
schema: z.object({
|
||||||
|
suggestions: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
shortDescription: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
prompt: `
|
prompt: `
|
||||||
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
|
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
|
||||||
|
|
||||||
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
|
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
|
||||||
|
|
||||||
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
|
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
|
||||||
- Generate different deployment VARIANTS of that SAME application
|
- Generate different deployment VARIANTS of that SAME application
|
||||||
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
|
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
|
||||||
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
|
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
|
||||||
- The name MUST include the specific application name the user mentioned
|
- The name MUST include the specific application name the user mentioned
|
||||||
|
|
||||||
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
|
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
|
||||||
- Suggest different open source projects that fulfill that need
|
- Suggest different open source projects that fulfill that need
|
||||||
- Each suggestion should be a different tool/platform that solves the same problem
|
- Each suggestion should be a different tool/platform that solves the same problem
|
||||||
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
|
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
|
||||||
- The name should be the actual project name
|
- The name should be the actual project name
|
||||||
|
|
||||||
Return your response as a JSON object with the following structure:
|
Return your response as a JSON object with the following structure:
|
||||||
{
|
{
|
||||||
"suggestions": [
|
"suggestions": [
|
||||||
{
|
{
|
||||||
"id": "project-or-variant-slug",
|
"id": "project-or-variant-slug",
|
||||||
"name": "Project Name or Variant Name",
|
"name": "Project Name or Variant Name",
|
||||||
"shortDescription": "Brief one-line description",
|
"shortDescription": "Brief one-line description",
|
||||||
"description": "Detailed description"
|
"description": "Detailed description"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Important rules for the response:
|
Important rules for the response:
|
||||||
1. Use slug format for the id field (lowercase, hyphenated)
|
1. Use slug format for the id field (lowercase, hyphenated)
|
||||||
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
|
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
|
||||||
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
|
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
|
||||||
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
|
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
|
||||||
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
|
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
|
||||||
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
|
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
|
||||||
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
|
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
|
||||||
8. All suggestions should be installable in docker and have docker compose support
|
8. All suggestions should be installable in docker and have docker compose support
|
||||||
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
|
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
|
||||||
|
|
||||||
User wants to create a new project with the following details:
|
User wants to create a new project with the following details:
|
||||||
|
|
||||||
${input}
|
${input}
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
const object = suggestionsResult.output as SuggestionsOutput | undefined;
|
|
||||||
|
|
||||||
if (object?.suggestions?.length) {
|
if (object?.suggestions?.length) {
|
||||||
const dockerSchema = z.object({
|
|
||||||
dockerCompose: z.string(),
|
|
||||||
envVariables: z.array(
|
|
||||||
z.object({
|
|
||||||
name: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
domains: z.array(
|
|
||||||
z.object({
|
|
||||||
host: z.string(),
|
|
||||||
port: z.number(),
|
|
||||||
serviceName: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
configFiles: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
content: z.string(),
|
|
||||||
filePath: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const suggestion of object.suggestions) {
|
for (const suggestion of object.suggestions) {
|
||||||
try {
|
try {
|
||||||
const dockerResult = await generateText({
|
const { object: docker } = await generateObject({
|
||||||
model,
|
model,
|
||||||
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
|
output: "object",
|
||||||
output: Output.object({ schema: dockerSchema }),
|
schema: z.object({
|
||||||
|
dockerCompose: z.string(),
|
||||||
|
envVariables: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
domains: z.array(
|
||||||
|
z.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: z.number(),
|
||||||
|
serviceName: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
configFiles: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
content: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
prompt: `
|
prompt: `
|
||||||
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
|
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
|
||||||
|
|
||||||
Return your response as a JSON object with this structure:
|
Return your response as a JSON object with this structure:
|
||||||
{
|
{
|
||||||
"dockerCompose": "yaml string here",
|
"dockerCompose": "yaml string here",
|
||||||
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
|
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
|
||||||
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
|
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
|
||||||
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
|
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
|
||||||
}
|
}
|
||||||
|
|
||||||
Note: configFiles is optional - only include it if configuration files are absolutely required.
|
Note: configFiles is optional - only include it if configuration files are absolutely required.
|
||||||
|
|
||||||
Follow these rules:
|
Follow these rules:
|
||||||
|
|
||||||
Docker Compose Rules:
|
Docker Compose Rules:
|
||||||
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
|
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
|
||||||
2. Use complex values for passwords/secrets variables
|
2. Use complex values for passwords/secrets variables
|
||||||
3. Don't set container_name field in services
|
3. Don't set container_name field in services
|
||||||
4. Don't set version field in the docker compose
|
4. Don't set version field in the docker compose
|
||||||
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
|
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
|
||||||
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
|
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
|
||||||
7. Make sure all required services are defined in the docker-compose
|
7. Make sure all required services are defined in the docker-compose
|
||||||
|
|
||||||
Docker Image Rules (CRITICAL):
|
Docker Image Rules (CRITICAL):
|
||||||
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
||||||
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
||||||
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
||||||
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
|
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
|
||||||
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
||||||
6. Examples of correct image usage:
|
6. Examples of correct image usage:
|
||||||
- image: sendingtk/chatwoot:develop
|
- image: sendingtk/chatwoot:develop
|
||||||
- image: postgres:16-alpine
|
- image: postgres:16-alpine
|
||||||
- image: redis:7-alpine
|
- image: redis:7-alpine
|
||||||
- image: chatwoot/chatwoot:latest
|
- image: chatwoot/chatwoot:latest
|
||||||
7. Examples of INCORRECT usage (DO NOT USE):
|
7. Examples of INCORRECT usage (DO NOT USE):
|
||||||
- build: .
|
- build: .
|
||||||
- build: ./app
|
- build: ./app
|
||||||
- build:
|
- build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
Volume Mounting and Configuration Rules:
|
Volume Mounting and Configuration Rules:
|
||||||
1. DO NOT create configuration files unless the service CANNOT work without them
|
1. DO NOT create configuration files unless the service CANNOT work without them
|
||||||
2. Most services can work with just environment variables - USE THEM FIRST
|
2. Most services can work with just environment variables - USE THEM FIRST
|
||||||
3. Ask yourself: "Can this be configured with an environment variable instead?"
|
3. Ask yourself: "Can this be configured with an environment variable instead?"
|
||||||
4. If and ONLY IF a config file is absolutely required:
|
4. If and ONLY IF a config file is absolutely required:
|
||||||
- Keep it minimal with only critical settings
|
- Keep it minimal with only critical settings
|
||||||
- Use "../files/" prefix for all mounts
|
- Use "../files/" prefix for all mounts
|
||||||
- Format: "../files/folder:/container/path"
|
- Format: "../files/folder:/container/path"
|
||||||
5. DO NOT add configuration files for:
|
5. DO NOT add configuration files for:
|
||||||
- Default configurations that work out of the box
|
- Default configurations that work out of the box
|
||||||
- Settings that can be handled by environment variables
|
- Settings that can be handled by environment variables
|
||||||
- Proxy or routing configurations (these are handled elsewhere)
|
- Proxy or routing configurations (these are handled elsewhere)
|
||||||
|
|
||||||
Environment Variables Rules:
|
Environment Variables Rules:
|
||||||
1. For the envVariables array, provide ACTUAL example values, not placeholders
|
1. For the envVariables array, provide ACTUAL example values, not placeholders
|
||||||
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
|
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
|
||||||
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
|
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
|
||||||
4. ONLY include environment variables that are actually used in the docker-compose
|
4. ONLY include environment variables that are actually used in the docker-compose
|
||||||
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
|
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
|
||||||
6. Do not include environment variables for services that don't exist in the docker-compose
|
6. Do not include environment variables for services that don't exist in the docker-compose
|
||||||
|
|
||||||
For each service that needs to be exposed to the internet:
|
For each service that needs to be exposed to the internet:
|
||||||
1. Define a domain configuration with:
|
1. Define a domain configuration with:
|
||||||
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
|
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
|
||||||
- port: the internal port the service runs on
|
- port: the internal port the service runs on
|
||||||
- serviceName: the name of the service in the docker-compose
|
- serviceName: the name of the service in the docker-compose
|
||||||
2. Make sure the service is properly configured to work with the specified port
|
2. Make sure the service is properly configured to work with the specified port
|
||||||
|
|
||||||
User's original request: ${input}
|
User's original request: ${input}
|
||||||
|
|
||||||
Project details:
|
Project details:
|
||||||
${suggestion?.description}
|
${suggestion?.description}
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
const docker = dockerResult.output as DockerOutput | undefined;
|
if (!!docker && !!docker.dockerCompose) {
|
||||||
if (docker?.dockerCompose) {
|
|
||||||
result.push({
|
result.push({
|
||||||
...suggestion,
|
...suggestion,
|
||||||
...docker,
|
...docker,
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ import {
|
|||||||
issueCommentExists,
|
issueCommentExists,
|
||||||
updateIssueComment,
|
updateIssueComment,
|
||||||
} from "./github";
|
} from "./github";
|
||||||
|
import {
|
||||||
|
findPatchesByApplicationId,
|
||||||
|
generateApplyPatchesCommand,
|
||||||
|
} from "./patch";
|
||||||
import {
|
import {
|
||||||
findPreviewDeploymentById,
|
findPreviewDeploymentById,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -202,6 +206,20 @@ export const deployApplication = async ({
|
|||||||
command += await buildRemoteDocker(application);
|
command += await buildRemoteDocker(application);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply patches after cloning (for non-docker sources only)
|
||||||
|
if (application.sourceType !== "docker") {
|
||||||
|
const patches = await findPatchesByApplicationId(application.applicationId);
|
||||||
|
const enabledPatches = patches.filter(p => p.enabled);
|
||||||
|
if (enabledPatches.length > 0) {
|
||||||
|
command += generateApplyPatchesCommand({
|
||||||
|
appName: application.appName,
|
||||||
|
type: "application",
|
||||||
|
serverId,
|
||||||
|
patches: enabledPatches,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
command += await getBuildCommand(application);
|
command += await getBuildCommand(application);
|
||||||
|
|
||||||
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ import {
|
|||||||
updateDeployment,
|
updateDeployment,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
} from "./deployment";
|
} from "./deployment";
|
||||||
|
import {
|
||||||
|
findPatchesByComposeId,
|
||||||
|
generateApplyPatchesCommand,
|
||||||
|
} from "./patch";
|
||||||
import { validUniqueServerAppName } from "./project";
|
import { validUniqueServerAppName } from "./project";
|
||||||
|
|
||||||
export type Compose = typeof compose.$inferSelect;
|
export type Compose = typeof compose.$inferSelect;
|
||||||
@@ -248,6 +252,26 @@ export const deployCompose = async ({
|
|||||||
await execAsync(commandWithLog);
|
await execAsync(commandWithLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply patches after cloning (for non-raw sources only)
|
||||||
|
if (compose.sourceType !== "raw") {
|
||||||
|
const patches = await findPatchesByComposeId(compose.composeId);
|
||||||
|
const enabledPatches = patches.filter(p => p.enabled);
|
||||||
|
if (enabledPatches.length > 0) {
|
||||||
|
const patchCommand = generateApplyPatchesCommand({
|
||||||
|
appName: compose.appName,
|
||||||
|
type: "compose",
|
||||||
|
serverId: compose.serverId,
|
||||||
|
patches: enabledPatches,
|
||||||
|
});
|
||||||
|
const patchCommandWithLog = `(${patchCommand}) >> ${deployment.logPath} 2>&1`;
|
||||||
|
if (compose.serverId) {
|
||||||
|
await execAsyncRemote(compose.serverId, patchCommandWithLog);
|
||||||
|
} else {
|
||||||
|
await execAsync(patchCommandWithLog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
command = "set -e;";
|
command = "set -e;";
|
||||||
command += await getBuildComposeCommand(entity);
|
command += await getBuildComposeCommand(entity);
|
||||||
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||||
@@ -395,14 +419,16 @@ export const removeCompose = async (
|
|||||||
if (compose.composeType === "stack") {
|
if (compose.composeType === "stack") {
|
||||||
const command = `
|
const command = `
|
||||||
docker network disconnect ${compose.appName} dokploy-traefik;
|
docker network disconnect ${compose.appName} dokploy-traefik;
|
||||||
docker stack rm ${compose.appName};
|
cd ${projectPath} && docker stack rm ${compose.appName} && rm -rf ${projectPath}`;
|
||||||
rm -rf ${projectPath}`;
|
|
||||||
|
|
||||||
if (compose.serverId) {
|
if (compose.serverId) {
|
||||||
await execAsyncRemote(compose.serverId, command);
|
await execAsyncRemote(compose.serverId, command);
|
||||||
} else {
|
} else {
|
||||||
await execAsync(command);
|
await execAsync(command);
|
||||||
}
|
}
|
||||||
|
await execAsync(command, {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const command = `
|
const command = `
|
||||||
docker network disconnect ${compose.appName} dokploy-traefik;
|
docker network disconnect ${compose.appName} dokploy-traefik;
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ import {
|
|||||||
deployments,
|
deployments,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
||||||
import {
|
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||||
execAsync,
|
|
||||||
execAsyncRemote,
|
|
||||||
} from "@dokploy/server/utils/process/execAsync";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
@@ -557,25 +554,8 @@ export const removeDeployment = async (deploymentId: string) => {
|
|||||||
const deployment = await db
|
const deployment = await db
|
||||||
.delete(deployments)
|
.delete(deployments)
|
||||||
.where(eq(deployments.deploymentId, deploymentId))
|
.where(eq(deployments.deploymentId, deploymentId))
|
||||||
.returning()
|
.returning();
|
||||||
.then((result) => result[0]);
|
return deployment[0];
|
||||||
|
|
||||||
if (!deployment) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Deployment not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const command = `
|
|
||||||
rm -f ${deployment.logPath};
|
|
||||||
`;
|
|
||||||
if (deployment.serverId) {
|
|
||||||
await execAsyncRemote(deployment.serverId, command);
|
|
||||||
} else {
|
|
||||||
await execAsync(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deployment;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Error creating the deployment";
|
error instanceof Error ? error.message : "Error creating the deployment";
|
||||||
@@ -851,19 +831,3 @@ export const findAllDeploymentsByServerId = async (serverId: string) => {
|
|||||||
});
|
});
|
||||||
return deploymentsList;
|
return deploymentsList;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearOldDeployments = async (
|
|
||||||
appName: string,
|
|
||||||
serverId: string | null,
|
|
||||||
) => {
|
|
||||||
const { LOGS_PATH } = paths(!!serverId);
|
|
||||||
const folder = path.join(LOGS_PATH, appName);
|
|
||||||
const command = `
|
|
||||||
rm -rf ${folder};
|
|
||||||
`;
|
|
||||||
if (serverId) {
|
|
||||||
await execAsyncRemote(serverId, command);
|
|
||||||
} else {
|
|
||||||
await execAsync(command);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -101,20 +101,6 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
|
|||||||
return projectEnvironments;
|
return projectEnvironments;
|
||||||
};
|
};
|
||||||
|
|
||||||
const environmentHasServices = (
|
|
||||||
env: Awaited<ReturnType<typeof findEnvironmentById>>,
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
(env.applications?.length ?? 0) > 0 ||
|
|
||||||
(env.compose?.length ?? 0) > 0 ||
|
|
||||||
(env.mariadb?.length ?? 0) > 0 ||
|
|
||||||
(env.mongo?.length ?? 0) > 0 ||
|
|
||||||
(env.mysql?.length ?? 0) > 0 ||
|
|
||||||
(env.postgres?.length ?? 0) > 0 ||
|
|
||||||
(env.redis?.length ?? 0) > 0
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteEnvironment = async (environmentId: string) => {
|
export const deleteEnvironment = async (environmentId: string) => {
|
||||||
const currentEnvironment = await findEnvironmentById(environmentId);
|
const currentEnvironment = await findEnvironmentById(environmentId);
|
||||||
if (currentEnvironment.isDefault) {
|
if (currentEnvironment.isDefault) {
|
||||||
@@ -123,13 +109,6 @@ export const deleteEnvironment = async (environmentId: string) => {
|
|||||||
message: "You cannot delete the default environment",
|
message: "You cannot delete the default environment",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (environmentHasServices(currentEnvironment)) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message:
|
|
||||||
"Cannot delete environment: it has active services. Delete all services first.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const deletedEnvironment = await db
|
const deletedEnvironment = await db
|
||||||
.delete(environments)
|
.delete(environments)
|
||||||
.where(eq(environments.environmentId, environmentId))
|
.where(eq(environments.environmentId, environmentId))
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
type apiCreatePushover,
|
type apiCreatePushover,
|
||||||
type apiCreateResend,
|
type apiCreateResend,
|
||||||
type apiCreateSlack,
|
type apiCreateSlack,
|
||||||
type apiCreateTeams,
|
|
||||||
type apiCreateTelegram,
|
type apiCreateTelegram,
|
||||||
type apiUpdateCustom,
|
type apiUpdateCustom,
|
||||||
type apiUpdateDiscord,
|
type apiUpdateDiscord,
|
||||||
@@ -20,7 +19,6 @@ import {
|
|||||||
type apiUpdatePushover,
|
type apiUpdatePushover,
|
||||||
type apiUpdateResend,
|
type apiUpdateResend,
|
||||||
type apiUpdateSlack,
|
type apiUpdateSlack,
|
||||||
type apiUpdateTeams,
|
|
||||||
type apiUpdateTelegram,
|
type apiUpdateTelegram,
|
||||||
custom,
|
custom,
|
||||||
discord,
|
discord,
|
||||||
@@ -32,7 +30,6 @@ import {
|
|||||||
pushover,
|
pushover,
|
||||||
resend,
|
resend,
|
||||||
slack,
|
slack,
|
||||||
teams,
|
|
||||||
telegram,
|
telegram,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -799,7 +796,6 @@ export const findNotificationById = async (notificationId: string) => {
|
|||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
pushover: true,
|
pushover: true,
|
||||||
teams: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!notification) {
|
if (!notification) {
|
||||||
@@ -909,96 +905,6 @@ export const updateLarkNotification = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTeamsNotification = async (
|
|
||||||
input: typeof apiCreateTeams._type,
|
|
||||||
organizationId: string,
|
|
||||||
) => {
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
const newTeams = await tx
|
|
||||||
.insert(teams)
|
|
||||||
.values({
|
|
||||||
webhookUrl: input.webhookUrl,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
.then((value) => value[0]);
|
|
||||||
|
|
||||||
if (!newTeams) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Error input: Inserting teams",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const newDestination = await tx
|
|
||||||
.insert(notifications)
|
|
||||||
.values({
|
|
||||||
teamsId: newTeams.teamsId,
|
|
||||||
name: input.name,
|
|
||||||
appDeploy: input.appDeploy,
|
|
||||||
appBuildError: input.appBuildError,
|
|
||||||
databaseBackup: input.databaseBackup,
|
|
||||||
volumeBackup: input.volumeBackup,
|
|
||||||
dokployRestart: input.dokployRestart,
|
|
||||||
dockerCleanup: input.dockerCleanup,
|
|
||||||
notificationType: "teams",
|
|
||||||
organizationId: organizationId,
|
|
||||||
serverThreshold: input.serverThreshold,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
.then((value) => value[0]);
|
|
||||||
|
|
||||||
if (!newDestination) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Error input: Inserting notification",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return newDestination;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateTeamsNotification = async (
|
|
||||||
input: typeof apiUpdateTeams._type,
|
|
||||||
) => {
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
const newDestination = await tx
|
|
||||||
.update(notifications)
|
|
||||||
.set({
|
|
||||||
name: input.name,
|
|
||||||
appDeploy: input.appDeploy,
|
|
||||||
appBuildError: input.appBuildError,
|
|
||||||
databaseBackup: input.databaseBackup,
|
|
||||||
volumeBackup: input.volumeBackup,
|
|
||||||
dokployRestart: input.dokployRestart,
|
|
||||||
dockerCleanup: input.dockerCleanup,
|
|
||||||
organizationId: input.organizationId,
|
|
||||||
serverThreshold: input.serverThreshold,
|
|
||||||
})
|
|
||||||
.where(eq(notifications.notificationId, input.notificationId))
|
|
||||||
.returning()
|
|
||||||
.then((value) => value[0]);
|
|
||||||
|
|
||||||
if (!newDestination) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Error Updating notification",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.update(teams)
|
|
||||||
.set({
|
|
||||||
webhookUrl: input.webhookUrl,
|
|
||||||
})
|
|
||||||
.where(eq(teams.teamsId, input.teamsId))
|
|
||||||
.returning()
|
|
||||||
.then((value) => value[0]);
|
|
||||||
|
|
||||||
return newDestination;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateNotificationById = async (
|
export const updateNotificationById = async (
|
||||||
notificationId: string,
|
notificationId: string,
|
||||||
notificationData: Partial<Notification>,
|
notificationData: Partial<Notification>,
|
||||||
|
|||||||
308
packages/server/src/services/patch-repo.ts
Normal file
308
packages/server/src/services/patch-repo.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import path, { join } from "node:path";
|
||||||
|
import { paths } from "@dokploy/server/constants";
|
||||||
|
import { findSSHKeyById } from "@dokploy/server/services/ssh-key";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||||
|
|
||||||
|
interface PatchRepoConfig {
|
||||||
|
appName: string;
|
||||||
|
type: "application" | "compose";
|
||||||
|
gitUrl: string;
|
||||||
|
gitBranch: string;
|
||||||
|
sshKeyId?: string | null;
|
||||||
|
serverId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure patch repo exists and is up-to-date
|
||||||
|
* Returns path to the repo
|
||||||
|
*/
|
||||||
|
export const ensurePatchRepo = async ({
|
||||||
|
appName,
|
||||||
|
type,
|
||||||
|
gitUrl,
|
||||||
|
gitBranch,
|
||||||
|
sshKeyId,
|
||||||
|
serverId,
|
||||||
|
}: PatchRepoConfig): Promise<string> => {
|
||||||
|
const { PATCH_REPOS_PATH, SSH_PATH } = paths(!!serverId);
|
||||||
|
const repoPath = join(PATCH_REPOS_PATH, type, appName);
|
||||||
|
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
|
||||||
|
|
||||||
|
// Check if repo exists
|
||||||
|
const checkCommand = `test -d "${repoPath}/.git" && echo "exists" || echo "not_exists"`;
|
||||||
|
|
||||||
|
let exists = false;
|
||||||
|
if (serverId) {
|
||||||
|
const result = await execAsyncRemote(serverId, checkCommand);
|
||||||
|
exists = result.stdout.trim() === "exists";
|
||||||
|
} else {
|
||||||
|
const result = await execAsync(checkCommand);
|
||||||
|
exists = result.stdout.trim() === "exists";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup SSH if needed
|
||||||
|
let sshSetup = "";
|
||||||
|
if (sshKeyId) {
|
||||||
|
const sshKey = await findSSHKeyById(sshKeyId);
|
||||||
|
const temporalKeyPath = "/tmp/patch_repo_id_rsa";
|
||||||
|
sshSetup = `
|
||||||
|
echo "${sshKey.privateKey}" > ${temporalKeyPath};
|
||||||
|
chmod 600 ${temporalKeyPath};
|
||||||
|
export GIT_SSH_COMMAND="ssh -i ${temporalKeyPath} -o UserKnownHostsFile=${knownHostsPath} -o StrictHostKeyChecking=accept-new";
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
// Clone the repo
|
||||||
|
const cloneCommand = `
|
||||||
|
set -e;
|
||||||
|
${sshSetup}
|
||||||
|
mkdir -p "${repoPath}";
|
||||||
|
git clone --branch ${gitBranch} --progress "${gitUrl}" "${repoPath}";
|
||||||
|
echo "Repository cloned successfully";
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, cloneCommand);
|
||||||
|
} else {
|
||||||
|
await execAsync(cloneCommand);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: `Failed to clone repository: ${error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Repo exists - check if on correct branch and update
|
||||||
|
const updateCommand = `
|
||||||
|
set -e;
|
||||||
|
cd "${repoPath}";
|
||||||
|
${sshSetup}
|
||||||
|
|
||||||
|
# Fetch all updates including tags
|
||||||
|
git fetch origin --tags --force
|
||||||
|
|
||||||
|
# Checkout the target (branch or tag) - this handles switching branches/tags
|
||||||
|
git checkout --force "${gitBranch}"
|
||||||
|
|
||||||
|
# If it's a branch that corresponds to a remote branch, hard reset to match remote
|
||||||
|
# This ensures we pull the latest changes for that branch.
|
||||||
|
# If it's a tag, we are already at the correct commit after checkout.
|
||||||
|
if git rev-parse --verify "origin/${gitBranch}" >/dev/null 2>&1; then
|
||||||
|
git reset --hard "origin/${gitBranch}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Updated repository to ${gitBranch}"
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, updateCommand);
|
||||||
|
} else {
|
||||||
|
await execAsync(updateCommand);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: `Failed to update repository: ${error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return repoPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DirectoryEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
children?: DirectoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read directory tree of the patch repo
|
||||||
|
*/
|
||||||
|
export const readPatchRepoDirectory = async (
|
||||||
|
repoPath: string,
|
||||||
|
serverId?: string | null,
|
||||||
|
): Promise<DirectoryEntry[]> => {
|
||||||
|
// Use git ls-tree to get tracked files only
|
||||||
|
const command = `cd "${repoPath}" && git ls-tree -r --name-only HEAD`;
|
||||||
|
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
if (serverId) {
|
||||||
|
const result = await execAsyncRemote(serverId, command);
|
||||||
|
stdout = result.stdout;
|
||||||
|
} else {
|
||||||
|
const result = await execAsync(command);
|
||||||
|
stdout = result.stdout;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: `Failed to read repository: ${error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = stdout.trim().split("\n").filter(Boolean);
|
||||||
|
|
||||||
|
// Build tree structure
|
||||||
|
const root: DirectoryEntry[] = [];
|
||||||
|
const dirMap = new Map<string, DirectoryEntry>();
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
const parts = filePath.split("/");
|
||||||
|
let currentPath = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
if (!part) continue;
|
||||||
|
|
||||||
|
const isFile = i === parts.length - 1;
|
||||||
|
const parentPath = currentPath;
|
||||||
|
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||||
|
|
||||||
|
if (!dirMap.has(currentPath)) {
|
||||||
|
const entry: DirectoryEntry = {
|
||||||
|
name: part,
|
||||||
|
path: currentPath,
|
||||||
|
type: isFile ? "file" : "directory",
|
||||||
|
children: isFile ? undefined : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
dirMap.set(currentPath, entry);
|
||||||
|
|
||||||
|
if (parentPath) {
|
||||||
|
const parent = dirMap.get(parentPath);
|
||||||
|
parent?.children?.push(entry);
|
||||||
|
} else {
|
||||||
|
root.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ReadFileResult {
|
||||||
|
content: string;
|
||||||
|
patchError?: boolean;
|
||||||
|
patchErrorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read file content from patch repo, optionally with patch applied
|
||||||
|
*/
|
||||||
|
export const readPatchRepoFile = async (
|
||||||
|
repoPath: string,
|
||||||
|
filePath: string,
|
||||||
|
patchContent?: string,
|
||||||
|
serverId?: string | null,
|
||||||
|
): Promise<ReadFileResult> => {
|
||||||
|
const fullPath = join(repoPath, filePath);
|
||||||
|
|
||||||
|
// Read original file
|
||||||
|
const command = `cat "${fullPath}" 2>/dev/null || echo "__FILE_NOT_FOUND__"`;
|
||||||
|
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
if (serverId) {
|
||||||
|
const result = await execAsyncRemote(serverId, command);
|
||||||
|
content = result.stdout;
|
||||||
|
} else {
|
||||||
|
const result = await execAsync(command);
|
||||||
|
content = result.stdout;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `File not found: ${filePath}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.trim() === "__FILE_NOT_FOUND__") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `File not found: ${filePath}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no patch, return original content
|
||||||
|
if (!patchContent) {
|
||||||
|
return { content };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to apply patch
|
||||||
|
const tempDir = `/tmp/patch_apply_${Date.now()}`;
|
||||||
|
const encodedContent = Buffer.from(content).toString("base64");
|
||||||
|
const encodedPatch = Buffer.from(patchContent).toString("base64");
|
||||||
|
|
||||||
|
// We need to recreate the file structure for git apply to work
|
||||||
|
// git diff usually uses paths relative to repo root
|
||||||
|
const applyCommand = `
|
||||||
|
set -e;
|
||||||
|
mkdir -p "${tempDir}";
|
||||||
|
cd "${tempDir}";
|
||||||
|
git init -q;
|
||||||
|
# Create file with correct path
|
||||||
|
mkdir -p "$(dirname "${filePath}")";
|
||||||
|
echo "${encodedContent}" | base64 -d > "${filePath}";
|
||||||
|
# Save patch
|
||||||
|
echo "${encodedPatch}" | base64 -d > "patch.diff";
|
||||||
|
# Apply patch
|
||||||
|
git apply --ignore-space-change --ignore-whitespace patch.diff;
|
||||||
|
# Read result
|
||||||
|
cat "${filePath}";
|
||||||
|
rm -rf "${tempDir}";
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let patchedContent: string;
|
||||||
|
if (serverId) {
|
||||||
|
const result = await execAsyncRemote(serverId, applyCommand);
|
||||||
|
patchedContent = result.stdout;
|
||||||
|
} else {
|
||||||
|
const result = await execAsync(applyCommand);
|
||||||
|
patchedContent = result.stdout;
|
||||||
|
}
|
||||||
|
return { content: patchedContent };
|
||||||
|
} catch (error) {
|
||||||
|
// Patch failed - return original content with error
|
||||||
|
const cleanupCommand = `rm -rf "${tempDir}" 2>/dev/null || true`;
|
||||||
|
try {
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, cleanupCommand);
|
||||||
|
} else {
|
||||||
|
await execAsync(cleanupCommand);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
patchError: true,
|
||||||
|
patchErrorMessage: `Failed to apply patch: ${error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean all patch repos
|
||||||
|
*/
|
||||||
|
export const cleanPatchRepos = async (serverId?: string | null): Promise<void> => {
|
||||||
|
const { PATCH_REPOS_PATH } = paths(!!serverId);
|
||||||
|
|
||||||
|
const command = `rm -rf "${PATCH_REPOS_PATH}"/* 2>/dev/null || true`;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
};
|
||||||
295
packages/server/src/services/patch.ts
Normal file
295
packages/server/src/services/patch.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import { paths } from "@dokploy/server/constants";
|
||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import {
|
||||||
|
type apiCreatePatch,
|
||||||
|
patch,
|
||||||
|
} from "@dokploy/server/db/schema";
|
||||||
|
import {
|
||||||
|
execAsync,
|
||||||
|
execAsyncRemote,
|
||||||
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { and, eq, isNotNull } from "drizzle-orm";
|
||||||
|
|
||||||
|
export type Patch = typeof patch.$inferSelect;
|
||||||
|
|
||||||
|
// CRUD Operations
|
||||||
|
|
||||||
|
export const createPatch = async (input: typeof apiCreatePatch._type) => {
|
||||||
|
if (!input.applicationId && !input.composeId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Either applicationId or composeId must be provided",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPatch = await db
|
||||||
|
.insert(patch)
|
||||||
|
.values({
|
||||||
|
...input,
|
||||||
|
content: input.content.endsWith("\n")
|
||||||
|
? input.content
|
||||||
|
: `${input.content}\n`,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.then((value) => value[0]);
|
||||||
|
|
||||||
|
if (!newPatch) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error creating the patch",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findPatchById = async (patchId: string) => {
|
||||||
|
const result = await db.query.patch.findFirst({
|
||||||
|
where: eq(patch.patchId, patchId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Patch not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findPatchesByApplicationId = async (applicationId: string) => {
|
||||||
|
return await db.query.patch.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(patch.applicationId, applicationId),
|
||||||
|
isNotNull(patch.applicationId),
|
||||||
|
),
|
||||||
|
orderBy: (patch, { asc }) => [asc(patch.filePath)],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findPatchesByComposeId = async (composeId: string) => {
|
||||||
|
return await db.query.patch.findMany({
|
||||||
|
where: and(eq(patch.composeId, composeId), isNotNull(patch.composeId)),
|
||||||
|
orderBy: (patch, { asc }) => [asc(patch.filePath)],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findPatchByFilePath = async (
|
||||||
|
filePath: string,
|
||||||
|
applicationId?: string,
|
||||||
|
composeId?: string,
|
||||||
|
) => {
|
||||||
|
if (applicationId) {
|
||||||
|
return await db.query.patch.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(patch.filePath, filePath),
|
||||||
|
eq(patch.applicationId, applicationId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (composeId) {
|
||||||
|
return await db.query.patch.findFirst({
|
||||||
|
where: and(eq(patch.filePath, filePath), eq(patch.composeId, composeId)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePatch = async (
|
||||||
|
patchId: string,
|
||||||
|
data: Partial<Patch>,
|
||||||
|
) => {
|
||||||
|
const result = await db
|
||||||
|
.update(patch)
|
||||||
|
.set({
|
||||||
|
...data,
|
||||||
|
...(data.content && {
|
||||||
|
content: data.content.endsWith("\n")
|
||||||
|
? data.content
|
||||||
|
: `${data.content}\n`,
|
||||||
|
}),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(patch.patchId, patchId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deletePatch = async (patchId: string) => {
|
||||||
|
const result = await db
|
||||||
|
.delete(patch)
|
||||||
|
.where(eq(patch.patchId, patchId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Patch Application Functions
|
||||||
|
|
||||||
|
interface ApplyPatchesOptions {
|
||||||
|
appName: string;
|
||||||
|
type: "application" | "compose";
|
||||||
|
serverId: string | null;
|
||||||
|
patches: Patch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate shell commands to apply patches to cloned repository
|
||||||
|
* Uses git apply to apply unified diff patches
|
||||||
|
*/
|
||||||
|
export const generateApplyPatchesCommand = ({
|
||||||
|
appName,
|
||||||
|
type,
|
||||||
|
patches,
|
||||||
|
serverId,
|
||||||
|
}: ApplyPatchesOptions): string => {
|
||||||
|
if (patches.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
|
||||||
|
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
|
||||||
|
const codePath = join(basePath, appName, "code");
|
||||||
|
|
||||||
|
let command = `echo "Applying ${patches.length} patch(es)...";`;
|
||||||
|
|
||||||
|
for (const p of patches) {
|
||||||
|
// Create a temporary patch file and apply it
|
||||||
|
const patchFileName = `/tmp/patch_${p.patchId}.patch`;
|
||||||
|
// Escape content for shell - use base64 encoding
|
||||||
|
const encodedContent = Buffer.from(p.content).toString("base64");
|
||||||
|
|
||||||
|
command += `
|
||||||
|
echo "${encodedContent}" | base64 -d > ${patchFileName};
|
||||||
|
cd ${codePath} && git apply --whitespace=fix ${patchFileName} && echo "✅ Applied patch for: ${p.filePath}" || echo "⚠️ Warning: Failed to apply patch for: ${p.filePath}";
|
||||||
|
rm -f ${patchFileName};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return command;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply patches during build process
|
||||||
|
*/
|
||||||
|
export const applyPatches = async ({
|
||||||
|
appName,
|
||||||
|
type,
|
||||||
|
serverId,
|
||||||
|
patches,
|
||||||
|
}: ApplyPatchesOptions): Promise<void> => {
|
||||||
|
const enabledPatches = patches.filter((p) => p.enabled);
|
||||||
|
|
||||||
|
if (enabledPatches.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = generateApplyPatchesCommand({
|
||||||
|
appName,
|
||||||
|
type,
|
||||||
|
serverId,
|
||||||
|
patches: enabledPatches,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GeneratePatchOptions {
|
||||||
|
codePath: string;
|
||||||
|
filePath: string;
|
||||||
|
newContent: string;
|
||||||
|
serverId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a patch from modified file content using git diff
|
||||||
|
*/
|
||||||
|
export const generatePatch = async ({
|
||||||
|
codePath,
|
||||||
|
filePath,
|
||||||
|
newContent,
|
||||||
|
serverId,
|
||||||
|
}: GeneratePatchOptions): Promise<string> => {
|
||||||
|
const fullPath = join(codePath, filePath);
|
||||||
|
|
||||||
|
// Write new content to the file
|
||||||
|
const encodedContent = Buffer.from(newContent).toString("base64");
|
||||||
|
const writeCommand = `echo "${encodedContent}" | base64 -d > "${fullPath}"`;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, writeCommand);
|
||||||
|
} else {
|
||||||
|
await execAsync(writeCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate diff
|
||||||
|
const diffCommand = `cd "${codePath}" && git diff -- "${filePath}"`;
|
||||||
|
|
||||||
|
let diffResult: string;
|
||||||
|
if (serverId) {
|
||||||
|
const result = await execAsyncRemote(serverId, diffCommand);
|
||||||
|
diffResult = result.stdout;
|
||||||
|
} else {
|
||||||
|
const result = await execAsync(diffCommand);
|
||||||
|
diffResult = result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the file to original state
|
||||||
|
const resetCommand = `cd "${codePath}" && git checkout -- "${filePath}"`;
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, resetCommand);
|
||||||
|
} else {
|
||||||
|
await execAsync(resetCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ApplyPatchToContentOptions {
|
||||||
|
originalContent: string;
|
||||||
|
patchContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a patch to content in memory (for preview purposes)
|
||||||
|
* Returns the patched content or throws an error if patch fails
|
||||||
|
*/
|
||||||
|
export const applyPatchToContent = async ({
|
||||||
|
originalContent,
|
||||||
|
patchContent,
|
||||||
|
}: ApplyPatchToContentOptions): Promise<string> => {
|
||||||
|
// Create temp files and apply patch
|
||||||
|
const tempDir = "/tmp/patch_preview_" + Date.now();
|
||||||
|
const tempFile = `${tempDir}/file`;
|
||||||
|
const patchFile = `${tempDir}/patch.diff`;
|
||||||
|
|
||||||
|
const encodedOriginal = Buffer.from(originalContent).toString("base64");
|
||||||
|
const encodedPatch = Buffer.from(patchContent).toString("base64");
|
||||||
|
|
||||||
|
const command = `
|
||||||
|
mkdir -p "${tempDir}";
|
||||||
|
echo "${encodedOriginal}" | base64 -d > "${tempFile}";
|
||||||
|
echo "${encodedPatch}" | base64 -d > "${patchFile}";
|
||||||
|
cd "${tempDir}" && patch -p0 < "${patchFile}" 2>/dev/null;
|
||||||
|
cat "${tempFile}";
|
||||||
|
rm -rf "${tempDir}";
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await execAsync(command);
|
||||||
|
return result.stdout;
|
||||||
|
} catch {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Failed to apply patch to content",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user