mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-12 15:05:19 +00:00
Merge branch 'canary' into fix/docker-cleanup-clears-away-all-unused-residue
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import type { Domain } from "@dokploy/server";
|
||||
import { createDomainLabels } from "@dokploy/server";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Regression tests for Traefik Host rule label format.
|
||||
*
|
||||
* These tests verify that the Host rule is generated with the correct format:
|
||||
* - Host(`domain.com`) - with opening and closing parentheses
|
||||
* - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
|
||||
*
|
||||
* Issue: https://github.com/Dokploy/dokploy/issues/3161
|
||||
* The bug caused Host rules to be malformed as Host`domain.com`)
|
||||
* (missing opening parenthesis) which broke all domain routing.
|
||||
*/
|
||||
describe("Host rule format regression tests", () => {
|
||||
const baseDomain: Domain = {
|
||||
host: "example.com",
|
||||
port: 8080,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
certificateType: "none",
|
||||
applicationId: "",
|
||||
composeId: "",
|
||||
domainType: "compose",
|
||||
serviceName: "test-app",
|
||||
domainId: "",
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
it("should generate Host rule with correct parentheses format", async () => {
|
||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
// Verify exact format: Host(`domain`)
|
||||
expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
|
||||
// Ensure opening parenthesis is present after Host
|
||||
expect(ruleLabel).toContain("Host(`example.com`)");
|
||||
// Ensure it does NOT have the malformed format
|
||||
expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should generate PathPrefix with correct parentheses format", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api" },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
// Verify PathPrefix format
|
||||
expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
|
||||
expect(ruleLabel).toContain("PathPrefix(`/api`)");
|
||||
// Ensure opening parenthesis is present
|
||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should generate combined Host and PathPrefix with correct format", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api/v1" },
|
||||
"websecure",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toBe(
|
||||
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("YAML serialization preserves Host rule format", () => {
|
||||
it("should preserve Host rule format through YAML stringify/parse", async () => {
|
||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
// Simulate compose file structure
|
||||
const composeSpec = {
|
||||
services: {
|
||||
myapp: {
|
||||
image: "nginx",
|
||||
labels: labels,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Stringify to YAML
|
||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||
|
||||
// Parse back
|
||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||
l.includes(".rule="),
|
||||
);
|
||||
|
||||
// Verify format is preserved
|
||||
expect(parsedRuleLabel).toBe(ruleLabel);
|
||||
expect(parsedRuleLabel).toContain("Host(`example.com`)");
|
||||
expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should preserve complex rule format through YAML serialization", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api", https: true },
|
||||
"websecure",
|
||||
);
|
||||
|
||||
const composeSpec = {
|
||||
services: {
|
||||
myapp: {
|
||||
labels: labels,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||
l.includes(".rule="),
|
||||
);
|
||||
|
||||
expect(parsedRuleLabel).toContain(
|
||||
"Host(`example.com`) && PathPrefix(`/api`)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases for domain names", () => {
|
||||
const domainCases = [
|
||||
{ name: "simple domain", host: "example.com" },
|
||||
{ name: "subdomain", host: "app.example.com" },
|
||||
{ name: "deep subdomain", host: "api.v1.app.example.com" },
|
||||
{ name: "numeric domain", host: "123.example.com" },
|
||||
{ name: "hyphenated domain", host: "my-app.example-host.com" },
|
||||
{ name: "localhost", host: "localhost" },
|
||||
{ name: "IP address style", host: "192.168.1.100" },
|
||||
];
|
||||
|
||||
for (const { name, host } of domainCases) {
|
||||
it(`should generate correct Host rule for ${name}: ${host}`, async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, host },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toContain(`Host(\`${host}\`)`);
|
||||
// Verify parenthesis is present
|
||||
expect(ruleLabel).toMatch(
|
||||
new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Multiple domains scenario", () => {
|
||||
it("should generate correct format for both web and websecure entrypoints", async () => {
|
||||
const webLabels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const websecureLabels = await createDomainLabels(
|
||||
"test-app",
|
||||
baseDomain,
|
||||
"websecure",
|
||||
);
|
||||
|
||||
const webRule = webLabels.find((l) => l.includes(".rule="));
|
||||
const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
|
||||
|
||||
// Both should have correct format
|
||||
expect(webRule).toContain("Host(`example.com`)");
|
||||
expect(websecureRule).toContain("Host(`example.com`)");
|
||||
|
||||
// Neither should have malformed format
|
||||
expect(webRule).not.toMatch(/Host`[^`]+`\)/);
|
||||
expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Special characters in paths", () => {
|
||||
const pathCases = [
|
||||
{ name: "simple path", path: "/api" },
|
||||
{ name: "nested path", path: "/api/v1/users" },
|
||||
{ name: "path with hyphen", path: "/api-v1" },
|
||||
{ name: "path with underscore", path: "/api_v1" },
|
||||
];
|
||||
|
||||
for (const { name, path } of pathCases) {
|
||||
it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
|
||||
// Verify parenthesis is present
|
||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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).mockResolvedValue(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).mockResolvedValue(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).mockResolvedValue(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).mockResolvedValue(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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
@@ -30,6 +30,10 @@ const baseApp: ApplicationNested = {
|
||||
previewLabels: [],
|
||||
herokuVersion: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
args: [],
|
||||
giteaBuildPath: "",
|
||||
previewRequireCollaboratorPermissions: false,
|
||||
giteaId: "",
|
||||
@@ -37,6 +41,9 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
cleanCache: false,
|
||||
watchPaths: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
enableSubmodules: false,
|
||||
applicationStatus: "done",
|
||||
triggerType: "push",
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||
|
||||
type MockCreateServiceOptions = {
|
||||
StopGracePeriod?: number;
|
||||
TaskTemplate?: {
|
||||
ContainerSpec?: {
|
||||
StopGracePeriod?: number;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -82,8 +86,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 () => {
|
||||
@@ -97,6 +103,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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,15 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
giteaOwner: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
giteaBuildPath: "",
|
||||
giteaId: "",
|
||||
args: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
cleanCache: false,
|
||||
applicationStatus: "done",
|
||||
endpointSpecSwarm: null,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -373,7 +373,19 @@ export const ShowDeployments = ({
|
||||
type === "application" && (
|
||||
<DialogAction
|
||||
title="Rollback to this deployment"
|
||||
description="Are you sure you want to rollback to this deployment?"
|
||||
description={
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
Are you sure you want to rollback to this
|
||||
deployment?
|
||||
</p>
|
||||
<AlertBlock type="info" className="text-sm">
|
||||
Please wait a few seconds while the image is
|
||||
pulled from the registry. Your application
|
||||
should be running shortly.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
}
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await rollback({
|
||||
@@ -407,7 +419,7 @@ export const ShowDeployments = ({
|
||||
</div>
|
||||
)}
|
||||
<ShowDeployment
|
||||
serverId={serverId}
|
||||
serverId={activeLog?.buildServerId || serverId}
|
||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog?.logPath || ""}
|
||||
|
||||
@@ -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"
|
||||
|
||||
+104
-4
@@ -1,5 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -20,13 +21,37 @@ import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
rollbackActive: z.boolean(),
|
||||
});
|
||||
const formSchema = z
|
||||
.object({
|
||||
rollbackActive: z.boolean(),
|
||||
rollbackRegistryId: z.string().optional(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (
|
||||
values.rollbackActive &&
|
||||
(!values.rollbackRegistryId || values.rollbackRegistryId === "none")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["rollbackRegistryId"],
|
||||
message: "Registry is required when rollbacks are enabled",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -49,17 +74,33 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
const { mutateAsync: updateApplication, isLoading } =
|
||||
api.application.update.useMutation();
|
||||
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
rollbackActive: application?.rollbackActive ?? false,
|
||||
rollbackRegistryId: application?.rollbackRegistryId || "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (application) {
|
||||
form.reset({
|
||||
rollbackActive: application.rollbackActive ?? false,
|
||||
rollbackRegistryId: application.rollbackRegistryId || "",
|
||||
});
|
||||
}
|
||||
}, [application, form]);
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
rollbackActive: data.rollbackActive,
|
||||
rollbackRegistryId:
|
||||
data.rollbackRegistryId === "none" || !data.rollbackRegistryId
|
||||
? null
|
||||
: data.rollbackRegistryId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Rollback settings updated");
|
||||
@@ -112,6 +153,65 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("rollbackActive") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rollbackRegistryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Rollback 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>
|
||||
{!registries || registries.length === 0 ? (
|
||||
<FormDescription className="text-amber-600 dark:text-amber-500">
|
||||
No registries available. Please{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/registry"
|
||||
className="underline font-medium hover:text-amber-700 dark:hover:text-amber-400"
|
||||
>
|
||||
configure a registry
|
||||
</Link>{" "}
|
||||
first to enable rollbacks.
|
||||
</FormDescription>
|
||||
) : (
|
||||
<FormDescription>
|
||||
Select a registry where rollback images will be stored.
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
Save Settings
|
||||
</Button>
|
||||
|
||||
+9
-3
@@ -10,7 +10,7 @@ import { DockerNetworkChart } from "./docker-network-chart";
|
||||
|
||||
const defaultData = {
|
||||
cpu: {
|
||||
value: 0,
|
||||
value: "0%",
|
||||
time: "",
|
||||
},
|
||||
memory: {
|
||||
@@ -46,7 +46,7 @@ interface Props {
|
||||
}
|
||||
export interface DockerStats {
|
||||
cpu: {
|
||||
value: number;
|
||||
value: string;
|
||||
time: string;
|
||||
};
|
||||
memory: {
|
||||
@@ -220,7 +220,13 @@ export const ContainerFreeMonitoring = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Used: {currentData.cpu.value}
|
||||
</span>
|
||||
<Progress value={currentData.cpu.value} className="w-[100%]" />
|
||||
<Progress
|
||||
value={Number.parseInt(
|
||||
currentData.cpu.value.replace("%", ""),
|
||||
10,
|
||||
)}
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,13 +51,38 @@ 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());
|
||||
|
||||
// Check if logs exist to determine if traefik has been reloaded
|
||||
// Only fetch when active to minimize network calls
|
||||
const { data: statsLogsCheck } = api.settings.readStatsLogs.useQuery(
|
||||
{
|
||||
page: {
|
||||
pageIndex: 0,
|
||||
pageSize: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!isActive,
|
||||
refetchInterval: 5000, // Check every 5 seconds when active
|
||||
},
|
||||
);
|
||||
|
||||
// Determine if warning should be shown
|
||||
// Show warning only if active but no logs exist yet
|
||||
const shouldShowWarning = isActive && (statsLogsCheck?.totalCount ?? 0) === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (logCleanupStatus) {
|
||||
@@ -79,16 +104,18 @@ export const ShowRequests = () => {
|
||||
See all the incoming requests that pass trough Traefik
|
||||
</CardDescription>
|
||||
|
||||
<AlertBlock type="warning">
|
||||
When you activate, you need to reload traefik to apply the
|
||||
changes, you can reload traefik in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
{shouldShowWarning && (
|
||||
<AlertBlock type="warning">
|
||||
When you activate, you need to reload traefik to apply the
|
||||
changes, you can reload traefik in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<div className="flex w-full gap-4 justify-end items-center">
|
||||
@@ -169,17 +196,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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
type: z.literal("ntfy"),
|
||||
serverUrl: z.string().min(1, { message: "Server URL is required" }),
|
||||
topic: z.string().min(1, { message: "Topic is required" }),
|
||||
accessToken: z.string().min(1, { message: "Access Token is required" }),
|
||||
accessToken: z.string().optional(),
|
||||
priority: z.number().min(1).max(5).default(3),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
@@ -303,7 +303,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
type: notification.notificationType,
|
||||
accessToken: notification.ntfy?.accessToken,
|
||||
accessToken: notification.ntfy?.accessToken || "",
|
||||
topic: notification.ntfy?.topic,
|
||||
priority: notification.ntfy?.priority,
|
||||
serverUrl: notification.ntfy?.serverUrl,
|
||||
@@ -432,7 +432,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
serverUrl: data.serverUrl,
|
||||
accessToken: data.accessToken,
|
||||
accessToken: data.accessToken || "",
|
||||
topic: data.topic,
|
||||
priority: data.priority,
|
||||
name: data.name,
|
||||
@@ -1001,8 +1001,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
<Input
|
||||
placeholder="AzxcvbnmKjhgfdsa..."
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. Leave blank for public topics.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -1214,56 +1218,62 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingLark
|
||||
}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
|
||||
try {
|
||||
if (type === "slack") {
|
||||
if (data.type === "slack") {
|
||||
await testSlackConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
channel: form.getValues("channel"),
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel,
|
||||
});
|
||||
} else if (type === "telegram") {
|
||||
} else if (data.type === "telegram") {
|
||||
await testTelegramConnection({
|
||||
botToken: form.getValues("botToken"),
|
||||
chatId: form.getValues("chatId"),
|
||||
messageThreadId: form.getValues("messageThreadId") || "",
|
||||
botToken: data.botToken,
|
||||
chatId: data.chatId,
|
||||
messageThreadId: data.messageThreadId || "",
|
||||
});
|
||||
} else if (type === "discord") {
|
||||
} else if (data.type === "discord") {
|
||||
await testDiscordConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
decoration: form.getValues("decoration"),
|
||||
webhookUrl: data.webhookUrl,
|
||||
decoration: data.decoration,
|
||||
});
|
||||
} else if (type === "email") {
|
||||
} else if (data.type === "email") {
|
||||
await testEmailConnection({
|
||||
smtpServer: form.getValues("smtpServer"),
|
||||
smtpPort: form.getValues("smtpPort"),
|
||||
username: form.getValues("username"),
|
||||
password: form.getValues("password"),
|
||||
toAddresses: form.getValues("toAddresses"),
|
||||
fromAddress: form.getValues("fromAddress"),
|
||||
smtpServer: data.smtpServer,
|
||||
smtpPort: data.smtpPort,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
});
|
||||
} else if (type === "gotify") {
|
||||
} else if (data.type === "gotify") {
|
||||
await testGotifyConnection({
|
||||
serverUrl: form.getValues("serverUrl"),
|
||||
appToken: form.getValues("appToken"),
|
||||
priority: form.getValues("priority"),
|
||||
decoration: form.getValues("decoration"),
|
||||
serverUrl: data.serverUrl,
|
||||
appToken: data.appToken,
|
||||
priority: data.priority,
|
||||
decoration: data.decoration,
|
||||
});
|
||||
} else if (type === "ntfy") {
|
||||
} else if (data.type === "ntfy") {
|
||||
await testNtfyConnection({
|
||||
serverUrl: form.getValues("serverUrl"),
|
||||
topic: form.getValues("topic"),
|
||||
accessToken: form.getValues("accessToken"),
|
||||
priority: form.getValues("priority"),
|
||||
serverUrl: data.serverUrl,
|
||||
topic: data.topic,
|
||||
accessToken: data.accessToken || "",
|
||||
priority: data.priority,
|
||||
});
|
||||
} else if (type === "lark") {
|
||||
} else if (data.type === "lark") {
|
||||
await testLarkConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
webhookUrl: data.webhookUrl,
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Error testing the provider ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
`Error testing the provider: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||
+40
-7
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -85,7 +87,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
</EditTraefikEnv>
|
||||
|
||||
<DropdownMenuItem
|
||||
<DialogAction
|
||||
title={
|
||||
haveTraefikDashboardPortEnabled
|
||||
? "Disable Traefik Dashboard"
|
||||
: "Enable Traefik Dashboard"
|
||||
}
|
||||
description={
|
||||
<div className="space-y-4">
|
||||
<AlertBlock type="warning">
|
||||
The Traefik container will be recreated from scratch. This
|
||||
means the container will be deleted and created again, which
|
||||
may cause downtime in your applications.
|
||||
</AlertBlock>
|
||||
<p>
|
||||
Are you sure you want to{" "}
|
||||
{haveTraefikDashboardPortEnabled ? "disable" : "enable"} the
|
||||
Traefik dashboard?
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
onClick={async () => {
|
||||
await toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
@@ -97,14 +118,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
);
|
||||
refetchDashboard();
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
}}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
disabled={toggleDashboardIsLoading}
|
||||
type="default"
|
||||
>
|
||||
<span>
|
||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
>
|
||||
<span>
|
||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
|
||||
Dashboard
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
<ManageTraefikPorts serverId={serverId}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -105,7 +105,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
});
|
||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||
setOpen(false);
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "Error updating Traefik ports");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -156,11 +158,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<ScrollArea className="pr-4">
|
||||
<div className="grid gap-4">
|
||||
{fields.map((field, index) => (
|
||||
<Card key={field.id} className="bg-transparent">
|
||||
<CardContent className="grid grid-cols-[1fr_1fr_1fr_auto] gap-4 p-4 transparent">
|
||||
<CardContent className="grid grid-cols-4 gap-4 p-4 transparent">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ports.${index}.targetPort`}
|
||||
@@ -303,6 +305,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
</div>
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<AlertBlock type="warning">
|
||||
The Traefik container will be recreated from scratch. This
|
||||
means the container will be deleted and created again, which
|
||||
may cause downtime in your applications.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -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;
|
||||
@@ -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[];
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "ntfy" ALTER COLUMN "accessToken" DROP NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "application" ADD COLUMN "rollbackRegistryId" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD CONSTRAINT "application_rollbackRegistryId_registry_registryId_fk" FOREIGN KEY ("rollbackRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -855,6 +855,34 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 125,
|
||||
"version": "7",
|
||||
"when": 1764573207555,
|
||||
"tag": "0125_neat_the_phantom",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,9 +6,6 @@
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.25.11",
|
||||
"version": "v0.26.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -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,14 +114,14 @@
|
||||
"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",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "^15.3.2",
|
||||
"next": "^16.0.7",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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} />)}
|
||||
|
||||
+2
-1
@@ -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} />
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
cleanupImages,
|
||||
cleanupVolumes,
|
||||
cleanupAll,
|
||||
checkPortInUse,
|
||||
DEFAULT_UPDATE_DATA,
|
||||
execAsync,
|
||||
findServerById,
|
||||
@@ -131,6 +132,17 @@ export const settingsRouter = createTRPCRouter({
|
||||
let newPorts = ports;
|
||||
// If receive true, add 8080 to ports
|
||||
if (input.enableDashboard) {
|
||||
// Check if port 8080 is already in use before enabling dashboard
|
||||
const portCheck = await checkPortInUse(8080, input.serverId);
|
||||
if (portCheck.isInUse) {
|
||||
const conflictingContainer = portCheck.conflictingContainer
|
||||
? ` by container "${portCheck.conflictingContainer}"`
|
||||
: "";
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
|
||||
});
|
||||
}
|
||||
newPorts.push({
|
||||
targetPort: 8080,
|
||||
publishedPort: 8080,
|
||||
@@ -808,6 +820,19 @@ export const settingsRouter = createTRPCRouter({
|
||||
"dokploy-traefik",
|
||||
input?.serverId,
|
||||
);
|
||||
|
||||
for (const port of input.additionalPorts) {
|
||||
const portCheck = await checkPortInUse(
|
||||
port.publishedPort,
|
||||
input.serverId,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${port.targetPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const preparedEnv = prepareEnvironmentVariables(env);
|
||||
|
||||
await writeTraefikSetup({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -8,21 +8,22 @@
|
||||
"resolveJsonModule": true,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"checkJs": true,
|
||||
|
||||
/* Bundled projects */
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"jsx": "react-jsx",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"incremental": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
@@ -30,7 +31,6 @@
|
||||
"@dokploy/server/*": ["../../packages/server/src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
|
||||
Reference in New Issue
Block a user