mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-27 10:05:32 +02:00
Compare commits
16 Commits
patches-im
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b2eedefd7 | ||
|
|
5c45cfcefe | ||
|
|
89416fef47 | ||
|
|
74d72f1494 | ||
|
|
a24dbe365a | ||
|
|
3b753ecfbf | ||
|
|
7184b7d4b2 | ||
|
|
5c36ca3986 | ||
|
|
3a3f3ab7d4 | ||
|
|
1779a8a950 | ||
|
|
a51a4b3e87 | ||
|
|
034d55d7cb | ||
|
|
eeb7f00d05 | ||
|
|
1326d14a00 | ||
|
|
59f843f8a0 | ||
|
|
fe807ae2a6 |
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
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";
|
||||||
@@ -9,17 +8,6 @@ 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 => {
|
||||||
@@ -79,16 +67,6 @@ 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
|
||||||
@@ -100,11 +78,6 @@ 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> = {},
|
||||||
@@ -501,105 +474,6 @@ 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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
|
|
||||||
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,3 +275,51 @@ 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("тест.рф");
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./show-patches";
|
|
||||||
export * from "./patch-editor";
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormDescription,
|
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormDescription,
|
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormDescription,
|
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormDescription,
|
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ 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
|
||||||
@@ -54,8 +51,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
cleanDockerBuilderIsLoading ||
|
cleanDockerBuilderIsLoading ||
|
||||||
cleanUnusedImagesIsLoading ||
|
cleanUnusedImagesIsLoading ||
|
||||||
cleanUnusedVolumesIsLoading ||
|
cleanUnusedVolumesIsLoading ||
|
||||||
cleanStoppedContainersIsLoading ||
|
cleanStoppedContainersIsLoading
|
||||||
cleanPatchReposIsLoading
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -64,8 +60,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
cleanDockerBuilderIsLoading ||
|
cleanDockerBuilderIsLoading ||
|
||||||
cleanUnusedImagesIsLoading ||
|
cleanUnusedImagesIsLoading ||
|
||||||
cleanUnusedVolumesIsLoading ||
|
cleanUnusedVolumesIsLoading ||
|
||||||
cleanStoppedContainersIsLoading ||
|
cleanStoppedContainersIsLoading
|
||||||
cleanPatchReposIsLoading
|
|
||||||
}
|
}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
@@ -134,23 +129,6 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</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
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
|
|||||||
@@ -1,7 +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 { useTranslation } from "next-i18next";
|
||||||
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";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
@@ -36,6 +35,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ 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} | {releaseTag}
|
{dokployVersion}{" "}
|
||||||
|
{(releaseTag === "canary" || releaseTag === "feature") &&
|
||||||
|
`(${releaseTag})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -638,127 +638,129 @@ function SidebarLogo() {
|
|||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
Organizations
|
Organizations
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{organizations?.map((org) => {
|
<div className="max-h-[60vh] overflow-y-auto">
|
||||||
const isDefault = org.members?.[0]?.isDefault ?? false;
|
{organizations?.map((org) => {
|
||||||
return (
|
const isDefault = org.members?.[0]?.isDefault ?? false;
|
||||||
<div
|
return (
|
||||||
className="flex flex-row justify-between"
|
<div
|
||||||
key={org.name}
|
className="flex flex-row justify-between"
|
||||||
>
|
key={org.name}
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={async () => {
|
|
||||||
await authClient.organization.setActive({
|
|
||||||
organizationId: org.id,
|
|
||||||
});
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
|
||||||
className="w-full gap-2 p-2"
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<DropdownMenuItem
|
||||||
<div className="flex items-center gap-2">
|
onClick={async () => {
|
||||||
{org.name}
|
await authClient.organization.setActive({
|
||||||
</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,
|
organizationId: org.id,
|
||||||
})
|
});
|
||||||
.then(() => {
|
window.location.reload();
|
||||||
refetch();
|
|
||||||
toast.success("Default organization updated");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(
|
|
||||||
error?.message ||
|
|
||||||
"Error setting default organization",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
title={
|
className="w-full gap-2 p-2"
|
||||||
isDefault
|
|
||||||
? "Default organization"
|
|
||||||
: "Set as default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isDefault ? (
|
<div className="flex flex-col gap-1">
|
||||||
<Star
|
<div className="flex items-center gap-2">
|
||||||
fill="#eab308"
|
{org.name}
|
||||||
stroke="#eab308"
|
</div>
|
||||||
className="size-4 text-yellow-500"
|
</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>
|
||||||
<Star
|
</DropdownMenuItem>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
<div className="flex items-center gap-2">
|
||||||
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
|
<Button
|
||||||
/>
|
variant="ghost"
|
||||||
)}
|
size="icon"
|
||||||
</Button>
|
className={cn(
|
||||||
{org.ownerId === session?.user?.id && (
|
"group",
|
||||||
<>
|
isDefault
|
||||||
<AddOrganization organizationId={org.id} />
|
? "hover:bg-yellow-500/10"
|
||||||
<DialogAction
|
: "hover:bg-blue-500/10",
|
||||||
title="Delete Organization"
|
)}
|
||||||
description="Are you sure you want to delete this organization?"
|
isLoading={isSettingDefault && !isDefault}
|
||||||
type="destructive"
|
disabled={isDefault}
|
||||||
onClick={async () => {
|
onClick={async (e) => {
|
||||||
await deleteOrganization({
|
if (isDefault) return;
|
||||||
organizationId: org.id,
|
e.stopPropagation();
|
||||||
|
await setDefaultOrganization({
|
||||||
|
organizationId: org.id,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success("Default organization updated");
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch((error) => {
|
||||||
refetch();
|
toast.error(
|
||||||
toast.success(
|
error?.message ||
|
||||||
"Organization deleted successfully",
|
"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,
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.then(() => {
|
||||||
toast.error(
|
refetch();
|
||||||
error?.message ||
|
toast.success(
|
||||||
"Error deleting organization",
|
"Organization deleted successfully",
|
||||||
);
|
);
|
||||||
});
|
})
|
||||||
}}
|
.catch((error) => {
|
||||||
>
|
toast.error(
|
||||||
<Button
|
error?.message ||
|
||||||
variant="ghost"
|
"Error deleting organization",
|
||||||
size="icon"
|
);
|
||||||
className="group hover:bg-red-500/10"
|
});
|
||||||
isLoading={isRemoving}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DialogAction>
|
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>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
{(user?.role === "owner" ||
|
{(user?.role === "owner" ||
|
||||||
user?.role === "admin" ||
|
user?.role === "admin" ||
|
||||||
isCloud) && (
|
isCloud) && (
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
export function SignInWithGithub() {
|
export function SignInWithGithub() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
export function SignInWithGoogle() {
|
export function SignInWithGoogle() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Eye, Loader2, LogIn, Trash2 } from "lucide-react";
|
import {
|
||||||
|
Eye,
|
||||||
|
Loader2,
|
||||||
|
LogIn,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, 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";
|
||||||
@@ -21,6 +29,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 { RegisterOidcDialog } from "./register-oidc-dialog";
|
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||||
@@ -68,6 +77,10 @@ export const SSOSettings = () => {
|
|||||||
const [detailsProvider, setDetailsProvider] =
|
const [detailsProvider, setDetailsProvider] =
|
||||||
useState<ProviderForDetails | null>(null);
|
useState<ProviderForDetails | null>(null);
|
||||||
const [baseURL, setBaseURL] = useState("");
|
const [baseURL, setBaseURL] = useState("");
|
||||||
|
const [manageOriginsOpen, setManageOriginsOpen] = useState(false);
|
||||||
|
const [editingOrigin, setEditingOrigin] = useState<string | null>(null);
|
||||||
|
const [editingValue, setEditingValue] = useState("");
|
||||||
|
const [newOriginInput, setNewOriginInput] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -76,20 +89,101 @@ export const SSOSettings = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
|
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
|
||||||
|
const { data: userData } = api.user.get.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 trustedOrigins = userData?.user?.trustedOrigins ?? [];
|
||||||
|
|
||||||
|
const handleAddOrigin = async () => {
|
||||||
|
const value = newOriginInput.trim();
|
||||||
|
if (!value) return;
|
||||||
|
try {
|
||||||
|
await addTrustedOrigin({ origin: value });
|
||||||
|
toast.success("Trusted origin added");
|
||||||
|
setNewOriginInput("");
|
||||||
|
await utils.user.get.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.user.get.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.user.get.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">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<LogIn className="size-6 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
|
<LogIn className="size-6 text-muted-foreground" />
|
||||||
|
<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>
|
||||||
<CardDescription>
|
<Button
|
||||||
Configure OIDC or SAML identity providers for enterprise sign-in.
|
variant="outline"
|
||||||
Users can sign in with their organization's IdP.
|
size="sm"
|
||||||
</CardDescription>
|
onClick={() => setManageOriginsOpen(true)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Shield className="mr-2 size-4" />
|
||||||
|
Manage origins
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -366,6 +460,128 @@ 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,15 +0,0 @@
|
|||||||
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;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1002,13 +1002,6 @@
|
|||||||
"when": 1770615019498,
|
"when": 1770615019498,
|
||||||
"tag": "0142_outstanding_tusk",
|
"tag": "0142_outstanding_tusk",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 143,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1770756316554,
|
|
||||||
"tag": "0143_cute_forge",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.27.0",
|
"version": "v0.27.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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";
|
||||||
@@ -249,9 +248,6 @@ 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>
|
||||||
)}
|
)}
|
||||||
@@ -363,11 +359,6 @@ 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,7 +19,6 @@ 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";
|
||||||
@@ -238,9 +237,6 @@ 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>
|
||||||
)}
|
)}
|
||||||
@@ -365,12 +361,6 @@ 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} />
|
||||||
|
|||||||
@@ -22,13 +22,12 @@ 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 { ssoRouter } from "./routers/proprietary/sso";
|
|
||||||
import { portRouter } from "./routers/port";
|
import { portRouter } from "./routers/port";
|
||||||
import { postgresRouter } from "./routers/postgres";
|
import { postgresRouter } from "./routers/postgres";
|
||||||
import { previewDeploymentRouter } from "./routers/preview-deployment";
|
import { previewDeploymentRouter } from "./routers/preview-deployment";
|
||||||
import { projectRouter } from "./routers/project";
|
import { projectRouter } from "./routers/project";
|
||||||
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { redirectsRouter } from "./routers/redirects";
|
import { redirectsRouter } from "./routers/redirects";
|
||||||
import { redisRouter } from "./routers/redis";
|
import { redisRouter } from "./routers/redis";
|
||||||
import { registryRouter } from "./routers/registry";
|
import { registryRouter } from "./routers/registry";
|
||||||
@@ -91,7 +90,6 @@ 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,502 +0,0 @@
|
|||||||
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;
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -177,4 +177,65 @@ 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 normalized = normalizeTrustedOrigin(input.origin);
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, ctx.session.userId),
|
||||||
|
columns: { trustedOrigins: true },
|
||||||
|
});
|
||||||
|
const existing = currentUser?.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, ctx.session.userId));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
removeTrustedOrigin: enterpriseProcedure
|
||||||
|
.input(z.object({ origin: z.string().min(1) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const normalized = normalizeTrustedOrigin(input.origin);
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, ctx.session.userId),
|
||||||
|
columns: { trustedOrigins: true },
|
||||||
|
});
|
||||||
|
const existing = currentUser?.trustedOrigins || [];
|
||||||
|
const next = existing.filter(
|
||||||
|
(o) => o.toLowerCase() !== normalized.toLowerCase(),
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({ trustedOrigins: next })
|
||||||
|
.where(eq(user.id, ctx.session.userId));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
updateTrustedOrigin: enterpriseProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
oldOrigin: z.string().min(1),
|
||||||
|
newOrigin: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const oldNorm = normalizeTrustedOrigin(input.oldOrigin);
|
||||||
|
const newNorm = normalizeTrustedOrigin(input.newOrigin);
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, ctx.session.userId),
|
||||||
|
columns: { trustedOrigins: true },
|
||||||
|
});
|
||||||
|
const existing = currentUser?.trustedOrigins || [];
|
||||||
|
const next = existing.map((o) =>
|
||||||
|
o.toLowerCase() === oldNorm.toLowerCase() ? newNorm : o,
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({ trustedOrigins: next })
|
||||||
|
.where(eq(user.id, ctx.session.userId));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,12 +27,17 @@ 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: products.data,
|
products: filteredProducts,
|
||||||
subscriptions: [],
|
subscriptions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -44,7 +49,7 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
products: products.data,
|
products: filteredProducts,
|
||||||
subscriptions: subscriptions.data,
|
subscriptions: subscriptions.data,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { exit } from "node:process";
|
|
||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
|
import { exit } from "node:process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
import { setupDirectories } from "@dokploy/server/setup/config-paths";
|
import { setupDirectories } from "@dokploy/server/setup/config-paths";
|
||||||
import { initializePostgres } from "@dokploy/server/setup/postgres-setup";
|
import { initializePostgres } from "@dokploy/server/setup/postgres-setup";
|
||||||
import { initializeRedis } from "@dokploy/server/setup/redis-setup";
|
import { initializeRedis } from "@dokploy/server/setup/redis-setup";
|
||||||
|
|||||||
42829
openapi.json
42829
openapi.json
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,5 @@ 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,7 +19,6 @@ 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";
|
||||||
@@ -287,7 +286,6 @@ export const applicationsRelations = relations(
|
|||||||
references: [registry.registryId],
|
references: [registry.registryId],
|
||||||
relationName: "applicationRollbackRegistry",
|
relationName: "applicationRollbackRegistry",
|
||||||
}),
|
}),
|
||||||
patches: many(patch),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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";
|
||||||
@@ -144,7 +143,6 @@ 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, {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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";
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
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(),
|
|
||||||
});
|
|
||||||
@@ -27,8 +27,6 @@ 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";
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ 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 query = await db.query.ssoProvider.findMany();
|
||||||
|
|
||||||
|
const trustedProviders = query.map((provider) => provider.providerId);
|
||||||
|
|
||||||
const { handler, api } = betterAuth({
|
const { handler, api } = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
@@ -43,17 +47,14 @@ const { handler, api } = betterAuth({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(IS_CLOUD
|
|
||||||
? {
|
account: {
|
||||||
account: {
|
accountLinking: {
|
||||||
accountLinking: {
|
enabled: true,
|
||||||
enabled: true,
|
trustedProviders: ["github", "google", ...(trustedProviders || [])],
|
||||||
trustedProviders: ["github", "google"],
|
allowDifferentEmails: true,
|
||||||
allowDifferentEmails: true,
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
appName: "Dokploy",
|
appName: "Dokploy",
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
github: {
|
github: {
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ import {
|
|||||||
issueCommentExists,
|
issueCommentExists,
|
||||||
updateIssueComment,
|
updateIssueComment,
|
||||||
} from "./github";
|
} from "./github";
|
||||||
import {
|
|
||||||
findPatchesByApplicationId,
|
|
||||||
generateApplyPatchesCommand,
|
|
||||||
} from "./patch";
|
|
||||||
import {
|
import {
|
||||||
findPreviewDeploymentById,
|
findPreviewDeploymentById,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -206,20 +202,6 @@ 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,10 +40,6 @@ 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;
|
||||||
@@ -252,26 +248,6 @@ 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`;
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -164,10 +164,12 @@ export const addDomainToCompose = async (
|
|||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
const { serviceName, https } = domain;
|
const { serviceName, https } = domain;
|
||||||
if (!serviceName) {
|
if (!serviceName) {
|
||||||
throw new Error("Service name not found");
|
throw new Error(`Domain "${domain.host}" is missing a service name`);
|
||||||
}
|
}
|
||||||
if (!result?.services?.[serviceName]) {
|
if (!result?.services?.[serviceName]) {
|
||||||
throw new Error(`The service ${serviceName} not found in the compose`);
|
throw new Error(
|
||||||
|
`Domain "${domain.host}" is attached to service "${serviceName}" which does not exist in the compose`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const httpLabels = createDomainLabels(appName, domain, "web");
|
const httpLabels = createDomainLabels(appName, domain, "web");
|
||||||
|
|||||||
@@ -104,6 +104,20 @@ export const removeDomain = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an internationalized domain name (IDN) to ASCII punycode format.
|
||||||
|
* Traefik requires domain names in ASCII format, so non-ASCII characters
|
||||||
|
* must be converted (e.g., "тест.рф" → "xn--e1aybc.xn--p1ai").
|
||||||
|
*/
|
||||||
|
const toPunycode = (host: string): string => {
|
||||||
|
try {
|
||||||
|
return new URL(`http://${host}`).hostname;
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, return the original host
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const createRouterConfig = async (
|
export const createRouterConfig = async (
|
||||||
app: ApplicationNested,
|
app: ApplicationNested,
|
||||||
domain: Domain,
|
domain: Domain,
|
||||||
@@ -114,8 +128,9 @@ export const createRouterConfig = async (
|
|||||||
|
|
||||||
const { host, path, https, uniqueConfigKey, internalPath, stripPath } =
|
const { host, path, https, uniqueConfigKey, internalPath, stripPath } =
|
||||||
domain;
|
domain;
|
||||||
|
const punycodeHost = toPunycode(host);
|
||||||
const routerConfig: HttpRouter = {
|
const routerConfig: HttpRouter = {
|
||||||
rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
|
rule: `Host(\`${punycodeHost}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
|
||||||
service: `${appName}-service-${uniqueConfigKey}`,
|
service: `${appName}-service-${uniqueConfigKey}`,
|
||||||
middlewares: [],
|
middlewares: [],
|
||||||
entryPoints: [entryPoint],
|
entryPoints: [entryPoint],
|
||||||
|
|||||||
Reference in New Issue
Block a user