mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-21 07:05:21 +02:00
Compare commits
1 Commits
patches-im
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b3d8b00ec |
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -245,13 +245,13 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -263,15 +263,11 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!bitbucketId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a Bitbucket account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo: GiteaRepository) =>
|
(repo: GiteaRepository) =>
|
||||||
repo.name === field.value.repo,
|
repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -277,15 +277,11 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!giteaId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a Gitea account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -251,15 +251,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!githubId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a GitHub account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -254,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -272,15 +272,11 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!gitlabId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a GitLab account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -247,13 +247,13 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -265,15 +265,11 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!bitbucketId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a Bitbucket account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -244,13 +244,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -261,15 +261,11 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!giteaId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a Gitea account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -234,13 +234,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -252,15 +252,11 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!githubId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a GitHub account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -256,13 +256,13 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -274,15 +274,11 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!gitlabId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a GitLab account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ export const ShowProjects = () => {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : null}
|
) : null}
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
<CardTitle className="flex items-center justify-between gap-2">
|
||||||
<span className="flex flex-col gap-1.5 ">
|
<span className="flex flex-col gap-1.5 ">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookIcon className="size-4 text-muted-foreground" />
|
<BookIcon className="size-4 text-muted-foreground" />
|
||||||
@@ -439,7 +439,7 @@ export const ShowProjects = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm font-medium text-muted-foreground break-normal">
|
<span className="text-sm font-medium text-muted-foreground break-all">
|
||||||
{project.description}
|
{project.description}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Link2, Loader2, Unlink } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
|
|
||||||
const LINKING_CALLBACK_URL = "/dashboard/settings/profile";
|
|
||||||
|
|
||||||
const TRUSTED_PROVIDERS = ["google", "github"] as const;
|
|
||||||
type SocialProvider = (typeof TRUSTED_PROVIDERS)[number];
|
|
||||||
|
|
||||||
type AccountItem = {
|
|
||||||
providerId: string;
|
|
||||||
accountId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function providerLabel(providerId: string): string {
|
|
||||||
return providerId.charAt(0).toUpperCase() + providerId.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LinkingAccount() {
|
|
||||||
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
|
||||||
const [accountsLoading, setAccountsLoading] = useState(true);
|
|
||||||
const [linkingProvider, setLinkingProvider] = useState<SocialProvider | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [unlinkingProviderId, setUnlinkingProviderId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchAccounts = useCallback(async () => {
|
|
||||||
setAccountsLoading(true);
|
|
||||||
try {
|
|
||||||
const { data } = await authClient.listAccounts();
|
|
||||||
const list = Array.isArray(data)
|
|
||||||
? data
|
|
||||||
: ((data && typeof data === "object" && "accounts" in data
|
|
||||||
? (data as { accounts?: AccountItem[] }).accounts
|
|
||||||
: null) ?? []);
|
|
||||||
setAccounts(Array.isArray(list) ? list : []);
|
|
||||||
} catch {
|
|
||||||
setAccounts([]);
|
|
||||||
} finally {
|
|
||||||
setAccountsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAccounts();
|
|
||||||
}, [fetchAccounts]);
|
|
||||||
|
|
||||||
const linkedProviderIds = new Set(accounts.map((a) => a.providerId));
|
|
||||||
const socialAccounts = accounts.filter((a) =>
|
|
||||||
TRUSTED_PROVIDERS.includes(a.providerId as SocialProvider),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleLinkSocial = async (provider: SocialProvider) => {
|
|
||||||
setLinkingProvider(provider);
|
|
||||||
try {
|
|
||||||
const { error } = await authClient.linkSocial({
|
|
||||||
provider,
|
|
||||||
callbackURL: LINKING_CALLBACK_URL,
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
toast.error(error.message ?? "Failed to link account");
|
|
||||||
setLinkingProvider(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(
|
|
||||||
"Failed to link account",
|
|
||||||
err instanceof Error ? { description: err.message } : undefined,
|
|
||||||
);
|
|
||||||
setLinkingProvider(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnlink = async (providerId: string, accountId?: string) => {
|
|
||||||
setUnlinkingProviderId(providerId);
|
|
||||||
try {
|
|
||||||
const { error } = await authClient.unlinkAccount({
|
|
||||||
providerId,
|
|
||||||
...(accountId && { accountId }),
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
toast.error(error.message ?? "Failed to unlink account");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success("Account unlinked");
|
|
||||||
await fetchAccounts();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(
|
|
||||||
"Failed to unlink account",
|
|
||||||
err instanceof Error ? { description: err.message } : undefined,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setUnlinkingProviderId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const canUnlink = accounts.length > 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto w-full">
|
|
||||||
<div className="rounded-xl bg-background shadow-md">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
|
||||||
<Link2 className="size-6 text-muted-foreground self-center" />
|
|
||||||
Linking account
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Link your Google or GitHub account to sign in with them.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6 py-8 border-t">
|
|
||||||
{/* Linked accounts */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium">Linked accounts</p>
|
|
||||||
{accountsLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : socialAccounts.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground py-2">
|
|
||||||
No social accounts linked yet.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{socialAccounts.map((acc) => (
|
|
||||||
<li
|
|
||||||
key={acc.accountId ?? acc.providerId}
|
|
||||||
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<span className="font-medium">
|
|
||||||
{providerLabel(acc.providerId)}
|
|
||||||
</span>
|
|
||||||
{canUnlink && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() =>
|
|
||||||
handleUnlink(acc.providerId, acc.accountId)
|
|
||||||
}
|
|
||||||
disabled={unlinkingProviderId === acc.providerId}
|
|
||||||
isLoading={unlinkingProviderId === acc.providerId}
|
|
||||||
>
|
|
||||||
{unlinkingProviderId === acc.providerId ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Unlink className="mr-1.5 size-4" />
|
|
||||||
Unlink
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Click a provider below to link it to your account. You will be
|
|
||||||
redirected to complete the flow.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{!linkedProviderIds.has("google") && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
className="min-w-[180px]"
|
|
||||||
onClick={() => handleLinkSocial("google")}
|
|
||||||
disabled={!!linkingProvider}
|
|
||||||
isLoading={linkingProvider === "google"}
|
|
||||||
>
|
|
||||||
{linkingProvider === "google" ? (
|
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<svg viewBox="0 0 24 24" className="mr-2 size-4">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
Link with Google
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!linkedProviderIds.has("github") && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
className="min-w-[180px]"
|
|
||||||
onClick={() => handleLinkSocial("github")}
|
|
||||||
disabled={!!linkingProvider}
|
|
||||||
isLoading={linkingProvider === "github"}
|
|
||||||
>
|
|
||||||
{linkingProvider === "github" ? (
|
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="mr-2 size-4"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
Link with GitHub
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { GetServerSidePropsContext } from "next";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
|
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
|
||||||
import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account";
|
|
||||||
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
|
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
@@ -13,16 +12,17 @@ import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
|||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const { data } = api.user.get.useQuery();
|
const { data } = api.user.get.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
|
|
||||||
|
// const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||||
<ProfileForm />
|
<ProfileForm />
|
||||||
{isCloud && <LinkingAccount />}
|
|
||||||
{(data?.canAccessToAPI ||
|
{(data?.canAccessToAPI ||
|
||||||
data?.role === "owner" ||
|
data?.role === "owner" ||
|
||||||
data?.role === "admin") && <ShowApiKeys />}
|
data?.role === "admin") && <ShowApiKeys />}
|
||||||
|
|
||||||
|
{/* {isCloud && <RemoveSelfAccount />} */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { mountRouter } from "./routers/mount";
|
|||||||
import { mysqlRouter } from "./routers/mysql";
|
import { mysqlRouter } from "./routers/mysql";
|
||||||
import { notificationRouter } from "./routers/notification";
|
import { notificationRouter } from "./routers/notification";
|
||||||
import { organizationRouter } from "./routers/organization";
|
import { organizationRouter } from "./routers/organization";
|
||||||
import { patchRouter } from "./routers/patch";
|
|
||||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
import { ssoRouter } from "./routers/proprietary/sso";
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { portRouter } from "./routers/port";
|
import { portRouter } from "./routers/port";
|
||||||
@@ -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;
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -7,12 +7,7 @@ import {
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { getStripeItems, WEBSITE_URL } from "@/server/utils/stripe";
|
||||||
getStripeItems,
|
|
||||||
PRODUCT_ANNUAL_ID,
|
|
||||||
PRODUCT_MONTHLY_ID,
|
|
||||||
WEBSITE_URL,
|
|
||||||
} from "@/server/utils/stripe";
|
|
||||||
import { adminProcedure, createTRPCRouter } from "../trpc";
|
import { adminProcedure, createTRPCRouter } from "../trpc";
|
||||||
|
|
||||||
export const stripeRouter = createTRPCRouter({
|
export const stripeRouter = createTRPCRouter({
|
||||||
@@ -27,7 +22,6 @@ 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],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!stripeCustomerId) {
|
if (!stripeCustomerId) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
deployApplication,
|
deployApplication,
|
||||||
deployCompose,
|
deployCompose,
|
||||||
deployPreviewApplication,
|
deployPreviewApplication,
|
||||||
IS_CLOUD,
|
|
||||||
rebuildApplication,
|
rebuildApplication,
|
||||||
rebuildCompose,
|
rebuildCompose,
|
||||||
rebuildPreviewApplication,
|
rebuildPreviewApplication,
|
||||||
@@ -14,83 +13,70 @@ import { type Job, Worker } from "bullmq";
|
|||||||
import type { DeploymentJob } from "./queue-types";
|
import type { DeploymentJob } from "./queue-types";
|
||||||
import { redisConfig } from "./redis-connection";
|
import { redisConfig } from "./redis-connection";
|
||||||
|
|
||||||
const createDeploymentWorker = () =>
|
export const deploymentWorker = new Worker(
|
||||||
new Worker(
|
"deployments",
|
||||||
"deployments",
|
async (job: Job<DeploymentJob>) => {
|
||||||
async (job: Job<DeploymentJob>) => {
|
try {
|
||||||
try {
|
if (job.data.applicationType === "application") {
|
||||||
if (job.data.applicationType === "application") {
|
await updateApplicationStatus(job.data.applicationId, "running");
|
||||||
await updateApplicationStatus(job.data.applicationId, "running");
|
|
||||||
|
|
||||||
if (job.data.type === "redeploy") {
|
if (job.data.type === "redeploy") {
|
||||||
await rebuildApplication({
|
await rebuildApplication({
|
||||||
applicationId: job.data.applicationId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
});
|
|
||||||
} else if (job.data.type === "deploy") {
|
|
||||||
await deployApplication({
|
|
||||||
applicationId: job.data.applicationId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (job.data.applicationType === "compose") {
|
|
||||||
await updateCompose(job.data.composeId, {
|
|
||||||
composeStatus: "running",
|
|
||||||
});
|
});
|
||||||
if (job.data.type === "deploy") {
|
} else if (job.data.type === "deploy") {
|
||||||
await deployCompose({
|
await deployApplication({
|
||||||
composeId: job.data.composeId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
});
|
});
|
||||||
} else if (job.data.type === "redeploy") {
|
}
|
||||||
await rebuildCompose({
|
} else if (job.data.applicationType === "compose") {
|
||||||
composeId: job.data.composeId,
|
await updateCompose(job.data.composeId, {
|
||||||
titleLog: job.data.titleLog,
|
composeStatus: "running",
|
||||||
descriptionLog: job.data.descriptionLog,
|
});
|
||||||
});
|
if (job.data.type === "deploy") {
|
||||||
}
|
await deployCompose({
|
||||||
} else if (job.data.applicationType === "application-preview") {
|
composeId: job.data.composeId,
|
||||||
await updatePreviewDeployment(job.data.previewDeploymentId, {
|
titleLog: job.data.titleLog,
|
||||||
previewStatus: "running",
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
});
|
||||||
|
} else if (job.data.type === "redeploy") {
|
||||||
|
await rebuildCompose({
|
||||||
|
composeId: job.data.composeId,
|
||||||
|
titleLog: job.data.titleLog,
|
||||||
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (job.data.applicationType === "application-preview") {
|
||||||
|
await updatePreviewDeployment(job.data.previewDeploymentId, {
|
||||||
|
previewStatus: "running",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (job.data.type === "redeploy") {
|
||||||
|
await rebuildPreviewApplication({
|
||||||
|
applicationId: job.data.applicationId,
|
||||||
|
titleLog: job.data.titleLog,
|
||||||
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
previewDeploymentId: job.data.previewDeploymentId,
|
||||||
|
});
|
||||||
|
} else if (job.data.type === "deploy") {
|
||||||
|
await deployPreviewApplication({
|
||||||
|
applicationId: job.data.applicationId,
|
||||||
|
titleLog: job.data.titleLog,
|
||||||
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
previewDeploymentId: job.data.previewDeploymentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (job.data.type === "redeploy") {
|
|
||||||
await rebuildPreviewApplication({
|
|
||||||
applicationId: job.data.applicationId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
previewDeploymentId: job.data.previewDeploymentId,
|
|
||||||
});
|
|
||||||
} else if (job.data.type === "deploy") {
|
|
||||||
await deployPreviewApplication({
|
|
||||||
applicationId: job.data.applicationId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
previewDeploymentId: job.data.previewDeploymentId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.log("Error", error);
|
|
||||||
}
|
}
|
||||||
},
|
} catch (error) {
|
||||||
{
|
console.log("Error", error);
|
||||||
autorun: false,
|
}
|
||||||
connection: redisConfig,
|
},
|
||||||
},
|
{
|
||||||
);
|
autorun: false,
|
||||||
|
connection: redisConfig,
|
||||||
/** No-op worker when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
|
},
|
||||||
const noopWorker = {
|
);
|
||||||
run: () => Promise.resolve(),
|
|
||||||
close: () => Promise.resolve(),
|
|
||||||
cancelJob: () => Promise.resolve(),
|
|
||||||
cancelAllJobs: () => Promise.resolve(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deploymentWorker = !IS_CLOUD
|
|
||||||
? createDeploymentWorker()
|
|
||||||
: (noopWorker as unknown as Worker<DeploymentJob>);
|
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import { IS_CLOUD } from "@dokploy/server";
|
|
||||||
import {
|
import {
|
||||||
execAsync,
|
execAsync,
|
||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
} from "@dokploy/server/utils/process/execAsync";
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
import type { Job } from "bullmq";
|
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { deploymentWorker } from "./deployments-queue";
|
import { deploymentWorker } from "./deployments-queue";
|
||||||
import { redisConfig } from "./redis-connection";
|
import { redisConfig } from "./redis-connection";
|
||||||
|
|
||||||
/** No-op queue when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
|
const myQueue = new Queue("deployments", {
|
||||||
const createNoopQueue = () => ({
|
connection: redisConfig,
|
||||||
getJobs: () => Promise.resolve([] as Job[]),
|
|
||||||
add: () =>
|
|
||||||
Promise.resolve({ id: "noop", remove: () => Promise.resolve() } as Job),
|
|
||||||
close: () => Promise.resolve(),
|
|
||||||
on: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const myQueue = !IS_CLOUD
|
|
||||||
? new Queue("deployments", { connection: redisConfig })
|
|
||||||
: (createNoopQueue() as unknown as Queue);
|
|
||||||
|
|
||||||
export const getJobsByApplicationId = async (applicationId: string) => {
|
export const getJobsByApplicationId = async (applicationId: string) => {
|
||||||
const jobs = await myQueue.getJobs();
|
const jobs = await myQueue.getJobs();
|
||||||
return jobs.filter((job) => job?.data?.applicationId === applicationId);
|
return jobs.filter((job) => job?.data?.applicationId === applicationId);
|
||||||
@@ -31,21 +20,19 @@ export const getJobsByComposeId = async (composeId: string) => {
|
|||||||
return jobs.filter((job) => job?.data?.composeId === composeId);
|
return jobs.filter((job) => job?.data?.composeId === composeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!IS_CLOUD) {
|
process.on("SIGTERM", () => {
|
||||||
process.on("SIGTERM", () => {
|
myQueue.close();
|
||||||
myQueue.close();
|
process.exit(0);
|
||||||
process.exit(0);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
myQueue.on("error", (error) => {
|
myQueue.on("error", (error) => {
|
||||||
if ((error as any).code === "ECONNREFUSED") {
|
if ((error as any).code === "ECONNREFUSED") {
|
||||||
console.error(
|
console.error(
|
||||||
"Make sure you have installed Redis and it is running.",
|
"Make sure you have installed Redis and it is running.",
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export const cleanQueuesByApplication = async (applicationId: string) => {
|
export const cleanQueuesByApplication = async (applicationId: string) => {
|
||||||
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
|
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ function isNetworkError(error: unknown): boolean {
|
|||||||
if (error.message === "fetch failed") return true;
|
if (error.message === "fetch failed") return true;
|
||||||
const cause = (error as Error & { cause?: { code?: string } }).cause;
|
const cause = (error as Error & { cause?: { code?: string } }).cause;
|
||||||
const code = cause?.code;
|
const code = cause?.code;
|
||||||
return (
|
return code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT";
|
||||||
code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ export const WEBSITE_URL =
|
|||||||
? "http://localhost:3000"
|
? "http://localhost:3000"
|
||||||
: process.env.SITE_URL;
|
: process.env.SITE_URL;
|
||||||
|
|
||||||
export const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!; // $4.00
|
const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!; // $4.00
|
||||||
|
|
||||||
export const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!; // $7.99
|
const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!; // $7.99
|
||||||
|
|
||||||
export const PRODUCT_MONTHLY_ID = process.env.PRODUCT_MONTHLY_ID!;
|
|
||||||
export const PRODUCT_ANNUAL_ID = process.env.PRODUCT_ANNUAL_ID!;
|
|
||||||
|
|
||||||
export const getStripeItems = (serverQuantity: number, isAnnual: boolean) => {
|
export const getStripeItems = (serverQuantity: number, isAnnual: boolean) => {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|||||||
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";
|
||||||
|
|||||||
@@ -43,17 +43,6 @@ const { handler, api } = betterAuth({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(IS_CLOUD
|
|
||||||
? {
|
|
||||||
account: {
|
|
||||||
accountLinking: {
|
|
||||||
enabled: true,
|
|
||||||
trustedProviders: ["github", "google"],
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -7,7 +7,7 @@ import { user as userSchema } from "../../db/schema/user";
|
|||||||
export const LICENSE_KEY_URL =
|
export const LICENSE_KEY_URL =
|
||||||
process.env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
? "http://localhost:4002"
|
? "http://localhost:4002"
|
||||||
: "https://licenses-api.dokploy.com";
|
: "https://licenses.dokploy.com";
|
||||||
|
|
||||||
export const initEnterpriseBackupCronJobs = async () => {
|
export const initEnterpriseBackupCronJobs = async () => {
|
||||||
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
|
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user