Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider

This commit is contained in:
ChristoferMendes
2025-12-01 10:42:01 -03:00
66 changed files with 44095 additions and 1687 deletions

View File

@@ -20,6 +20,32 @@ jobs:
with:
node-version: 20.16.0
cache: "pnpm"
- name: Install Nixpacks
if: matrix.job == 'test'
run: |
export NIXPACKS_VERSION=1.39.0
curl -sSL https://nixpacks.com/install.sh | bash
echo "Nixpacks installed $NIXPACKS_VERSION"
- name: Install Railpack
if: matrix.job == 'test'
run: |
export RAILPACK_VERSION=0.15.0
curl -sSL https://railpack.com/install.sh | bash
echo "Railpack installed $RAILPACK_VERSION"
- name: Add build tools to PATH
if: matrix.job == 'test'
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Initialize Docker Swarm
if: matrix.job == 'test'
run: |
docker swarm init
docker network create --driver overlay dokploy-network || true
echo "✅ Docker Swarm initialized"
- run: pnpm install --frozen-lockfile
- run: pnpm server:build
- run: pnpm ${{ matrix.job }}

70
.github/workflows/sync-openapi-docs.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: Generate and Sync OpenAPI
on:
push:
branches:
- canary
- main
paths:
- 'apps/dokploy/server/api/routers/**'
- 'packages/server/src/services/**'
- 'packages/server/src/db/schema/**'
workflow_dispatch:
jobs:
generate-and-commit:
name: Generate OpenAPI and commit to Dokploy repo
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate OpenAPI specification
run: |
pnpm generate:openapi
# Verifica que se generó correctamente
if [ ! -f openapi.json ]; then
echo "❌ openapi.json not found"
exit 1
fi
echo "✅ OpenAPI specification generated successfully"
- name: Sync to website repository
run: |
# Clona el repositorio de website
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
cd website-repo
# Copia el openapi.json al website (sobrescribe)
mkdir -p apps/docs/public
cp -f ../openapi.json apps/docs/public/openapi.json
# Configura git
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
# Agrega y commitea siempre
git add apps/docs/public/openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to website successfully"

2
.gitignore vendored
View File

@@ -13,6 +13,8 @@ node_modules
.env.test.local
.env.production.local
openapi.json
# Testing
coverage

View File

@@ -0,0 +1,276 @@
import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
import * as builders from "@dokploy/server/utils/builders";
import * as notifications from "@dokploy/server/utils/notifications/build-success";
import * as execProcess from "@dokploy/server/utils/process/execAsync";
import * as gitProvider from "@dokploy/server/utils/providers/git";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
const chain = {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}] as any),
} as any;
return chain;
};
return {
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
query: {
applications: {
findFirst: vi.fn(),
},
},
},
};
});
vi.mock("@dokploy/server/services/application", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/application")
>("@dokploy/server/services/application");
return {
...actual,
findApplicationById: vi.fn(),
updateApplicationStatus: vi.fn(),
};
});
vi.mock("@dokploy/server/services/admin", () => ({
getDokployUrl: vi.fn(),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeployment: vi.fn(),
updateDeploymentStatus: vi.fn(),
updateDeployment: vi.fn(),
}));
vi.mock("@dokploy/server/utils/providers/git", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/providers/git")
>("@dokploy/server/utils/providers/git");
return {
...actual,
getGitCommitInfo: vi.fn(),
};
});
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
ExecError: class ExecError extends Error {},
}));
vi.mock("@dokploy/server/utils/builders", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/builders")
>("@dokploy/server/utils/builders");
return {
...actual,
mechanizeDockerContainer: vi.fn(),
getBuildCommand: vi.fn(),
};
});
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
sendBuildSuccessNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
sendBuildErrorNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
import { db } from "@dokploy/server/db";
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
const createMockApplication = (overrides = {}) => ({
applicationId: "test-app-id",
name: "Test App",
appName: "test-app",
sourceType: "git" as const,
customGitUrl: "https://github.com/Dokploy/examples.git",
customGitBranch: "main",
customGitSSHKeyId: null,
buildType: "nixpacks" as const,
buildPath: "/astro",
env: "NODE_ENV=production",
serverId: null,
rollbackActive: false,
enableSubmodules: false,
environmentId: "env-id",
environment: {
projectId: "project-id",
env: "",
name: "production",
project: {
name: "Test Project",
organizationId: "org-id",
env: "",
},
},
domains: [],
...overrides,
});
const createMockDeployment = () => ({
deploymentId: "deployment-id",
logPath: "/tmp/test-deployment.log",
});
describe("deployApplication - Command Generation Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
"http://localhost:3000",
);
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
createMockDeployment() as any,
);
vi.mocked(execProcess.execAsync).mockResolvedValue({
stdout: "",
stderr: "",
} as any);
vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
undefined as any,
);
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
undefined as any,
);
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
{} as any,
);
vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
undefined as any,
);
vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({
message: "test commit",
hash: "abc123",
});
vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any);
});
it("should generate correct git clone command for astro example", async () => {
const app = createMockApplication();
const command = await cloneGitRepository(app);
console.log(command);
expect(command).toContain("https://github.com/Dokploy/examples.git");
expect(command).not.toContain("--recurse-submodules");
expect(command).toContain("--branch main");
expect(command).toContain("--depth 1");
expect(command).toContain("git clone");
});
it("should generate git clone with submodules when enabled", async () => {
const app = createMockApplication({ enableSubmodules: true });
const command = await cloneGitRepository(app);
expect(command).toContain("--recurse-submodules");
expect(command).toContain("https://github.com/Dokploy/examples.git");
});
it("should verify nixpacks command is called with correct app", async () => {
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test deployment",
descriptionLog: "",
});
expect(builders.getBuildCommand).toHaveBeenCalledWith(
expect.objectContaining({
buildType: "nixpacks",
customGitUrl: "https://github.com/Dokploy/examples.git",
buildPath: "/astro",
}),
);
expect(execProcess.execAsync).toHaveBeenCalledWith(
expect.stringContaining("nixpacks build"),
);
});
it("should verify railpack command includes correct parameters", async () => {
const mockApp = createMockApplication({ buildType: "railpack" });
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
mockApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
mockApp as any,
);
const mockRailpackCommand = "railpack prepare /path/to/app";
vi.mocked(builders.getBuildCommand).mockReturnValue(mockRailpackCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Railpack test",
descriptionLog: "",
});
expect(builders.getBuildCommand).toHaveBeenCalledWith(
expect.objectContaining({
buildType: "railpack",
}),
);
expect(execProcess.execAsync).toHaveBeenCalledWith(
expect.stringContaining("railpack prepare"),
);
});
it("should execute commands in correct order", async () => {
const mockNixpacksCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test",
descriptionLog: "",
});
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
expect(execCalls.length).toBeGreaterThan(0);
const fullCommand = execCalls[0]?.[0];
expect(fullCommand).toContain("set -e");
expect(fullCommand).toContain("git clone");
expect(fullCommand).toContain("nixpacks build");
});
it("should include log redirection in command", async () => {
const mockCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockReturnValue(mockCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test",
descriptionLog: "",
});
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
const fullCommand = execCalls[0]?.[0];
expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1");
});
});

View File

@@ -0,0 +1,479 @@
import { existsSync } from "node:fs";
import path from "node:path";
import type { ApplicationNested } from "@dokploy/server";
import { paths } from "@dokploy/server/constants";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { format } from "date-fns";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
// Mock ONLY database and notifications
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
const chain: any = {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}]),
};
return chain;
};
return {
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
query: {
applications: {
findFirst: vi.fn(),
},
},
},
};
});
vi.mock("@dokploy/server/services/application", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/application")
>("@dokploy/server/services/application");
return {
...actual,
findApplicationById: vi.fn(),
updateApplicationStatus: vi.fn(),
};
});
vi.mock("@dokploy/server/services/admin", () => ({
getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeployment: vi.fn(),
updateDeploymentStatus: vi.fn(),
updateDeployment: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
sendBuildSuccessNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
sendBuildErrorNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
// NOT mocked (executed for real):
// - execAsync
// - cloneGitRepository
// - getBuildCommand
// - mechanizeDockerContainer (requires Docker Swarm)
import { db } from "@dokploy/server/db";
import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
const createMockApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
applicationId: "test-app-id",
name: "Real Test App",
appName: `real-test-${Date.now()}`,
sourceType: "git" as const,
customGitUrl: "https://github.com/Dokploy/examples.git",
customGitBranch: "main",
customGitSSHKeyId: null,
customGitBuildPath: "/astro",
buildType: "nixpacks" as const,
env: "NODE_ENV=production",
serverId: null,
rollbackActive: false,
enableSubmodules: false,
environmentId: "env-id",
environment: {
projectId: "project-id",
env: "",
name: "production",
project: {
name: "Test Project",
organizationId: "org-id",
env: "",
},
},
domains: [],
mounts: [],
security: [],
redirects: [],
ports: [],
registry: null,
...overrides,
}) as ApplicationNested;
const createMockDeployment = async (appName: string) => {
const { LOGS_PATH } = paths(false); // false = local, no remote server
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, appName, fileName);
// Actually create the log directory
await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
return {
deploymentId: "deployment-id",
logPath: logFilePath,
};
};
async function cleanupDocker(appName: string) {
try {
await execAsync(`docker stop ${appName} 2>/dev/null || true`);
await execAsync(`docker rm ${appName} 2>/dev/null || true`);
await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
} catch (error) {
console.log("Docker cleanup completed");
}
}
async function cleanupFiles(appName: string) {
try {
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
// Clean cloned code directories
const appPath = path.join(APPLICATIONS_PATH, appName);
await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
// Clean logs for appName - removes entire folder
const logPath = path.join(LOGS_PATH, appName);
await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
console.log(`✅ Cleaned up files and logs for ${appName}`);
} catch (error) {
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
}
}
describe(
"deployApplication - REAL Execution Tests",
() => {
let currentAppName: string;
let currentDeployment: any;
const allTestAppNames: string[] = [];
beforeEach(async () => {
vi.clearAllMocks();
currentAppName = `real-test-${Date.now()}`;
currentDeployment = await createMockDeployment(currentAppName);
allTestAppNames.push(currentAppName);
const mockApp = createMockApplication({ appName: currentAppName });
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
mockApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
mockApp as any,
);
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
"http://localhost:3000",
);
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
currentDeployment as any,
);
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
undefined as any,
);
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
{} as any,
);
vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
{} as any,
);
});
afterEach(async () => {
// ALWAYS cleanup, even if test failed or passed
console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
// Clean current appName
try {
await cleanupDocker(currentAppName);
await cleanupFiles(currentAppName);
} catch (error) {
console.error("⚠️ Error cleaning current app:", error);
}
// Clean ALL test folders just in case
try {
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
await execAsync(
`rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
);
console.log("✅ Cleaned up all test artifacts");
} catch (error) {
console.error("⚠️ Error cleaning all artifacts:", error);
}
console.log("✅ Cleanup completed\n");
});
it(
"should REALLY clone git repo and build with nixpacks",
async () => {
console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Nixpacks Test",
descriptionLog: "Testing real execution",
});
expect(result).toBe(true);
// Verify that Docker image was actually created
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
console.log("dockerImages", dockerImages);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Docker image created: ${currentAppName}`);
// Verify log exists and has content
expect(existsSync(currentDeployment.logPath)).toBe(true);
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Cloning");
expect(logContent).toContain("nixpacks");
console.log(`✅ Build log created with ${logContent.length} chars`);
// Verify update functions were called
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
"deployment-id",
"done",
);
},
REAL_TEST_TIMEOUT,
);
it.skip(
"should REALLY build with railpack (SKIPPED: requires special permissions)",
async () => {
const railpackAppName = `real-railpack-${Date.now()}`;
const railpackApp = createMockApplication({
appName: railpackAppName,
buildType: "railpack",
railpackVersion: "3",
});
currentAppName = railpackAppName;
allTestAppNames.push(railpackAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
railpackApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
railpackApp as any,
);
console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Railpack Test",
descriptionLog: "",
});
expect(result).toBe(true);
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Railpack image created: ${currentAppName}`);
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("railpack");
console.log("✅ Railpack build completed");
},
REAL_TEST_TIMEOUT,
);
it(
"should handle REAL git clone errors",
async () => {
const errorAppName = `real-error-${Date.now()}`;
const errorApp = createMockApplication({
appName: errorAppName,
customGitUrl:
"https://github.com/invalid/nonexistent-repo-123456.git",
});
currentAppName = errorAppName;
allTestAppNames.push(errorAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
errorApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
errorApp as any,
);
console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
await expect(
deployApplication({
applicationId: "test-app-id",
titleLog: "Real Error Test",
descriptionLog: "",
}),
).rejects.toThrow();
// Verify error status was called
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
"deployment-id",
"error",
);
// Verify log contains error
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent.toLowerCase()).toContain("error");
console.log("✅ Error handling verified");
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY clone with submodules when enabled",
async () => {
const submodulesAppName = `real-submodules-${Date.now()}`;
const submodulesApp = createMockApplication({
appName: submodulesAppName,
enableSubmodules: true,
});
currentAppName = submodulesAppName;
allTestAppNames.push(submodulesAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
submodulesApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
submodulesApp as any,
);
console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Submodules Test",
descriptionLog: "",
});
expect(result).toBe(true);
// Verify deployment completed successfully
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Cloning");
expect(logContent.length).toBeGreaterThan(100);
console.log("✅ Submodules deployment completed");
// Verify image
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
expect(dockerImages.trim()).toBe(currentAppName);
},
REAL_TEST_TIMEOUT,
);
it(
"should verify REAL commit info extraction",
async () => {
console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Commit Test",
descriptionLog: "",
});
// Verify updateDeployment was called with commit info
expect(deploymentService.updateDeployment).toHaveBeenCalled();
const updateCall = vi.mocked(deploymentService.updateDeployment).mock
.calls[0];
// Real commit info should have title and hash
expect(updateCall?.[1]).toHaveProperty("title");
expect(updateCall?.[1]).toHaveProperty("description");
expect(updateCall?.[1]?.description).toContain("Commit:");
console.log(
`✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
);
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY build with Dockerfile",
async () => {
const dockerfileAppName = `real-dockerfile-${Date.now()}`;
const dockerfileApp = createMockApplication({
appName: dockerfileAppName,
buildType: "dockerfile",
customGitBuildPath: "/deno",
dockerfile: "Dockerfile",
});
currentAppName = dockerfileAppName;
allTestAppNames.push(dockerfileAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
dockerfileApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
dockerfileApp as any,
);
console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Dockerfile Test",
descriptionLog: "",
});
expect(result).toBe(true);
// Verify log
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Building");
expect(logContent).toContain(dockerfileAppName);
console.log("✅ Dockerfile build log verified");
// Verify image
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
console.log("dockerImages", dockerImages);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Docker image created: ${currentAppName}`);
},
REAL_TEST_TIMEOUT,
);
},
REAL_TEST_TIMEOUT,
);

View File

@@ -30,6 +30,10 @@ const baseApp: ApplicationNested = {
previewLabels: [],
herokuVersion: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
args: [],
giteaBuildPath: "",
previewRequireCollaboratorPermissions: false,
giteaId: "",

View File

@@ -3,7 +3,11 @@ import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
import { beforeEach, describe, expect, it, vi } from "vitest";
type MockCreateServiceOptions = {
StopGracePeriod?: number;
TaskTemplate?: {
ContainerSpec?: {
StopGracePeriod?: number;
};
};
[key: string]: unknown;
};
@@ -81,8 +85,10 @@ describe("mechanizeDockerContainer", () => {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.StopGracePeriod).toBe(0);
expect(typeof settings.StopGracePeriod).toBe("number");
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
"number",
);
});
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
@@ -96,6 +102,8 @@ describe("mechanizeDockerContainer", () => {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings).not.toHaveProperty("StopGracePeriod");
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
"StopGracePeriod",
);
});
});

View File

@@ -11,8 +11,12 @@ const baseApp: ApplicationNested = {
giteaRepository: "",
giteaOwner: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
giteaBuildPath: "",
giteaId: "",
args: [],
cleanCache: false,
applicationStatus: "done",
endpointSpecSwarm: null,

View File

@@ -13,7 +13,11 @@ export default defineConfig({
NODE: "test",
},
},
plugins: [tsconfigPaths()],
plugins: [
tsconfigPaths({
projects: [path.resolve(__dirname, "../tsconfig.json")],
}),
],
resolve: {
alias: {
"@dokploy/server": path.resolve(

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,6 +29,13 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
args: z
.array(
z.object({
value: z.string().min(1, "Argument cannot be empty"),
}),
)
.optional(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -47,22 +55,30 @@ export const AddCommand = ({ applicationId }: Props) => {
const form = useForm<AddCommand>({
defaultValues: {
command: "",
args: [],
},
resolver: zodResolver(AddRedirectSchema),
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "args",
});
useEffect(() => {
if (data?.command) {
if (data) {
form.reset({
command: data?.command || "",
args: data?.args?.map((arg) => ({ value: arg })) || [],
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
}, [data, form]);
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId,
command: data?.command,
args: data?.args?.map((arg) => arg.value).filter(Boolean),
})
.then(async () => {
toast.success("Command Updated");
@@ -100,13 +116,65 @@ export const AddCommand = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="Custom command" {...field} />
<Input placeholder="/bin/sh" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Arguments (Args)</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ value: "" })}
>
<Plus className="h-4 w-4 mr-1" />
Add Argument
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No arguments added yet. Click "Add Argument" to add one.
</p>
)}
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`args.${index}.value`}
render={({ field }) => (
<FormItem>
<div className="flex gap-2">
<FormControl>
<Input
placeholder={
index === 0 ? "-c" : "echo Hello World"
}
{...field}
/>
</FormControl>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</div>
<div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit">

View File

@@ -0,0 +1,248 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Server } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface Props {
applicationId: string;
}
const schema = z.object({
buildServerId: z.string().min(1, "Build server is required"),
buildRegistryId: z.string().min(1, "Build registry is required"),
});
type Schema = z.infer<typeof schema>;
export const ShowBuildServer = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{ applicationId },
{ enabled: !!applicationId },
);
const { data: buildServers } = api.server.buildServers.useQuery();
const { data: registries } = api.registry.all.useQuery();
const { mutateAsync, isLoading } = api.application.update.useMutation();
const form = useForm<Schema>({
defaultValues: {
buildServerId: data?.buildServerId || "",
buildRegistryId: data?.buildRegistryId || "",
},
resolver: zodResolver(schema),
});
useEffect(() => {
if (data) {
form.reset({
buildServerId: data?.buildServerId || "",
buildRegistryId: data?.buildRegistryId || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (formData: Schema) => {
await mutateAsync({
applicationId,
buildServerId:
formData?.buildServerId === "none" || !formData?.buildServerId
? null
: formData?.buildServerId,
buildRegistryId:
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
? null
: formData?.buildRegistryId,
})
.then(async () => {
toast.success("Build Server Settings Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating build server settings");
});
};
return (
<Card className="bg-background">
<CardHeader>
<div className="flex flex-row items-center gap-2">
<Server className="size-6 text-muted-foreground" />
<div>
<CardTitle className="text-xl">Build Server</CardTitle>
<CardDescription>
Configure a dedicated server for building your application.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Build servers offload the build process from your deployment servers.
Select a build server and registry to use for building your
application.
</AlertBlock>
<AlertBlock type="info">
📊 <strong>Important:</strong> Once the build finishes, you'll need to
wait a few seconds for the deployment server to download the image.
These download logs will <strong>NOT</strong> appear in the build
deployment logs. Check the <strong>Logs</strong> tab to see when the
container starts running.
</AlertBlock>
{!registries || registries.length === 0 ? (
<AlertBlock type="warning">
You need to add at least one registry to use build servers. Please
go to{" "}
<Link
href="/dashboard/settings/registry"
className="text-primary underline"
>
Settings
</Link>{" "}
to add a registry.
</AlertBlock>
) : null}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="buildServerId"
render={({ field }) => (
<FormItem>
<FormLabel>Build Server</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a build server" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem value="none">
<span className="flex items-center gap-2">
<span>None</span>
</span>
</SelectItem>
{buildServers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Build Servers ({buildServers?.length || 0})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
Select a build server to handle the build process for this
application.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildRegistryId"
render={({ field }) => (
<FormItem>
<FormLabel>Build Registry</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a registry" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem value="none">
<span className="flex items-center gap-2">
<span>None</span>
</span>
</SelectItem>
{registries?.map((registry) => (
<SelectItem
key={registry.registryId}
value={registry.registryId}
>
{registry.registryName}
</SelectItem>
))}
<SelectLabel>
Registries ({registries?.length || 0})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
Select a registry to store the built images from the build
server.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -407,7 +407,7 @@ export const ShowDeployments = ({
</div>
)}
<ShowDeployment
serverId={serverId}
serverId={activeLog?.buildServerId || serverId}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}

View File

@@ -46,7 +46,13 @@ export type CacheType = "fetch" | "cache";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
@@ -299,6 +305,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{type === "compose" && (
<AlertBlock type="info" className="mb-4">
Whenever you make changes to domains, remember to redeploy your
compose to apply the changes.
</AlertBlock>
)}
<Form {...form}>
<form
id="hook-form"

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,13 @@ import type { ServiceType } from "../../application/advanced/show-resources";
const addDockerImage = z.object({
dockerImage: z.string().min(1, "Docker image is required"),
command: z.string(),
args: z
.array(
z.object({
value: z.string().min(1, "Argument cannot be empty"),
}),
)
.optional(),
});
interface Props {
@@ -61,18 +69,25 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
defaultValues: {
dockerImage: "",
command: "",
args: [],
},
resolver: zodResolver(addDockerImage),
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "args",
});
useEffect(() => {
if (data) {
form.reset({
dockerImage: data.dockerImage,
command: data.command || "",
args: data.args?.map((arg) => ({ value: arg })) || [],
});
}
}, [data, form, form.reset]);
}, [data, form]);
const onSubmit = async (formData: AddDockerImage) => {
await mutateAsync({
@@ -83,6 +98,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
mariadbId: id || "",
dockerImage: formData?.dockerImage,
command: formData?.command,
args: formData?.args?.map((arg) => arg.value).filter(Boolean),
})
.then(async () => {
toast.success("Custom Command Updated");
@@ -128,13 +144,68 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="Custom command" {...field} />
<Input placeholder="/bin/sh" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Arguments (Args)</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ value: "" })}
>
<Plus className="h-4 w-4 mr-1" />
Add Argument
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No arguments added yet. Click "Add Argument" to add one.
</p>
)}
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`args.${index}.value`}
render={({ field }) => (
<FormItem>
<div className="flex gap-2">
<FormControl>
<Input
placeholder={
index === 0
? "-c"
: "redis-server --port 6379"
}
{...field}
/>
</FormControl>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
<div className="flex w-full justify-end">
<Button isLoading={form.formState.isSubmitting} type="submit">
Save

View File

@@ -138,7 +138,7 @@ export const ShowProjects = () => {
list={[{ name: "Projects", href: "/dashboard/projects" }]}
/>
{!isCloud && (
<div className="absolute top-5 right-5">
<div className="absolute top-4 right-4">
<TimeBadge />
</div>
)}

View File

@@ -49,51 +49,65 @@ export const RequestDistributionChart = ({
);
return (
<ResponsiveContainer width="100%" height={200}>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="natural"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
<div className="w-full h-[200px] overflow-hidden">
<ResponsiveContainer
width="100%"
height="100%"
className="overflow-hidden"
>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
top: 10,
left: 12,
right: 12,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDataOverflow={false}
domain={[0, "auto"]}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="monotone"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
</div>
);
};

View File

@@ -51,13 +51,19 @@ export const ShowRequests = () => {
const { mutateAsync: updateLogCleanup } =
api.settings.updateLogCleanup.useMutation();
const [cronExpression, setCronExpression] = useState<string | null>(null);
// Set default date range to last 3 days
const getDefaultDateRange = () => {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 3);
return { from, to };
};
const [dateRange, setDateRange] = useState<{
from: Date | undefined;
to: Date | undefined;
}>({
from: undefined,
to: undefined,
});
}>(getDefaultDateRange());
useEffect(() => {
if (logCleanupStatus) {
@@ -169,17 +175,13 @@ export const ShowRequests = () => {
{isActive ? (
<>
<div className="flex justify-end mb-4 gap-2">
{(dateRange.from || dateRange.to) && (
<Button
variant="outline"
onClick={() =>
setDateRange({ from: undefined, to: undefined })
}
className="px-3"
>
Clear dates
</Button>
)}
<Button
variant="outline"
onClick={() => setDateRange(getDefaultDateRange())}
className="px-3"
>
Reset to Last 3 Days
</Button>
<Popover>
<PopoverTrigger asChild>
<Button

View File

@@ -1,12 +1,19 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -26,13 +33,12 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const Schema = z.object({
@@ -53,6 +59,8 @@ export const HandleAi = ({ aiId }: Props) => {
const utils = api.useUtils();
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [modelPopoverOpen, setModelPopoverOpen] = useState(false);
const [modelSearch, setModelSearch] = useState("");
const { data, refetch } = api.ai.one.useQuery(
{
aiId: aiId || "",
@@ -77,13 +85,17 @@ export const HandleAi = ({ aiId }: Props) => {
});
useEffect(() => {
form.reset({
name: data?.name ?? "",
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "",
isEnabled: data?.isEnabled ?? true,
});
if (data) {
form.reset({
name: data?.name ?? "",
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "",
isEnabled: data?.isEnabled ?? true,
});
}
setModelSearch("");
setModelPopoverOpen(false);
}, [aiId, form, data]);
const apiUrl = form.watch("apiUrl");
@@ -104,14 +116,6 @@ export const HandleAi = ({ aiId }: Props) => {
},
);
useEffect(() => {
const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey");
if (apiUrl && apiKey) {
form.setValue("model", "");
}
}, [form.watch("apiUrl"), form.watch("apiKey")]);
const onSubmit = async (data: Schema) => {
try {
await mutateAsync({
@@ -131,7 +135,16 @@ export const HandleAi = ({ aiId }: Props) => {
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
setModelSearch("");
setModelPopoverOpen(false);
}
}}
>
<DialogTrigger className="" asChild>
{aiId ? (
<Button
@@ -182,7 +195,17 @@ export const HandleAi = ({ aiId }: Props) => {
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input placeholder="https://api.openai.com/v1" {...field} />
<Input
placeholder="https://api.openai.com/v1"
{...field}
onChange={(e) => {
field.onChange(e);
// Reset model when user changes API URL
if (form.getValues("model")) {
form.setValue("model", "");
}
}}
/>
</FormControl>
<FormDescription>
The base URL for your AI provider's API
@@ -205,6 +228,13 @@ export const HandleAi = ({ aiId }: Props) => {
placeholder="sk-..."
autoComplete="one-time-code"
{...field}
onChange={(e) => {
field.onChange(e);
// Reset model when user changes API Key
if (form.getValues("model")) {
form.setValue("model", "");
}
}}
/>
</FormControl>
<FormDescription>
@@ -232,30 +262,89 @@ export const HandleAi = ({ aiId }: Props) => {
<FormField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>Select an AI model to use</FormDescription>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
const selectedModel = models.find(
(m) => m.id === field.value,
);
const filteredModels = models.filter((model) =>
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
);
// Ensure selected model is always in the filtered list
const displayModels =
field.value &&
!filteredModels.find((m) => m.id === field.value) &&
selectedModel
? [selectedModel, ...filteredModels]
: filteredModels;
return (
<FormItem>
<FormLabel>Model</FormLabel>
<Popover
open={modelPopoverOpen}
onOpenChange={setModelPopoverOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{field.value
? (selectedModel?.id ?? field.value)
: "Select a model"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search models..."
value={modelSearch}
onValueChange={setModelSearch}
/>
<CommandList>
<CommandEmpty>No models found.</CommandEmpty>
{displayModels.map((model) => {
const isSelected = field.value === model.id;
return (
<CommandItem
key={model.id}
value={model.id}
onSelect={() => {
field.onChange(model.id);
setModelPopoverOpen(false);
setModelSearch("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected
? "opacity-100"
: "opacity-0",
)}
/>
{model.id}
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Select an AI model to use
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}

View File

@@ -52,6 +52,7 @@ const Schema = z.object({
sshKeyId: z.string().min(1, {
message: "SSH Key is required",
}),
serverType: z.enum(["deploy", "build"]).default("deploy"),
});
type Schema = z.infer<typeof Schema>;
@@ -89,6 +90,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: 22,
username: "root",
sshKeyId: "",
serverType: "deploy",
},
resolver: zodResolver(Schema),
});
@@ -101,6 +103,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: data?.port || 22,
username: data?.username || "root",
sshKeyId: data?.sshKeyId || "",
serverType: data?.serverType || "deploy",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
@@ -116,6 +119,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverType: data.serverType || "deploy",
serverId: serverId || "",
})
.then(async (_data) => {
@@ -266,6 +270,50 @@ export const HandleServers = ({ serverId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="serverType"
render={({ field }) => {
const serverTypeValue = form.watch("serverType");
return (
<FormItem>
<FormLabel>Server Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a server type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="deploy">Deploy Server</SelectItem>
<SelectItem value="build">Build Server</SelectItem>
<SelectLabel>Server Type</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
{serverTypeValue === "deploy" && (
<AlertBlock type="info" className="mt-2">
Deploy servers are used to run your applications,
databases, and services. They handle the deployment and
execution of your projects.
</AlertBlock>
)}
{serverTypeValue === "build" && (
<AlertBlock type="info" className="mt-2">
Build servers are dedicated to building your
applications. They handle the compilation and build
process, offloading this work from your deployment
servers. Build servers won't appear in deployment
options.
</AlertBlock>
)}
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="sshKeyId"

View File

@@ -51,6 +51,7 @@ export const SetupServer = ({ serverId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data: isCloud } = api.settings.isCloud.useQuery();
const isBuildServer = server?.serverType === "build";
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
@@ -117,17 +118,26 @@ export const SetupServer = ({ serverId }: Props) => {
<TabsList
className={cn(
"grid w-[700px]",
isCloud ? "grid-cols-6" : "grid-cols-5",
isBuildServer
? "grid-cols-3"
: isCloud
? "grid-cols-6"
: "grid-cols-5",
)}
>
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="validate">Validate</TabsTrigger>
<TabsTrigger value="audit">Security</TabsTrigger>
{isCloud && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
{!isBuildServer && (
<>
<TabsTrigger value="audit">Security</TabsTrigger>
{isCloud && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
</>
)}
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
</TabsList>
<TabsContent
value="ssh-keys"
@@ -311,32 +321,36 @@ export const SetupServer = ({ serverId }: Props) => {
<ValidateServer serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="audit"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<SecurityAudit serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="monitoring"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm pt-3">
<div className="rounded-xl bg-background shadow-md border">
<SetupMonitoring serverId={serverId} />
</div>
</div>
</TabsContent>
<TabsContent
value="gpu-setup"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<GPUSupport serverId={serverId} />
</div>
</TabsContent>
{!isBuildServer && (
<>
<TabsContent
value="audit"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<SecurityAudit serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="monitoring"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm pt-3">
<div className="rounded-xl bg-background shadow-md border">
<SetupMonitoring serverId={serverId} />
</div>
</div>
</TabsContent>
<TabsContent
value="gpu-setup"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<GPUSupport serverId={serverId} />
</div>
</TabsContent>
</>
)}
</Tabs>
</div>
)}

View File

@@ -129,6 +129,9 @@ export const ShowServers = () => {
Status
</TableHead>
)}
<TableHead className="text-center">
Type
</TableHead>
<TableHead className="text-center">
IP Address
</TableHead>
@@ -153,6 +156,8 @@ export const ShowServers = () => {
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
const isBuildServer =
server.serverType === "build";
return (
<TableRow key={server.serverId}>
<TableCell className="text-left">
@@ -171,6 +176,15 @@ export const ShowServers = () => {
</Badge>
</TableHead>
)}
<TableCell className="text-center">
<Badge
variant={
isBuildServer ? "secondary" : "default"
}
>
{server.serverType}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge>{server.ipAddress}</Badge>
</TableCell>
@@ -233,11 +247,12 @@ export const ShowServers = () => {
serverId={server.serverId}
/>
{server.sshKeyId && (
<ShowServerActions
serverId={server.serverId}
/>
)}
{server.sshKeyId &&
!isBuildServer && (
<ShowServerActions
serverId={server.serverId}
/>
)}
</>
)}
@@ -286,41 +301,43 @@ export const ShowServers = () => {
</DropdownMenuItem>
</DialogAction>
{isActive && server.sshKeyId && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
{isActive &&
server.sshKeyId &&
!isBuildServer && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig?.server
?.token
}
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
)}
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

View File

@@ -25,6 +25,13 @@ export const ValidateServer = ({ serverId }: Props) => {
enabled: !!serverId,
},
);
const { data: server } = api.server.one.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const isBuildServer = server?.serverType === "build";
const _utils = api.useUtils();
return (
<CardContent className="p-0">
@@ -73,7 +80,9 @@ export const ValidateServer = ({ serverId }: Props) => {
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">Status</h3>
<p className="text-sm text-muted-foreground mb-4">
Shows the server configuration status
{isBuildServer
? "Shows the build server configuration status"
: "Shows the server configuration status"}
</p>
<div className="grid gap-2.5">
<StatusRow
@@ -85,15 +94,17 @@ export const ValidateServer = ({ serverId }: Props) => {
: undefined
}
/>
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
{!isBuildServer && (
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
)}
<StatusRow
label="Nixpacks Installed"
isEnabled={data?.nixpacks?.enabled}
@@ -113,23 +124,36 @@ export const ValidateServer = ({ serverId }: Props) => {
}
/>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
label="Railpack Installed"
isEnabled={data?.railpack?.enabled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
data?.railpack?.enabled
? `Installed: ${data?.railpack?.version}`
: undefined
}
/>
{!isBuildServer && (
<>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
}
/>
</>
)}
<StatusRow
label="Main Directory Created"
isEnabled={data?.isMainDirectoryInstalled}
@@ -139,15 +163,6 @@ export const ValidateServer = ({ serverId }: Props) => {
: "Not Created"
}
/>
<StatusRow
label="Railpack Installed"
isEnabled={data?.railpack?.enabled}
description={
data?.railpack?.enabled
? `Installed: ${data?.railpack?.version}`
: undefined
}
/>
</div>
</div>
</div>

View File

@@ -95,6 +95,7 @@ export const CreateServer = ({ stepper }: Props) => {
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverType: "deploy",
})
.then(async (_data) => {
toast.success("Server Created");

View File

@@ -0,0 +1,8 @@
CREATE TYPE "public"."serverType" AS ENUM('deploy', 'build');--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "buildServerId" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "buildRegistryId" text;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "buildServerId" text;--> statement-breakpoint
ALTER TABLE "server" ADD COLUMN "serverType" "serverType" DEFAULT 'deploy' NOT NULL;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_buildRegistryId_registry_registryId_fk" FOREIGN KEY ("buildRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "application" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "args" text[];

View File

@@ -0,0 +1 @@
ALTER TABLE "ntfy" ALTER COLUMN "accessToken" DROP NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -855,6 +855,27 @@
"when": 1763755037033,
"tag": "0121_rainy_cargill",
"breakpoints": true
},
{
"idx": 122,
"version": "7",
"when": 1764479387555,
"tag": "0122_absent_frightful_four",
"breakpoints": true
},
{
"idx": 123,
"version": "7",
"when": 1764525308939,
"tag": "0123_cloudy_piledriver",
"breakpoints": true
},
{
"idx": 124,
"version": "7",
"when": 1764571454170,
"tag": "0124_certain_cloak",
"breakpoints": true
}
]
}

View File

@@ -34,7 +34,8 @@
"docker:build:canary": "./docker/build.sh canary",
"docker:push:canary": "./docker/push.sh canary",
"version": "echo $(node -p \"require('./package.json').version\")",
"test": "vitest --config __test__/vitest.config.ts"
"test": "vitest --config __test__/vitest.config.ts",
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.5",
@@ -113,7 +114,6 @@
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"yaml": "2.8.1",
"lodash": "4.17.21",
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
@@ -121,6 +121,7 @@
"next": "^15.3.2",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
"node-pty": "1.0.0",
"node-schedule": "2.1.1",
@@ -154,6 +155,7 @@
"use-resize-observer": "9.1.0",
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^3.25.32",
"zod-form-data": "^2.0.7"
},

View File

@@ -7,6 +7,7 @@ import Head from "next/head";
import Script from "next/script";
import { appWithTranslation } from "next-i18next";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { Toaster } from "@/components/ui/sonner";
@@ -57,6 +58,7 @@ const MyApp = ({
disableTransitionOnChange
forcedTheme={Component.theme}
>
<NextTopLoader color="hsl(var(--sidebar-ring))" />
<Toaster richColors />
<SearchCommand />
{getLayout(<Component {...pageProps} />)}

View File

@@ -17,6 +17,7 @@ import { AddCommand } from "@/components/dashboard/application/advanced/general/
import { ShowPorts } from "@/components/dashboard/application/advanced/ports/show-port";
import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects";
import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security";
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
@@ -353,7 +354,7 @@ const Service = (
id={applicationId}
type="application"
/>
<ShowBuildServer applicationId={applicationId} />
<ShowResources id={applicationId} type="application" />
<ShowVolumes id={applicationId} type="application" />
<ShowRedirects applicationId={applicationId} />

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env tsx
/**
* Script to generate OpenAPI specification locally
* This runs in CI/CD to generate the openapi.json file
* which can then be consumed by the documentation website
*/
import { writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
import { appRouter } from "../server/api/root";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function generateOpenAPI() {
try {
console.log("🔄 Generating OpenAPI specification...");
const openApiDocument = generateOpenApiDocument(appRouter, {
title: "Dokploy API",
version: "1.0.0",
baseUrl: "https://your-dokploy-instance.com/api",
docsUrl: "https://docs.dokploy.com/api",
tags: [
"admin",
"docker",
"compose",
"registry",
"cluster",
"user",
"domain",
"destination",
"backup",
"deployment",
"mounts",
"certificates",
"settings",
"security",
"redirects",
"port",
"project",
"application",
"mysql",
"postgres",
"redis",
"mongo",
"mariadb",
"sshRouter",
"gitProvider",
"bitbucket",
"github",
"gitlab",
"gitea",
"server",
"swarm",
"ai",
"organization",
"schedule",
"rollback",
"volumeBackups",
"environment",
],
});
// Enhance metadata
openApiDocument.info = {
title: "Dokploy API",
description:
"Complete API documentation for Dokploy - Deploy applications, manage databases, and orchestrate your infrastructure. This API allows you to programmatically manage all aspects of your Dokploy instance.",
version: "1.0.0",
contact: {
name: "Dokploy Team",
url: "https://dokploy.com",
},
license: {
name: "Apache 2.0",
url: "https://github.com/dokploy/dokploy/blob/canary/LICENSE",
},
};
// Add security schemes
openApiDocument.components = {
...openApiDocument.components,
securitySchemes: {
apiKey: {
type: "apiKey",
in: "header",
name: "x-api-key",
description:
"API key authentication. Generate an API key from your Dokploy dashboard under Settings > API Keys.",
},
},
};
// Apply global security
openApiDocument.security = [
{
apiKey: [],
},
];
// Add external docs
openApiDocument.externalDocs = {
description: "Full documentation",
url: "https://docs.dokploy.com",
};
// Write to root of repo
const outputPath = resolve(__dirname, "../../../openapi.json");
writeFileSync(
outputPath,
JSON.stringify(openApiDocument, null, 2),
"utf-8",
);
console.log("✅ OpenAPI specification generated successfully!");
console.log(`📄 Output: ${outputPath}`);
console.log(
`📊 Endpoints: ${Object.keys(openApiDocument.paths || {}).length}`,
);
} catch (error) {
console.error("❌ Error generating OpenAPI specification:", error);
process.exit(1);
} finally {
process.exit(0);
}
}
generateOpenAPI();

View File

@@ -81,8 +81,10 @@ export const serverRouter = createTRPCRouter({
}),
getDefaultCommand: protectedProcedure
.input(apiFindOneServer)
.query(async () => {
return defaultCommand();
.query(async ({ input }) => {
const server = await findServerById(input.serverId);
const isBuildServer = server.serverType === "build";
return defaultCommand(isBuildServer);
}),
all: protectedProcedure.query(async ({ ctx }) => {
const result = await db
@@ -124,10 +126,30 @@ export const serverRouter = createTRPCRouter({
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverStatus, "active"),
eq(server.serverType, "deploy"),
)
: and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverType, "deploy"),
),
});
return result;
}),
buildServers: protectedProcedure.query(async ({ ctx }) => {
const result = await db.query.server.findMany({
orderBy: desc(server.createdAt),
where: IS_CLOUD
? and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverStatus, "active"),
eq(server.serverType, "build"),
)
: and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverType, "build"),
),
});
return result;

View File

@@ -2,7 +2,13 @@ import { z } from "zod";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
port: z
.number()
@@ -33,7 +39,13 @@ export const domain = z
export const domainCompose = z
.object({
host: z.string().min(1, { message: "Host is required" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
port: z
.number()

View File

@@ -46,6 +46,14 @@ export const setupDockerContainerLogsWebSocketServer = (
ws.close();
return;
}
// Set up keep-alive ping mechanism to prevent timeout
// Send ping every 45 seconds to keep connection alive
const pingInterval = setInterval(() => {
if (ws.readyState === ws.OPEN) {
ws.ping();
}
}, 45000); // 45 seconds
try {
if (serverId) {
const server = await findServerById(serverId);
@@ -86,6 +94,7 @@ export const setupDockerContainerLogsWebSocketServer = (
.on("error", (err) => {
console.error("SSH connection error:", err);
ws.send(`SSH error: ${err.message}`);
clearInterval(pingInterval);
ws.close(); // Cierra el WebSocket si hay un error con SSH
client.end();
})
@@ -96,6 +105,7 @@ export const setupDockerContainerLogsWebSocketServer = (
privateKey: server.sshKey?.privateKey,
});
ws.on("close", () => {
clearInterval(pingInterval);
client.end();
});
} else {
@@ -121,6 +131,7 @@ export const setupDockerContainerLogsWebSocketServer = (
ws.send(data);
});
ws.on("close", () => {
clearInterval(pingInterval);
ptyProcess.kill();
});
ws.on("message", (message) => {

20004
openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,8 @@
"build": "pnpm -r run build",
"format-and-lint": "biome check .",
"check": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true",
"format-and-lint:fix": "biome check . --write"
"format-and-lint:fix": "biome check . --write",
"generate:openapi": "pnpm --filter=dokploy run generate:openapi"
},
"devDependencies": {
"@biomejs/biome": "2.1.1",

View File

@@ -111,6 +111,7 @@ export const applications = pgTable("application", {
enabled: boolean("enabled"),
subtitle: text("subtitle"),
command: text("command"),
args: text("args").array(),
refreshToken: text("refreshToken").$defaultFn(() => nanoid()),
sourceType: sourceType("sourceType").notNull().default("github"),
cleanCache: boolean("cleanCache").default(false),
@@ -204,6 +205,15 @@ export const applications = pgTable("application", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
buildServerId: text("buildServerId").references(() => server.serverId, {
onDelete: "set null",
}),
buildRegistryId: text("buildRegistryId").references(
() => registry.registryId,
{
onDelete: "set null",
},
),
});
export const applicationsRelations = relations(
@@ -226,6 +236,7 @@ export const applicationsRelations = relations(
registry: one(registry, {
fields: [applications.registryId],
references: [registry.registryId],
relationName: "applicationRegistry",
}),
github: one(github, {
fields: [applications.githubId],
@@ -246,6 +257,17 @@ export const applicationsRelations = relations(
server: one(server, {
fields: [applications.serverId],
references: [server.serverId],
relationName: "applicationServer",
}),
buildServer: one(server, {
fields: [applications.buildServerId],
references: [server.serverId],
relationName: "applicationBuildServer",
}),
buildRegistry: one(registry, {
fields: [applications.buildRegistryId],
references: [registry.registryId],
relationName: "applicationBuildRegistry",
}),
previewDeployments: many(previewDeployments),
}),
@@ -272,6 +294,7 @@ const createSchema = createInsertSchema(applications, {
username: z.string().optional(),
isPreviewDeploymentsActive: z.boolean().optional(),
password: z.string().optional(),
args: z.array(z.string()).optional(),
registryUrl: z.string().optional(),
customGitSSHKeyId: z.string().optional(),
repository: z.string().optional(),

View File

@@ -70,6 +70,9 @@ export const deployments = pgTable("deployment", {
(): AnyPgColumn => volumeBackups.volumeBackupId,
{ onDelete: "cascade" },
),
buildServerId: text("buildServerId").references(() => server.serverId, {
onDelete: "cascade",
}),
});
export const deploymentsRelations = relations(deployments, ({ one }) => ({
@@ -84,6 +87,12 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
server: one(server, {
fields: [deployments.serverId],
references: [server.serverId],
relationName: "deploymentServer",
}),
buildServer: one(server, {
fields: [deployments.buildServerId],
references: [server.serverId],
relationName: "deploymentBuildServer",
}),
previewDeployment: one(previewDeployments, {
fields: [deployments.previewDeploymentId],
@@ -115,6 +124,7 @@ const schema = createInsertSchema(deployments, {
composeId: z.string(),
description: z.string().optional(),
previewDeploymentId: z.string(),
buildServerId: z.string(),
});
export const apiCreateDeployment = schema

View File

@@ -45,6 +45,7 @@ export const mariadb = pgTable("mariadb", {
databaseRootPassword: text("rootPassword").notNull(),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
// RESOURCES
memoryReservation: text("memoryReservation"),
@@ -114,6 +115,7 @@ const createSchema = createInsertSchema(mariadb, {
.optional(),
dockerImage: z.string().default("mariadb:6"),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),

View File

@@ -50,6 +50,7 @@ export const mongo = pgTable("mongo", {
databasePassword: text("databasePassword").notNull(),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
@@ -110,6 +111,7 @@ const createSchema = createInsertSchema(mongo, {
databaseUser: z.string().min(1),
dockerImage: z.string().default("mongo:15"),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),

View File

@@ -45,6 +45,7 @@ export const mysql = pgTable("mysql", {
databaseRootPassword: text("rootPassword").notNull(),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
@@ -112,6 +113,7 @@ const createSchema = createInsertSchema(mysql, {
.optional(),
dockerImage: z.string().default("mysql:8"),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),

View File

@@ -120,7 +120,7 @@ export const ntfy = pgTable("ntfy", {
.$defaultFn(() => nanoid()),
serverUrl: text("serverUrl").notNull(),
topic: text("topic").notNull(),
accessToken: text("accessToken").notNull(),
accessToken: text("accessToken"),
priority: integer("priority").notNull().default(3),
});
@@ -348,7 +348,7 @@ export const apiCreateNtfy = notificationsSchema
.extend({
serverUrl: z.string().min(1),
topic: z.string().min(1),
accessToken: z.string().min(1),
accessToken: z.string().optional(),
priority: z.number().min(1),
})
.required();
@@ -438,7 +438,7 @@ export const apiSendTest = notificationsSchema
serverUrl: z.string(),
topic: z.string(),
appToken: z.string(),
accessToken: z.string(),
accessToken: z.string().optional(),
priority: z.number(),
endpoint: z.string(),
headers: z.string(),

View File

@@ -44,6 +44,7 @@ export const postgres = pgTable("postgres", {
description: text("description"),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
memoryReservation: text("memoryReservation"),
externalPort: integer("externalPort"),
@@ -103,6 +104,7 @@ const createSchema = createInsertSchema(postgres, {
databaseUser: z.string().min(1),
dockerImage: z.string().default("postgres:15"),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),

View File

@@ -41,6 +41,7 @@ export const redis = pgTable("redis", {
databasePassword: text("password").notNull(),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
@@ -93,6 +94,7 @@ const createSchema = createInsertSchema(redis, {
databasePassword: z.string(),
dockerImage: z.string().default("redis:8"),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),

View File

@@ -33,7 +33,12 @@ export const registry = pgTable("registry", {
});
export const registryRelations = relations(registry, ({ many }) => ({
applications: many(applications),
applications: many(applications, {
relationName: "applicationRegistry",
}),
buildApplications: many(applications, {
relationName: "applicationBuildRegistry",
}),
}));
const createSchema = createInsertSchema(registry, {

View File

@@ -24,6 +24,7 @@ import { schedules } from "./schedule";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]);
export const serverType = pgEnum("serverType", ["deploy", "build"]);
export const server = pgTable("server", {
serverId: text("serverId")
@@ -44,6 +45,7 @@ export const server = pgTable("server", {
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
serverStatus: serverStatus("serverStatus").notNull().default("active"),
serverType: serverType("serverType").notNull().default("deploy"),
command: text("command").notNull().default(""),
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
onDelete: "set null",
@@ -97,12 +99,22 @@ export const server = pgTable("server", {
});
export const serverRelations = relations(server, ({ one, many }) => ({
deployments: many(deployments),
deployments: many(deployments, {
relationName: "deploymentServer",
}),
buildDeployments: many(deployments, {
relationName: "deploymentBuildServer",
}),
sshKey: one(sshKeys, {
fields: [server.sshKeyId],
references: [sshKeys.sshKeyId],
}),
applications: many(applications),
applications: many(applications, {
relationName: "applicationServer",
}),
buildApplications: many(applications, {
relationName: "applicationBuildServer",
}),
compose: many(compose),
redis: many(redis),
mariadb: many(mariadb),
@@ -131,6 +143,7 @@ export const apiCreateServer = createSchema
port: true,
username: true,
sshKeyId: true,
serverType: true,
})
.required();
@@ -155,6 +168,7 @@ export const apiUpdateServer = createSchema
port: true,
username: true,
sshKeyId: true,
serverType: true,
})
.required()
.extend({

View File

@@ -2,7 +2,13 @@ import { z } from "zod";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
@@ -58,7 +64,13 @@ export const domain = z
export const domainCompose = z
.object({
host: z.string().min(1, { message: "Host is required" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),

View File

@@ -112,6 +112,7 @@ export const findApplicationById = async (applicationId: string) => {
gitea: true,
server: true,
previewDeployments: true,
buildRegistry: true,
},
});
if (!application) {
@@ -172,6 +173,7 @@ export const deployApplication = async ({
descriptionLog: string;
}) => {
const application = await findApplicationById(applicationId);
const serverId = application.buildServerId || application.serverId;
const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`;
const deployment = await createDeployment({
@@ -199,8 +201,8 @@ export const deployApplication = async ({
command += getBuildCommand(application);
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (application.serverId) {
await execAsyncRemote(application.serverId, commandWithLog);
if (serverId) {
await execAsyncRemote(serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
@@ -240,8 +242,8 @@ export const deployApplication = async ({
}
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
if (application.serverId) {
await execAsyncRemote(application.serverId, command);
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
@@ -285,6 +287,7 @@ export const rebuildApplication = async ({
descriptionLog: string;
}) => {
const application = await findApplicationById(applicationId);
const serverId = application.buildServerId || application.serverId;
const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`;
const deployment = await createDeployment({
@@ -298,8 +301,8 @@ export const rebuildApplication = async ({
// Check case for docker only
command += getBuildCommand(application);
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (application.serverId) {
await execAsyncRemote(application.serverId, commandWithLog);
if (serverId) {
await execAsyncRemote(serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
@@ -338,8 +341,8 @@ export const rebuildApplication = async ({
}
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
if (application.serverId) {
await execAsyncRemote(application.serverId, command);
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}

View File

@@ -78,7 +78,6 @@ const BUNNY_CDN_IPS = new Set([
"89.187.188.227",
"89.187.188.228",
"139.180.134.196",
"89.38.96.158",
"89.187.162.249",
"89.187.162.242",
"185.102.217.65",
@@ -106,12 +105,9 @@ const BUNNY_CDN_IPS = new Set([
"200.25.38.69",
"200.25.42.70",
"200.25.36.166",
"195.206.229.106",
"194.242.11.186",
"185.164.35.8",
"94.20.154.22",
"185.93.1.244",
"156.59.145.154",
"143.244.49.177",
"138.199.46.66",
"138.199.37.227",
@@ -136,7 +132,6 @@ const BUNNY_CDN_IPS = new Set([
"84.17.59.115",
"89.187.165.194",
"138.199.15.193",
"89.35.237.170",
"37.19.216.130",
"185.93.1.247",
"185.93.3.244",
@@ -150,6 +145,7 @@ const BUNNY_CDN_IPS = new Set([
"84.17.63.178",
"200.25.32.131",
"37.19.207.34",
"37.19.207.38",
"192.189.65.146",
"143.244.45.177",
"185.93.1.249",
@@ -168,9 +164,7 @@ const BUNNY_CDN_IPS = new Set([
"129.227.217.178",
"129.227.217.179",
"200.25.69.94",
"128.1.52.179",
"200.25.16.103",
"15.235.54.226",
"102.67.138.155",
"156.146.43.65",
"195.181.163.203",
@@ -278,13 +272,11 @@ const BUNNY_CDN_IPS = new Set([
"107.155.47.146",
"193.201.190.174",
"156.59.95.218",
"213.170.143.139",
"129.227.186.154",
"195.238.127.98",
"200.25.22.6",
"204.16.244.92",
"200.25.70.101",
"200.25.66.100",
"139.180.209.182",
"103.108.231.41",
"103.108.229.5",
@@ -387,46 +379,13 @@ const BUNNY_CDN_IPS = new Set([
"38.54.5.37",
"38.54.3.92",
"185.165.170.74",
"207.121.80.118",
"207.121.46.228",
"207.121.46.236",
"207.121.46.244",
"207.121.46.252",
"216.202.235.164",
"207.121.46.220",
"207.121.75.132",
"207.121.80.12",
"207.121.80.172",
"207.121.90.60",
"207.121.90.68",
"207.121.97.204",
"207.121.90.252",
"207.121.97.236",
"207.121.99.12",
"138.199.24.219",
"185.93.2.251",
"138.199.46.65",
"207.121.41.196",
"207.121.99.20",
"207.121.99.36",
"207.121.99.44",
"207.121.99.52",
"207.121.99.60",
"207.121.23.68",
"207.121.23.124",
"207.121.23.244",
"207.121.23.180",
"207.121.23.188",
"207.121.23.196",
"207.121.23.204",
"207.121.24.52",
"207.121.24.60",
"207.121.24.68",
"207.121.24.76",
"207.121.24.92",
"207.121.24.100",
"207.121.24.108",
"207.121.24.116",
"154.95.86.76",
"5.9.99.73",
"78.46.92.118",
@@ -434,14 +393,52 @@ const BUNNY_CDN_IPS = new Set([
"78.46.156.89",
"88.198.9.155",
"144.76.79.22",
"103.1.215.93",
"103.137.12.33",
"103.107.196.31",
"116.90.72.155",
"103.137.14.5",
"116.90.75.65",
"37.19.207.37",
"208.83.234.224",
"79.127.237.104",
"79.127.243.187",
"45.156.248.73",
"79.127.134.225",
"79.127.134.226",
"79.127.134.227",
"79.127.134.228",
"79.127.134.229",
"79.127.134.230",
"79.127.134.231",
"79.127.134.130",
"79.127.134.131",
"79.127.134.132",
"79.127.134.234",
"79.127.134.235",
"185.111.111.154",
"185.111.111.155",
"185.111.111.156",
"185.111.111.157",
"185.111.111.158",
"185.111.111.159",
"185.111.111.160",
"141.227.142.242",
"94.128.254.166",
"195.206.229.69",
"200.25.86.90",
"148.113.190.161",
"46.151.194.242",
"46.151.194.243",
"212.102.40.120",
"213.170.143.100",
"154.93.86.71",
"143.244.60.196",
"143.244.60.197",
"143.244.60.195",
"79.127.134.129",
"79.127.134.133",
"152.233.22.97",
"152.233.22.98",
"152.233.22.100",
"152.233.22.99",
"152.233.22.101",
"152.233.22.102",
"152.233.22.103",
"116.202.155.146",
"116.202.193.178",
"116.202.224.168",
@@ -502,6 +499,12 @@ const BUNNY_CDN_IPS = new Set([
"103.60.15.166",
"103.60.15.167",
"103.60.15.168",
"176.9.139.94",
"148.251.129.132",
"148.251.131.73",
"148.251.131.74",
"136.243.70.170",
"148.251.131.238",
"109.248.43.116",
"109.248.43.117",
"109.248.43.162",
@@ -527,7 +530,9 @@ const BUNNY_CDN_IPS = new Set([
"139.180.129.216",
"139.99.174.7",
"89.187.169.18",
"143.244.38.133",
"89.187.179.7",
"169.150.213.50",
"143.244.62.213",
"185.93.3.246",
"195.181.163.198",
@@ -535,7 +540,6 @@ const BUNNY_CDN_IPS = new Set([
"84.17.37.211",
"212.102.50.54",
"212.102.46.115",
"143.244.38.135",
"169.150.238.21",
"169.150.207.51",
"169.150.207.49",
@@ -546,7 +550,6 @@ const BUNNY_CDN_IPS = new Set([
"169.150.247.139",
"169.150.247.177",
"169.150.247.178",
"169.150.213.49",
"212.102.46.119",
"84.17.38.234",
"84.17.38.233",
@@ -558,7 +561,6 @@ const BUNNY_CDN_IPS = new Set([
"169.150.247.138",
"169.150.247.184",
"169.150.247.185",
"156.146.58.83",
"212.102.43.88",
"89.187.169.26",
"109.61.89.57",
@@ -587,6 +589,17 @@ const BUNNY_CDN_IPS = new Set([
"138.199.4.177",
"37.19.222.34",
"46.151.193.85",
"79.127.237.99",
"212.104.158.30",
"212.104.158.31",
"212.104.158.32",
"212.104.158.33",
"212.104.158.34",
"212.104.158.28",
"212.104.158.29",
"212.104.158.35",
"212.104.158.36",
"212.104.158.37",
"212.104.158.17",
"212.104.158.18",
"212.104.158.19",
@@ -595,12 +608,20 @@ const BUNNY_CDN_IPS = new Set([
"212.104.158.22",
"212.104.158.24",
"212.104.158.26",
"79.127.237.134",
"89.187.184.177",
"89.187.184.179",
"89.187.184.173",
"89.187.184.178",
"89.187.184.176",
"212.104.158.25",
"212.104.158.27",
"212.104.158.67",
"212.104.158.10",
"212.104.158.12",
"212.104.158.64",
"212.104.158.16",
"212.104.158.23",
"212.104.158.54",
]);
// Arvancloud IP ranges

View File

@@ -80,7 +80,7 @@ export const createDeployment = async (
"application",
application.serverId,
);
const serverId = application.serverId;
const serverId = application.buildServerId || application.serverId;
const { LOGS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
@@ -93,6 +93,7 @@ export const createDeployment = async (
const command = `
mkdir -p ${LOGS_PATH}/${application.appName};
echo "Initializing deployment" >> ${logFilePath};
echo "Building on ${serverId ? "Build Server" : "Dokploy Server"}" >> ${logFilePath};
`;
await execAsyncRemote(server.serverId, command);
@@ -112,6 +113,9 @@ export const createDeployment = async (
logPath: logFilePath,
description: deployment.description || "",
startedAt: new Date().toISOString(),
...(application.buildServerId && {
buildServerId: application.buildServerId,
}),
})
.returning();
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {

View File

@@ -19,6 +19,7 @@ export const createDomain = async (input: typeof apiCreateDomain._type) => {
.insert(domains)
.values({
...input,
host: input.host?.trim(),
})
.returning()
.then((response) => response[0]);
@@ -120,6 +121,7 @@ export const updateDomainById = async (
.update(domains)
.set({
...domainData,
...(domainData.host && { host: domainData.host.trim() }),
})
.where(eq(domains.domainId, domainId))
.returning();

View File

@@ -501,7 +501,7 @@ export const createNtfyNotification = async (
.values({
serverUrl: input.serverUrl,
topic: input.topic,
accessToken: input.accessToken,
accessToken: input.accessToken ?? null,
priority: input.priority,
})
.returning()
@@ -572,7 +572,7 @@ export const updateNtfyNotification = async (
.set({
serverUrl: input.serverUrl,
topic: input.topic,
accessToken: input.accessToken,
accessToken: input.accessToken ?? null,
priority: input.priority,
})
.where(eq(ntfy.ntfyId, input.ntfyId));

View File

@@ -51,7 +51,12 @@ export const serverSetup = async (
});
try {
onData?.("\nInstalling Server Dependencies: ✅\n");
const isBuildServer = server.serverType === "build";
onData?.(
isBuildServer
? "\nInstalling Build Server Dependencies: ✅\n"
: "\nInstalling Server Dependencies: ✅\n",
);
await installRequirements(serverId, onData);
await updateDeploymentStatus(deployment.deploymentId, "done");
@@ -65,7 +70,7 @@ export const serverSetup = async (
}
};
export const defaultCommand = () => {
export const defaultCommand = (isBuildServer = false) => {
const bashCommand = `
set -e;
DOCKER_VERSION=27.0.3
@@ -126,6 +131,7 @@ echo -e "---------------------------------------------"
echo "| CPU Architecture | $SYS_ARCH"
echo "| Operating System | $OS_TYPE $OS_VERSION"
echo "| Docker | $DOCKER_VERSION"
${isBuildServer ? 'echo "| Server Type | Build Server"' : ""}
echo -e "---------------------------------------------\n"
echo -e "1. Installing required packages (curl, wget, git, jq, openssl). "
@@ -135,6 +141,9 @@ command_exists() {
${installUtilities()}
${
!isBuildServer
? `
echo -e "2. Validating ports. "
${validatePorts()}
@@ -173,6 +182,25 @@ ${installBuildpacks()}
echo -e "13. Installing Railpack"
${installRailpack()}
`
: `
echo -e "2. Installing Docker. "
${installDocker()}
echo -e "3. Setting up Directories"
${setupMainDirectory()}
${setupDirectories()}
echo -e "4. Installing Nixpacks"
${installNixpacks()}
echo -e "5. Installing Buildpacks"
${installBuildpacks()}
echo -e "6. Installing Railpack"
${installRailpack()}
`
}
`;
return bashCommand;
@@ -189,10 +217,12 @@ const installRequirements = async (
throw new Error("No SSH Key found");
}
const isBuildServer = server.serverType === "build";
return new Promise<void>((resolve, reject) => {
client
.once("ready", () => {
const command = server.command || defaultCommand();
const command = server.command || defaultCommand(isBuildServer);
client.exec(command, (err, stream) => {
if (err) {
onData?.(err.message);

View File

@@ -29,13 +29,14 @@ export type ApplicationNested = InferResultType<
redirects: true;
ports: true;
registry: true;
buildRegistry: true;
environment: { with: { project: true } };
}
>;
export const getBuildCommand = (application: ApplicationNested) => {
let command = "";
const { buildType, registry } = application;
const { buildType } = application;
if (application.sourceType === "docker") {
return "";
@@ -60,7 +61,7 @@ export const getBuildCommand = (application: ApplicationNested) => {
command = getRailpackCommand(application);
break;
}
if (registry) {
if (application.registry || application.buildRegistry) {
command += uploadImageRemoteCommand(application);
}
@@ -79,6 +80,7 @@ export const mechanizeDockerContainer = async (
memoryReservation,
cpuReservation,
command,
args,
ports,
} = application;
@@ -125,12 +127,16 @@ export const mechanizeDockerContainer = async (
Image: image,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(command
? {
Command: ["/bin/sh"],
Args: ["-c", command],
}
: {}),
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(command && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 && {
Args: args,
}),
Labels,
},
Networks,
@@ -153,8 +159,6 @@ export const mechanizeDockerContainer = async (
})),
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {
@@ -169,13 +173,15 @@ export const mechanizeDockerContainer = async (
ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1,
},
});
} catch {
} catch (error) {
console.log(error);
await docker.createService(settings);
}
};
const getImageName = (application: ApplicationNested) => {
const { appName, sourceType, dockerImage, registry } = application;
const { appName, sourceType, dockerImage, registry, buildRegistry } =
application;
const imageName = `${appName}:latest`;
if (sourceType === "docker") {
return dockerImage || "ERROR-NO-IMAGE-PROVIDED";
@@ -188,12 +194,26 @@ const getImageName = (application: ApplicationNested) => {
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
return registryTag;
}
if (buildRegistry) {
const { registryUrl, imagePrefix, username } = buildRegistry;
const registryTag = imagePrefix
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
return registryTag;
}
return imageName;
};
export const getAuthConfig = (application: ApplicationNested) => {
const { registry, username, password, sourceType, registryUrl } = application;
const {
registry,
buildRegistry,
username,
password,
sourceType,
registryUrl,
} = application;
if (sourceType === "docker") {
if (username && password) {
@@ -209,6 +229,12 @@ export const getAuthConfig = (application: ApplicationNested) => {
username: registry.username,
serveraddress: registry.registryUrl,
};
} else if (buildRegistry) {
return {
password: buildRegistry.password,
username: buildRegistry.username,
serveraddress: buildRegistry.registryUrl,
};
}
return undefined;

View File

@@ -1,44 +1,77 @@
import type { Registry } from "@dokploy/server/services/registry";
import type { ApplicationNested } from "../builders";
export const uploadImageRemoteCommand = (application: ApplicationNested) => {
const registry = application.registry;
const buildRegistry = application.buildRegistry;
if (!registry) {
throw new Error("Registry not found");
if (!registry && !buildRegistry) {
throw new Error("No registry found");
}
const { registryUrl, imagePrefix, username } = registry;
const { appName } = application;
const imageName = `${appName}:latest`;
const finalURL = registryUrl;
// Build registry tag in correct format: registry.com/owner/image:tag
const registryTag = imagePrefix
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
const commands: string[] = [];
if (registry) {
const registryTag = getRegistryTag(registry, imageName);
if (registryTag) {
commands.push(`echo "📦 [Enabled Registry Swarm]"`);
commands.push(getRegistryCommands(registry, imageName, registryTag));
}
}
if (buildRegistry) {
const buildRegistryTag = getRegistryTag(buildRegistry, imageName);
if (buildRegistryTag) {
commands.push(`echo "🔑 [Enabled Build Registry]"`);
commands.push(
getRegistryCommands(buildRegistry, imageName, buildRegistryTag),
);
commands.push(
`echo "⚠️ INFO: After the build is finished, you need to wait a few seconds for the server to download the image and run the container."`,
);
commands.push(
`echo "📊 Check the Logs tab to see when the container starts running."`,
);
}
}
try {
const command = `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
echo "${registry.password}" | docker login ${finalURL} -u ${registry.username} --password-stdin || {
echo "❌ DockerHub Failed" ;
exit 1;
}
echo "✅ Registry Login Success" ;
docker tag ${imageName} ${registryTag} || {
echo "❌ Error tagging image" ;
exit 1;
}
echo "✅ Image Tagged" ;
docker push ${registryTag} || {
echo "❌ Error pushing image" ;
exit 1;
}
echo "✅ Image Pushed" ;
`;
return command;
return commands.join("\n");
} catch (error) {
throw error;
}
};
const getRegistryTag = (registry: Registry | null, imageName: string) => {
if (!registry) {
return null;
}
const { registryUrl, imagePrefix, username } = registry;
return imagePrefix
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
};
const getRegistryCommands = (
registry: Registry,
imageName: string,
registryTag: string,
): string => {
return `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
echo "${registry.password}" | docker login ${registry.registryUrl} -u ${registry.username} --password-stdin || {
echo "❌ DockerHub Failed" ;
exit 1;
}
echo "✅ Registry Login Success" ;
docker tag ${imageName} ${registryTag} || {
echo "❌ Error tagging image" ;
exit 1;
}
echo "✅ Image Tagged" ;
docker push ${registryTag} || {
echo "❌ Error pushing image" ;
exit 1;
}
echo "✅ Image Pushed" ;
`;
};

View File

@@ -29,6 +29,7 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
cpuLimit,
cpuReservation,
command,
args,
mounts,
} = mariadb;
@@ -73,12 +74,16 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(command
? {
Command: ["/bin/sh"],
Args: ["-c", command],
}
: {}),
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(command && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 && {
Args: args,
}),
Labels,
},
Networks,
@@ -106,8 +111,6 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {
const service = docker.getService(appName);

View File

@@ -28,6 +28,7 @@ export const buildMongo = async (mongo: MongoNested) => {
databaseUser,
databasePassword,
command,
args,
mounts,
replicaSets,
} = mongo;
@@ -121,17 +122,24 @@ ${command ?? "wait $MONGOD_PID"}`;
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(replicaSets
? {
Command: ["/bin/bash"],
Args: ["-c", startupScript],
}
: {
...(command && {
Command: ["/bin/bash"],
Args: ["-c", command],
}),
}),
: {}),
...(command &&
!replicaSets && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 &&
!replicaSets && {
Args: args,
}),
Labels,
},
Networks,
@@ -159,8 +167,6 @@ ${command ?? "wait $MONGOD_PID"}`;
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {

View File

@@ -30,6 +30,7 @@ export const buildMysql = async (mysql: MysqlNested) => {
cpuLimit,
cpuReservation,
command,
args,
mounts,
} = mysql;
@@ -79,12 +80,16 @@ export const buildMysql = async (mysql: MysqlNested) => {
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(command
? {
Command: ["/bin/sh"],
Args: ["-c", command],
}
: {}),
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(command && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 && {
Args: args,
}),
Labels,
},
Networks,
@@ -112,8 +117,6 @@ export const buildMysql = async (mysql: MysqlNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {
const service = docker.getService(appName);

View File

@@ -28,6 +28,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
databaseUser,
databasePassword,
command,
args,
mounts,
} = postgres;
@@ -72,12 +73,16 @@ export const buildPostgres = async (postgres: PostgresNested) => {
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(command
? {
Command: ["/bin/sh"],
Args: ["-c", command],
}
: {}),
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(command && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 && {
Args: args,
}),
Labels,
},
Networks,
@@ -105,8 +110,6 @@ export const buildPostgres = async (postgres: PostgresNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {
const service = docker.getService(appName);

View File

@@ -26,6 +26,7 @@ export const buildRedis = async (redis: RedisNested) => {
cpuLimit,
cpuReservation,
command,
args,
mounts,
} = redis;
@@ -70,11 +71,22 @@ export const buildRedis = async (redis: RedisNested) => {
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
Command: ["/bin/sh"],
Args: [
"-c",
command ? command : `redis-server --requirepass ${databasePassword}`,
],
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(command || args
? {
...(command && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 && {
Args: args,
}),
}
: {
Command: ["/bin/sh"],
Args: ["-c", `redis-server --requirepass ${databasePassword}`],
}),
Labels,
},
Networks,
@@ -102,8 +114,6 @@ export const buildRedis = async (redis: RedisNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {

View File

@@ -160,7 +160,9 @@ export const sendNtfyNotification = async (
const response = await fetch(`${connection.serverUrl}/${connection.topic}`, {
method: "POST",
headers: {
Authorization: `Bearer ${connection.accessToken}`,
...(connection.accessToken && {
Authorization: `Bearer ${connection.accessToken}`,
}),
"X-Priority": connection.priority?.toString() || "3",
"X-Title": title,
"X-Tags": tags,

23
pnpm-lock.yaml generated
View File

@@ -346,6 +346,9 @@ importers:
next-themes:
specifier: ^0.2.1
version: 0.2.1(next@15.3.2(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nextjs-toploader:
specifier: ^3.9.17
version: 3.9.17(next@15.3.2(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
node-os-utils:
specifier: 2.0.1
version: 2.0.1
@@ -6267,6 +6270,13 @@ packages:
sass:
optional: true
nextjs-toploader@3.9.17:
resolution: {integrity: sha512-9OF0KSSLtoSAuNg2LZ3aTl4hR9mBDj5L9s9DZiFCbMlXehyICGjkIz5dVGzuATU2bheJZoBdFgq9w07AKSuQQw==}
peerDependencies:
next: '>= 6.0.0'
react: '>= 16.0.0'
react-dom: '>= 16.0.0'
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
@@ -6366,6 +6376,9 @@ packages:
resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==}
deprecated: This package is no longer supported.
nprogress@0.2.0:
resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -13816,6 +13829,14 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
nextjs-toploader@3.9.17(next@15.3.2(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
next: 15.3.2(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nprogress: 0.2.0
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
node-abort-controller@3.1.1: {}
node-addon-api@5.1.0: {}
@@ -13900,6 +13921,8 @@ snapshots:
gauge: 3.0.2
set-blocking: 2.0.0
nprogress@0.2.0: {}
object-assign@4.1.1: {}
object-hash@3.0.0: {}