Merge branch 'canary' into feat/add-mattermost-notification-provider

Resolves merge conflicts between mattermost notification provider (this PR)
and pushover/custom/lark notification providers (from canary).

All notification providers are now included:
- slack, telegram, discord, email, gotify, ntfy
- mattermost (this PR)
- pushover, custom, lark (from canary)
This commit is contained in:
Hootan
2026-01-30 23:51:09 +01:00
343 changed files with 164082 additions and 8184 deletions
-1
View File
@@ -13,7 +13,6 @@
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0",
"@nerimity/mimiqueue": "1.2.3",
"dotenv": "^16.4.5",
"hono": "^4.7.10",
"pino": "9.4.0",
+1 -1
View File
@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy"]),
type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("application-preview"),
serverId: z.string().min(1),
}),
+19 -11
View File
@@ -1,9 +1,10 @@
import {
deployRemoteApplication,
deployRemoteCompose,
deployRemotePreviewApplication,
rebuildRemoteApplication,
rebuildRemoteCompose,
deployApplication,
deployCompose,
deployPreviewApplication,
rebuildApplication,
rebuildCompose,
rebuildPreviewApplication,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
@@ -16,13 +17,13 @@ export const deploy = async (job: DeployJob) => {
await updateApplicationStatus(job.applicationId, "running");
if (job.server) {
if (job.type === "redeploy") {
await rebuildRemoteApplication({
await rebuildApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployRemoteApplication({
await deployApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
@@ -36,13 +37,13 @@ export const deploy = async (job: DeployJob) => {
if (job.server) {
if (job.type === "redeploy") {
await rebuildRemoteCompose({
await rebuildCompose({
composeId: job.composeId,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployRemoteCompose({
await deployCompose({
composeId: job.composeId,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
@@ -54,8 +55,15 @@ export const deploy = async (job: DeployJob) => {
previewStatus: "running",
});
if (job.server) {
if (job.type === "deploy") {
await deployRemotePreviewApplication({
if (job.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Rebuild Preview Deployment",
descriptionLog: job.descriptionLog || "",
previewDeploymentId: job.previewDeploymentId,
});
} else if (job.type === "deploy") {
await deployPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Preview Deployment",
descriptionLog: job.descriptionLog || "",
+1 -1
View File
@@ -1,3 +1,3 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
PORT=3000
NODE_ENV=development
NODE_ENV=development
-1
View File
@@ -1,3 +1,2 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy"
PORT=3000
NODE_ENV=production
@@ -0,0 +1,243 @@
import type { Registry } from "@dokploy/server";
import { getRegistryTag } from "@dokploy/server";
import { describe, expect, it } from "vitest";
describe("getRegistryTag", () => {
// Helper to create a mock registry
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry => {
return {
registryId: "test-registry-id",
registryName: "Test Registry",
username: "myuser",
password: "test-password",
registryUrl: "docker.io",
registryType: "cloud",
imagePrefix: null,
createdAt: new Date().toISOString(),
organizationId: "test-org-id",
...overrides,
};
};
describe("with username (no imagePrefix)", () => {
it("should handle simple image name without tag", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/myuser/nginx");
});
it("should handle image name with tag", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/myuser/nginx:latest");
});
it("should handle image name with username already present (no duplication)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("docker.io/myuser/myprivaterepo");
});
it("should handle image name with username and tag already present", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
// Should not duplicate username
expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
});
it("should handle complex image name with username", () => {
const registry = createMockRegistry({ username: "siumauricio" });
const result = getRegistryTag(
registry,
"siumauricio/app-parse-multi-byte-port-e32uh7",
);
// Should not duplicate username
expect(result).toBe(
"docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
);
});
it("should handle image name with different username (should not duplicate)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "otheruser/myprivaterepo");
expect(result).toBe("docker.io/myuser/myprivaterepo");
});
it("should handle image name with full registry URL (no username)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "docker.io/nginx");
// Should add username since imageName doesn't have one
expect(result).toBe("docker.io/myuser/nginx");
});
it("should handle image name with custom registry URL and username", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
// Should not duplicate username even if registry URL is different
expect(result).toBe("docker.io/myuser/repo");
});
it("should handle image name with custom registry URL (different username)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
// Should use registry username, not the one in imageName
expect(result).toBe("docker.io/myuser/repo");
});
});
describe("with imagePrefix", () => {
it("should use imagePrefix instead of username", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/myorg/nginx");
});
it("should use imagePrefix with image tag", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/myorg/nginx:latest");
});
it("should handle imagePrefix with username already in image name", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
expect(result).toBe("docker.io/myorg/myprivaterepo");
});
it("should handle imagePrefix matching image name prefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "myorg/myprivaterepo");
// Should not duplicate prefix
expect(result).toBe("docker.io/myorg/myprivaterepo");
});
});
describe("without registryUrl", () => {
it("should work without registryUrl", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("myuser/nginx");
});
it("should work without registryUrl with imagePrefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
registryUrl: "",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("myorg/nginx");
});
it("should handle username already present without registryUrl", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("myuser/myprivaterepo");
});
});
describe("with custom registryUrl", () => {
it("should handle custom registry URL", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("ghcr.io/myuser/nginx");
});
it("should handle custom registry URL with imagePrefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("ghcr.io/myorg/nginx");
});
it("should handle custom registry URL with username already present", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("ghcr.io/myuser/myprivaterepo");
});
});
describe("edge cases", () => {
it("should handle empty image name", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "");
expect(result).toBe("docker.io/myuser/");
});
it("should handle image name with multiple slashes", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "org/suborg/repo");
expect(result).toBe("docker.io/myuser/repo");
});
it("should handle image name with username at different position", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "org/myuser/repo");
expect(result).toBe("docker.io/myuser/repo");
});
});
describe("special characters in username", () => {
it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
const registry = createMockRegistry({
username: "robot$library+dokploy",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/robot$library+dokploy/nginx");
});
it("should handle username with $ and other special characters", () => {
const registry = createMockRegistry({
username: "robot$test+app",
});
const result = getRegistryTag(registry, "myapp:latest");
expect(result).toBe("docker.io/robot$test+app/myapp:latest");
});
it("should handle username with multiple $ symbols", () => {
const registry = createMockRegistry({
username: "user$name$test",
});
const result = getRegistryTag(registry, "app");
expect(result).toBe("docker.io/user$name$test/app");
});
it("should handle username with + and - symbols", () => {
const registry = createMockRegistry({
username: "robot+test-user",
});
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/robot+test-user/nginx:latest");
});
});
});
@@ -0,0 +1,215 @@
import type { Domain } from "@dokploy/server";
import { createDomainLabels } from "@dokploy/server";
import { describe, expect, it } from "vitest";
import { parse, stringify } from "yaml";
/**
* 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,
);
+311 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
import {
extractCommitMessage,
extractImageName,
extractImageTag,
extractImageTagFromRequest,
} from "@/pages/api/deploy/[refreshToken]";
describe("GitHub Webhook Skip CI", () => {
const mockGithubHeaders = {
@@ -96,3 +101,308 @@ describe("GitHub Webhook Skip CI", () => {
);
});
});
describe("GitHub Packages Docker Image Tag Extraction", () => {
it("should extract tag from container_metadata", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "v1.0.0",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:v1.0.0",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("v1.0.0");
});
it("should extract tag from package_url when container_metadata tag matches version", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "sha256:abc123...",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("latest");
});
it("should extract tag from package_url when container_metadata is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo:1.2.3",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("1.2.3");
});
it("should handle different tag formats in package_url", () => {
const headers = { "x-github-event": "registry_package" };
const testCases = [
{ url: "ghcr.io/owner/repo:latest", expected: "latest" },
{ url: "ghcr.io/owner/repo:v1.0.0", expected: "v1.0.0" },
{ url: "ghcr.io/owner/repo:1.2.3", expected: "1.2.3" },
{ url: "ghcr.io/owner/repo:dev", expected: "dev" },
];
for (const testCase of testCases) {
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: testCase.url,
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe(testCase.expected);
}
});
it("should return null for non-registry_package events", () => {
const headers = { "x-github-event": "push" };
const body = {
registry_package: {
package_version: {
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_version is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_url has no tag", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_url ends with colon (no tag)", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo:",
container_metadata: {
tag: {
name: "",
digest: "sha256:abc123...",
},
},
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when tag name is empty string", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should ignore tag if it matches the version (digest)", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "sha256:abc123...",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("latest");
});
it("should handle registry_package commit message with package_url", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("Docker GHCR image pushed: ghcr.io/owner/repo:latest");
});
it("should handle registry_package commit message when package_url is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
},
},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("Docker GHCR image pushed");
});
it("should handle registry_package commit message when package_version is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("NEW COMMIT");
});
});
describe("Docker Image Name and Tag Extraction", () => {
describe("extractImageName", () => {
it("should return image name without tag", () => {
expect(extractImageName("my-image:latest")).toBe("my-image");
expect(extractImageName("my-image:1.0.0")).toBe("my-image");
expect(extractImageName("ghcr.io/owner/repo:latest")).toBe(
"ghcr.io/owner/repo",
);
});
it("should return full image name when no tag is present", () => {
expect(extractImageName("my-image")).toBe("my-image");
expect(extractImageName("ghcr.io/owner/repo")).toBe("ghcr.io/owner/repo");
});
it("should handle images with port numbers correctly", () => {
expect(extractImageName("registry:5000/image:tag")).toBe(
"registry:5000/image",
);
expect(extractImageName("localhost:5000/my-app:latest")).toBe(
"localhost:5000/my-app",
);
});
it("should handle complex image paths", () => {
expect(
extractImageName("myregistryhost:5000/fedora/httpd:version1.0"),
).toBe("myregistryhost:5000/fedora/httpd");
expect(extractImageName("registry.example.com:8080/ns/app:v1.2.3")).toBe(
"registry.example.com:8080/ns/app",
);
});
it("should return null for invalid inputs", () => {
expect(extractImageName(null)).toBeNull();
expect(extractImageName("")).toBeNull();
});
it("should handle edge cases with multiple colons", () => {
expect(extractImageName("image:tag:extra")).toBe("image:tag");
expect(extractImageName("registry:5000:invalid")).toBe("registry:5000");
});
});
describe("extractImageTag", () => {
it("should extract tag from image with tag", () => {
expect(extractImageTag("my-image:latest")).toBe("latest");
expect(extractImageTag("my-image:1.0.0")).toBe("1.0.0");
expect(extractImageTag("ghcr.io/owner/repo:v1.2.3")).toBe("v1.2.3");
});
it("should return 'latest' when no tag is present", () => {
expect(extractImageTag("my-image")).toBe("latest");
expect(extractImageTag("ghcr.io/owner/repo")).toBe("latest");
});
it("should handle complex image paths with tags", () => {
expect(
extractImageTag("myregistryhost:5000/fedora/httpd:version1.0"),
).toBe("version1.0");
expect(extractImageTag("registry.example.com:8080/ns/app:v1.2.3")).toBe(
"v1.2.3",
);
});
it("should return null for invalid inputs", () => {
expect(extractImageTag(null)).toBeNull();
expect(extractImageTag("")).toBeNull();
});
it("should handle edge cases with multiple colons", () => {
expect(extractImageTag("image:tag:extra")).toBe("extra");
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
});
it("should handle numeric tags", () => {
expect(extractImageTag("my-image:123")).toBe("123");
expect(extractImageTag("my-image:1")).toBe("1");
});
});
});
+12 -1
View File
@@ -25,11 +25,17 @@ if (typeof window === "undefined") {
}
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
railpackVersion: "0.15.4",
applicationId: "",
previewLabels: [],
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
args: [],
giteaBuildPath: "",
previewRequireCollaboratorPermissions: false,
giteaId: "",
@@ -37,11 +43,15 @@ const baseApp: ApplicationNested = {
giteaRepository: "",
cleanCache: false,
watchPaths: [],
rollbackRegistryId: "",
rollbackRegistry: null,
deployments: [],
enableSubmodules: false,
applicationStatus: "done",
triggerType: "push",
appName: "",
autoDeploy: true,
endpointSpecSwarm: null,
serverId: "",
registryUrl: "",
branch: null,
@@ -59,6 +69,7 @@ const baseApp: ApplicationNested = {
previewWildcard: "",
environment: {
env: "",
isDefault: false,
environmentId: "",
name: "",
createdAt: "",
@@ -0,0 +1,294 @@
import { describe, expect, it } from "vitest";
// Type definitions matching the project structure
type Environment = {
environmentId: string;
name: string;
isDefault: boolean;
};
type Project = {
projectId: string;
name: string;
environments: Environment[];
};
/**
* Helper function that selects the appropriate environment for a user
* This matches the logic used in search-command.tsx and show.tsx
*/
function selectAccessibleEnvironment(
project: Project | null | undefined,
): Environment | null {
if (!project || !project.environments || project.environments.length === 0) {
return null;
}
// Find default environment from accessible environments, or fall back to first accessible environment
const defaultEnvironment =
project.environments.find((environment) => environment.isDefault) ||
project.environments[0];
return defaultEnvironment || null;
}
describe("Environment Access Fallback", () => {
describe("selectAccessibleEnvironment", () => {
it("should return default environment when user has access to it", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-prod",
name: "production",
isDefault: true,
},
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-prod");
expect(result?.isDefault).toBe(true);
});
it("should return first accessible environment when user doesn't have access to default", () => {
// Simulating filtered environments (user only has access to development)
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
// Note: production is not in the list because user doesn't have access
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev");
expect(result?.name).toBe("development");
});
it("should return first environment when no default is marked but environments exist", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev");
});
it("should return null when project has no accessible environments", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [],
};
const result = selectAccessibleEnvironment(project);
expect(result).toBeNull();
});
it("should return null when project is null", () => {
const result = selectAccessibleEnvironment(null);
expect(result).toBeNull();
});
it("should return null when project is undefined", () => {
const result = selectAccessibleEnvironment(undefined);
expect(result).toBeNull();
});
it("should handle project with single accessible environment", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev");
});
it("should prioritize default environment even when it's not first in the array", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
{
environmentId: "env-prod",
name: "production",
isDefault: true,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-prod");
expect(result?.isDefault).toBe(true);
});
it("should handle multiple default environments by returning the first one found", () => {
// Edge case: multiple environments marked as default (shouldn't happen, but test it)
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-prod-1",
name: "production-1",
isDefault: true,
},
{
environmentId: "env-prod-2",
name: "production-2",
isDefault: true,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.isDefault).toBe(true);
// Should return the first default found
expect(result?.environmentId).toBe("env-prod-1");
});
it("should work correctly when user has access to multiple environments including default", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-prod",
name: "production",
isDefault: true,
},
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-prod");
expect(result?.isDefault).toBe(true);
});
it("should handle real-world scenario: user with only development access", () => {
// This simulates the exact bug we're fixing:
// User has access to development but not production (default)
// The filtered environments array only contains development
const project: Project = {
projectId: "proj-1",
name: "My Project",
environments: [
// Only development is accessible (production was filtered out)
{
environmentId: "env-dev-123",
name: "development",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev-123");
expect(result?.name).toBe("development");
// Should not be null even though it's not the default
});
});
describe("Environment selection edge cases", () => {
it("should handle project with environments property as undefined", () => {
const project = {
projectId: "proj-1",
name: "Test Project",
environments: undefined,
} as unknown as Project;
const result = selectAccessibleEnvironment(project);
expect(result).toBeNull();
});
it("should handle project with null environments array", () => {
const project = {
projectId: "proj-1",
name: "Test Project",
environments: null,
} as unknown as Project;
const result = selectAccessibleEnvironment(project);
expect(result).toBeNull();
});
});
});
+310 -1
View File
@@ -1,4 +1,7 @@
import { prepareEnvironmentVariables } from "@dokploy/server/index";
import {
prepareEnvironmentVariables,
prepareEnvironmentVariablesForShell,
} from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
@@ -332,4 +335,310 @@ IS_DEV=\${{environment.DEVELOPMENT}}
"IS_DEV=0",
]);
});
it("handles environment variables with single quotes in values", () => {
const envWithSingleQuotes = `
ENV_VARIABLE='ENVITONME'NT'
ANOTHER_VAR='value with 'quotes' inside'
SIMPLE_VAR=no-quotes
`;
const serviceWithSingleQuotes = `
TEST_VAR=\${{environment.ENV_VARIABLE}}
ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
SIMPLE=\${{environment.SIMPLE_VAR}}
`;
const resolved = prepareEnvironmentVariables(
serviceWithSingleQuotes,
"",
envWithSingleQuotes,
);
expect(resolved).toEqual([
"TEST_VAR=ENVITONME'NT",
"ANOTHER_TEST=value with 'quotes' inside",
"SIMPLE=no-quotes",
]);
});
});
describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
it("escapes single quotes in environment variable values", () => {
const serviceEnv = `
ENV_VARIABLE='ENVITONME'NT'
ANOTHER_VAR='value with 'quotes' inside'
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote should wrap these in double quotes
expect(resolved).toEqual([
`"ENV_VARIABLE=ENVITONME'NT"`,
`"ANOTHER_VAR=value with 'quotes' inside"`,
]);
});
it("escapes double quotes in environment variable values", () => {
const serviceEnv = `
MESSAGE="Hello "World""
QUOTED_PATH="/path/to/"file""
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote wraps in single quotes when there are double quotes inside
expect(resolved).toEqual([
`'MESSAGE=Hello "World"'`,
`'QUOTED_PATH=/path/to/"file"'`,
]);
});
it("escapes dollar signs in environment variable values", () => {
const serviceEnv = `
PRICE=$100
VARIABLE=$HOME/path
TEMPLATE=Hello $USER
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Dollar signs should be escaped to prevent variable expansion
for (const env of resolved) {
expect(env).toContain("$");
}
});
it("escapes backticks in environment variable values", () => {
const serviceEnv = `
COMMAND=\`echo "test"\`
NESTED=value with \`backticks\` inside
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
expect(resolved.length).toBe(2);
expect(resolved[0]).toContain("COMMAND");
expect(resolved[1]).toContain("NESTED");
});
it("handles environment variables with spaces", () => {
const serviceEnv = `
FULL_NAME="John Doe"
MESSAGE='Hello World'
SENTENCE=This is a test
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote uses single quotes for strings with spaces
expect(resolved).toEqual([
`'FULL_NAME=John Doe'`,
`'MESSAGE=Hello World'`,
`'SENTENCE=This is a test'`,
]);
});
it("handles environment variables with backslashes", () => {
const serviceEnv = `
WINDOWS_PATH=C:\\Users\\Documents
ESCAPED=value\\with\\backslashes
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Backslashes should be properly escaped
expect(resolved.length).toBe(2);
for (const env of resolved) {
expect(env).toContain("\\");
}
});
it("handles simple environment variables without special characters", () => {
const serviceEnv = `
NODE_ENV=production
PORT=3000
DEBUG=true
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote escapes the = sign in some cases
expect(resolved).toEqual([
"NODE_ENV\\=production",
"PORT\\=3000",
"DEBUG\\=true",
]);
});
it("handles environment variables with mixed special characters", () => {
const serviceEnv = `
COMPLEX='value with "double" and 'single' quotes'
BASH_COMMAND=echo "$HOME" && echo 'test'
WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// All should be escaped, none should throw errors
expect(resolved.length).toBe(3);
// Verify each can be safely used in shell
for (const env of resolved) {
expect(typeof env).toBe("string");
expect(env.length).toBeGreaterThan(0);
}
});
it("handles environment variables with newlines", () => {
const serviceEnv = `
MULTILINE="line1
line2
line3"
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(1);
expect(resolved[0]).toContain("MULTILINE");
});
it("handles empty environment variable values", () => {
const serviceEnv = `
EMPTY=
EMPTY_QUOTED=""
EMPTY_SINGLE=''
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote escapes the = sign for empty values
expect(resolved).toEqual([
"EMPTY\\=",
"EMPTY_QUOTED\\=",
"EMPTY_SINGLE\\=",
]);
});
it("handles environment variables with equals signs in values", () => {
const serviceEnv = `
EQUATION=a=b+c
CONNECTION_STRING=user=admin;password=test
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(2);
expect(resolved[0]).toContain("EQUATION");
expect(resolved[1]).toContain("CONNECTION_STRING");
});
it("resolves and escapes environment variables together", () => {
const projectEnv = `
BASE_URL=https://example.com
API_KEY='secret-key-with-quotes'
`;
const environmentEnv = `
ENV_NAME=production
DB_PASS='pa$$word'
`;
const serviceEnv = `
FULL_URL=\${{project.BASE_URL}}/api
AUTH_KEY=\${{project.API_KEY}}
ENVIRONMENT=\${{environment.ENV_NAME}}
DB_PASSWORD=\${{environment.DB_PASS}}
CUSTOM='value with 'quotes' inside'
`;
const resolved = prepareEnvironmentVariablesForShell(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(resolved.length).toBe(5);
// All resolved values should be properly escaped
for (const env of resolved) {
expect(typeof env).toBe("string");
}
});
it("handles environment variables with semicolons and ampersands", () => {
const serviceEnv = `
COMMAND=echo "test" && echo "test2"
MULTIPLE=cmd1; cmd2; cmd3
URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
// These should be safely escaped to prevent command injection
for (const env of resolved) {
expect(typeof env).toBe("string");
expect(env.length).toBeGreaterThan(0);
}
});
it("handles environment variables with pipes and redirects", () => {
const serviceEnv = `
PIPE_COMMAND=cat file | grep test
REDIRECT=echo "test" > output.txt
BOTH=cat input.txt | grep pattern > output.txt
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
// Pipes and redirects should be safely quoted
expect(resolved[0]).toContain("PIPE_COMMAND");
expect(resolved[1]).toContain("REDIRECT");
expect(resolved[2]).toContain("BOTH");
// At least one should contain a pipe
const hasPipe = resolved.some((env) => env.includes("|"));
expect(hasPipe).toBe(true);
});
it("handles environment variables with parentheses and brackets", () => {
const serviceEnv = `
MATH=(a+b)*c
ARRAY=[1,2,3]
JSON={"key":"value"}
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
expect(resolved[0]).toContain("(");
expect(resolved[1]).toContain("[");
expect(resolved[2]).toContain("{");
});
it("handles very long environment variable values", () => {
const longValue = "a".repeat(10000);
const serviceEnv = `LONG_VAR=${longValue}`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(1);
expect(resolved[0]).toContain("LONG_VAR");
expect(resolved[0]?.length).toBeGreaterThan(10000);
});
it("handles special unicode characters in environment variables", () => {
const serviceEnv = `
EMOJI=Hello 🌍 World 🚀
CHINESE=你好世界
SPECIAL=café résumé naïve
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
expect(resolved[0]).toContain("🌍");
expect(resolved[1]).toContain("你好");
expect(resolved[2]).toContain("café");
});
});
+184
View File
@@ -0,0 +1,184 @@
import { getEnviromentVariablesObject } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
PORT=3000
`;
const environmentEnv = `
NODE_ENV=development
API_URL=https://api.dev.example.com
REDIS_URL=redis://localhost:6379
DATABASE_NAME=dev_database
SECRET_KEY=env-secret-123
`;
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
it("resolves environment variables correctly for Stack compose", () => {
const serviceEnv = `
FOO=\${{environment.NODE_ENV}}
BAR=\${{environment.API_URL}}
BAZ=test
`;
const result = getEnviromentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
FOO: "development",
BAR: "https://api.dev.example.com",
BAZ: "test",
});
});
it("resolves both project and environment variables for Stack compose", () => {
const serviceEnv = `
ENVIRONMENT=\${{project.ENVIRONMENT}}
NODE_ENV=\${{environment.NODE_ENV}}
API_URL=\${{environment.API_URL}}
DATABASE_URL=\${{project.DATABASE_URL}}
SERVICE_PORT=4000
`;
const result = getEnviromentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
ENVIRONMENT: "staging",
NODE_ENV: "development",
API_URL: "https://api.dev.example.com",
DATABASE_URL: "postgres://postgres:postgres@localhost:5432/project_db",
SERVICE_PORT: "4000",
});
});
it("handles multiple environment references in single value for Stack compose", () => {
const multiRefEnv = `
HOST=localhost
PORT=5432
USERNAME=postgres
PASSWORD=secret123
`;
const serviceEnv = `
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
`;
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
expect(result).toEqual({
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
});
});
it("throws error for undefined environment variables in Stack compose", () => {
const serviceWithUndefined = `
UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
`;
expect(() =>
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
});
it("allows service variables to override environment variables in Stack compose", () => {
const serviceOverrideEnv = `
NODE_ENV=production
API_URL=\${{environment.API_URL}}
`;
const result = getEnviromentVariablesObject(
serviceOverrideEnv,
"",
environmentEnv,
);
expect(result).toEqual({
NODE_ENV: "production",
API_URL: "https://api.dev.example.com",
});
});
it("resolves complex references with project, environment, and service variables for Stack compose", () => {
const complexServiceEnv = `
FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}}
API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api
SERVICE_NAME=my-service
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
complexServiceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
FULL_DATABASE_URL:
"postgres://postgres:postgres@localhost:5432/project_db/dev_database",
API_ENDPOINT: "https://api.dev.example.com/staging/api",
SERVICE_NAME: "my-service",
COMPLEX_VAR: "my-service-development-staging",
});
});
it("maintains precedence: service > environment > project in Stack compose", () => {
const conflictingProjectEnv = `
NODE_ENV=production-project
API_URL=https://project.api.com
DATABASE_NAME=project_db
`;
const conflictingEnvironmentEnv = `
NODE_ENV=development-environment
API_URL=https://environment.api.com
DATABASE_NAME=env_db
`;
const serviceWithConflicts = `
NODE_ENV=service-override
PROJECT_ENV=\${{project.NODE_ENV}}
ENV_VAR=\${{environment.API_URL}}
DB_NAME=\${{environment.DATABASE_NAME}}
`;
const result = getEnviromentVariablesObject(
serviceWithConflicts,
conflictingProjectEnv,
conflictingEnvironmentEnv,
);
expect(result).toEqual({
NODE_ENV: "service-override",
PROJECT_ENV: "production-project",
ENV_VAR: "https://environment.api.com",
DB_NAME: "env_db",
});
});
it("handles empty environment variables in Stack compose", () => {
const serviceWithEmpty = `
SERVICE_VAR=test
PROJECT_VAR=\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
serviceWithEmpty,
projectEnv,
"",
);
expect(result).toEqual({
SERVICE_VAR: "test",
PROJECT_VAR: "staging",
});
});
});
@@ -54,4 +54,22 @@ describe("processLogs", () => {
const result = parseRawConfig(entryWithWhitespace);
expect(result.data).toHaveLength(2);
});
it("should filter out Dokploy dashboard requests", () => {
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
// Test with only Dokploy dashboard entry - should be filtered out
const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry);
expect(resultOnlyDokploy.data).toHaveLength(0);
expect(resultOnlyDokploy.totalCount).toBe(0);
// Test with mixed entries - Dokploy should be filtered, others should remain
const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`;
const resultMixed = parseRawConfig(mixedEntries);
expect(resultMixed.data).toHaveLength(1);
expect(resultMixed.totalCount).toBe(1);
expect(resultMixed.data[0]?.ServiceName).not.toBe(
"dokploy-service-app@file",
);
});
});
@@ -1,10 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ApplicationNested } from "@dokploy/server/utils/builders";
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
import { beforeEach, describe, expect, it, vi } from "vitest";
type MockCreateServiceOptions = {
StopGracePeriod?: number;
TaskTemplate?: {
ContainerSpec?: {
StopGracePeriod?: number;
};
};
[key: string]: unknown;
};
@@ -82,8 +85,10 @@ describe("mechanizeDockerContainer", () => {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.StopGracePeriod).toBe(0);
expect(typeof settings.StopGracePeriod).toBe("number");
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
"number",
);
});
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
@@ -97,6 +102,8 @@ describe("mechanizeDockerContainer", () => {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings).not.toHaveProperty("StopGracePeriod");
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
"StopGracePeriod",
);
});
});
@@ -161,6 +161,50 @@ describe("helpers functions", () => {
});
});
describe("Empty string variables", () => {
it("should replace variables with empty string values correctly", () => {
const variables = {
smtp_username: "",
smtp_password: "",
non_empty: "value",
};
const result1 = processValue("${smtp_username}", variables, mockSchema);
expect(result1).toBe("");
const result2 = processValue("${smtp_password}", variables, mockSchema);
expect(result2).toBe("");
const result3 = processValue("${non_empty}", variables, mockSchema);
expect(result3).toBe("value");
});
it("should not replace undefined variables", () => {
const variables = {
defined_var: "",
};
const result = processValue("${undefined_var}", variables, mockSchema);
expect(result).toBe("${undefined_var}");
});
it("should handle mixed empty and non-empty variables in template", () => {
const variables = {
smtp_address: "smtp.example.com",
smtp_port: "2525",
smtp_username: "",
smtp_password: "",
};
const template =
"SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}";
const result = processValue(template, variables, mockSchema);
expect(result).toBe(
"SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=",
);
});
});
describe("${jwt}", () => {
it("should generate a JWT string", () => {
const jwt = processValue("${jwt}", {}, mockSchema);
@@ -5,19 +5,27 @@ vi.mock("node:fs", () => ({
default: fs,
}));
import type { FileConfig, User } from "@dokploy/server";
import type { FileConfig } from "@dokploy/server";
import {
createDefaultServerTraefikConfig,
loadOrCreateConfig,
updateServerTraefik,
} from "@dokploy/server";
import type { webServerSettings } from "@dokploy/server/db/schema";
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: User = {
type WebServerSettings = typeof webServerSettings.$inferSelect;
const baseSettings: WebServerSettings = {
id: "",
https: false,
enablePaidFeatures: false,
allowImpersonation: false,
role: "user",
certificateType: "none",
host: null,
serverIp: null,
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
metricsConfig: {
containers: {
refreshRate: 20,
@@ -43,30 +51,8 @@ const baseAdmin: User = {
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
createdAt: new Date(),
serverIp: null,
certificateType: "none",
host: null,
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
serversQuantity: 0,
stripeCustomerId: "",
stripeSubscriptionId: "",
banExpires: new Date(),
banned: true,
banReason: "",
email: "",
expirationDate: "",
id: "",
isRegistered: false,
name: "",
createdAt2: new Date().toISOString(),
emailVerified: false,
image: "",
createdAt: null,
updatedAt: new Date(),
twoFactorEnabled: false,
};
beforeEach(() => {
@@ -84,7 +70,7 @@ test("Should read the configuration file", () => {
test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseAdmin,
...baseSettings,
https: true,
certificateType: "letsencrypt",
},
@@ -99,7 +85,7 @@ test("Should apply redirect-to-https", () => {
});
test("Should change only host when no certificate", () => {
updateServerTraefik(baseAdmin, "example.com");
updateServerTraefik(baseSettings, "example.com");
const config: FileConfig = loadOrCreateConfig("dokploy");
@@ -109,7 +95,7 @@ test("Should change only host when no certificate", () => {
test("Should not touch config without host", () => {
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
updateServerTraefik(baseAdmin, null);
updateServerTraefik(baseSettings, null);
const config: FileConfig = loadOrCreateConfig("dokploy");
@@ -118,11 +104,14 @@ test("Should not touch config without host", () => {
test("Should remove websecure if https rollback to http", () => {
updateServerTraefik(
{ ...baseAdmin, certificateType: "letsencrypt" },
{ ...baseSettings, certificateType: "letsencrypt" },
"example.com",
);
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
updateServerTraefik(
{ ...baseSettings, certificateType: "none" },
"example.com",
);
const config: FileConfig = loadOrCreateConfig("dokploy");
+12 -1
View File
@@ -3,18 +3,28 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
railpackVersion: "0.15.4",
rollbackActive: false,
applicationId: "",
previewLabels: [],
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
giteaRepository: "",
giteaOwner: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
giteaBuildPath: "",
giteaId: "",
args: [],
rollbackRegistryId: "",
rollbackRegistry: null,
deployments: [],
cleanCache: false,
applicationStatus: "done",
endpointSpecSwarm: null,
appName: "",
autoDeploy: true,
enableSubmodules: false,
@@ -41,6 +51,7 @@ const baseApp: ApplicationNested = {
environmentId: "",
environment: {
env: "",
isDefault: false,
environmentId: "",
name: "",
createdAt: "",
+5 -1
View File
@@ -13,7 +13,11 @@ export default defineConfig({
NODE: "test",
},
},
plugins: [tsconfigPaths()],
plugins: [
tsconfigPaths({
projects: [path.resolve(__dirname, "../tsconfig.json")],
}),
],
resolve: {
alias: {
"@dokploy/server": path.resolve(
@@ -0,0 +1,154 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const endpointSpecFormSchema = z.object({
Mode: z.string().optional(),
});
interface EndpointSpecFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(endpointSpecFormSchema),
defaultValues: {
Mode: undefined,
},
});
useEffect(() => {
if (data?.endpointSpecSwarm) {
const es = data.endpointSpecSwarm;
form.reset({
Mode: es.Mode,
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof endpointSpecFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
formData.Mode !== undefined &&
formData.Mode !== null &&
formData.Mode !== "";
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
endpointSpecSwarm: hasAnyValue ? formData : null,
});
toast.success("Endpoint spec updated successfully");
refetch();
} catch {
toast.error("Error updating endpoint spec");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Mode"
render={({ field }) => (
<FormItem>
<FormLabel>Mode</FormLabel>
<FormDescription>Endpoint mode (vip or dnsrr)</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select endpoint mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="vip">VIP (Virtual IP)</SelectItem>
<SelectItem value="dnsrr">DNS Round Robin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Mode: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Endpoint Spec
</Button>
</div>
</form>
</Form>
);
};
@@ -0,0 +1,267 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
export const healthCheckFormSchema = z.object({
Test: z.array(z.string()).optional(),
Interval: z.coerce.number().optional(),
Timeout: z.coerce.number().optional(),
StartPeriod: z.coerce.number().optional(),
Retries: z.coerce.number().optional(),
});
interface HealthCheckFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const [testCommands, setTestCommands] = useState<string[]>([]);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(healthCheckFormSchema),
defaultValues: {
Test: [],
Interval: undefined,
Timeout: undefined,
StartPeriod: undefined,
Retries: undefined,
},
});
useEffect(() => {
if (data?.healthCheckSwarm) {
const hc = data.healthCheckSwarm;
form.reset({
Test: hc.Test || [],
Interval: hc.Interval,
Timeout: hc.Timeout,
StartPeriod: hc.StartPeriod,
Retries: hc.Retries,
});
setTestCommands(hc.Test || []);
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof healthCheckFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
(formData.Test && formData.Test.length > 0) ||
formData.Interval !== undefined ||
formData.Timeout !== undefined ||
formData.StartPeriod !== undefined ||
formData.Retries !== undefined;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
healthCheckSwarm: hasAnyValue ? formData : null,
});
toast.success("Health check updated successfully");
refetch();
} catch {
toast.error("Error updating health check");
} finally {
setIsLoading(false);
}
};
const addTestCommand = () => {
setTestCommands([...testCommands, ""]);
};
const updateTestCommand = (index: number, value: string) => {
const newCommands = [...testCommands];
newCommands[index] = value;
setTestCommands(newCommands);
};
const removeTestCommand = (index: number) => {
setTestCommands(testCommands.filter((_, i) => i !== index));
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Test Commands</FormLabel>
<FormDescription>
Command to run for health check (e.g., ["CMD-SHELL", "curl -f
http://localhost:3000/health"])
</FormDescription>
<div className="space-y-2 mt-2">
{testCommands.map((cmd, index) => (
<div key={index} className="flex gap-2">
<Input
value={cmd}
onChange={(e) => updateTestCommand(index, e.target.value)}
placeholder={
index === 0
? "CMD-SHELL"
: "curl -f http://localhost:3000/health"
}
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeTestCommand(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addTestCommand}
>
Add Command
</Button>
</div>
</div>
<FormField
control={form.control}
name="Interval"
render={({ field }) => (
<FormItem>
<FormLabel>Interval (nanoseconds)</FormLabel>
<FormDescription>
Time between health checks (e.g., 10000000000 for 10 seconds)
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Timeout"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (nanoseconds)</FormLabel>
<FormDescription>
Maximum time to wait for health check response
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="StartPeriod"
render={({ field }) => (
<FormItem>
<FormLabel>Start Period (nanoseconds)</FormLabel>
<FormDescription>
Initial grace period before health checks begin
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Retries"
render={({ field }) => (
<FormItem>
<FormLabel>Retries</FormLabel>
<FormDescription>
Number of consecutive failures needed to consider container
unhealthy
</FormDescription>
<FormControl>
<Input type="number" placeholder="3" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Test: [],
Interval: undefined,
Timeout: undefined,
StartPeriod: undefined,
Retries: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Health Check
</Button>
</div>
</form>
</Form>
);
};
@@ -0,0 +1,10 @@
export { HealthCheckForm } from "./health-check-form";
export { RestartPolicyForm } from "./restart-policy-form";
export { PlacementForm } from "./placement-form";
export { UpdateConfigForm } from "./update-config-form";
export { RollbackConfigForm } from "./rollback-config-form";
export { ModeForm } from "./mode-form";
export { LabelsForm } from "./labels-form";
export { StopGracePeriodForm } from "./stop-grace-period-form";
export { EndpointSpecForm } from "./endpoint-spec-form";
export { filterEmptyValues, hasValues } from "./utils";
@@ -0,0 +1,200 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
export const labelsFormSchema = z.object({
labels: z
.array(
z.object({
key: z.string(),
value: z.string(),
}),
)
.optional(),
});
interface LabelsFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(labelsFormSchema),
defaultValues: {
labels: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "labels",
});
useEffect(() => {
if (data?.labelsSwarm && typeof data.labelsSwarm === "object") {
const labelEntries = Object.entries(data.labelsSwarm).map(
([key, value]) => ({
key,
value: value as string,
}),
);
form.reset({ labels: labelEntries });
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof labelsFormSchema>) => {
setIsLoading(true);
try {
const labelsObject =
formData.labels?.reduce(
(acc, { key, value }) => {
if (key && value) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>,
) || {};
// If no labels, send null to clear the database
const labelsToSend =
Object.keys(labelsObject).length > 0 ? labelsObject : null;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
labelsSwarm: labelsToSend,
});
toast.success("Labels updated successfully");
refetch();
} catch {
toast.error("Error updating labels");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Labels</FormLabel>
<FormDescription>
Add key-value labels to your service
</FormDescription>
<div className="space-y-2 mt-2">
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<FormField
control={form.control}
name={`labels.${index}.key`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder="com.example.app.name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`labels.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder="my-app" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: "", value: "" })}
>
Add Label
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({ labels: [] });
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Labels
</Button>
</div>
</form>
</Form>
);
};
@@ -0,0 +1,195 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface ModeFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const ModeForm = ({ id, type }: ModeFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
defaultValues: {
type: undefined,
Replicas: undefined,
},
});
const modeType = form.watch("type");
useEffect(() => {
if (data?.modeSwarm) {
const mode = data.modeSwarm;
if (mode.Replicated) {
form.reset({
type: "Replicated",
Replicas: mode.Replicated.Replicas,
});
} else if (mode.Global) {
form.reset({
type: "Global",
Replicas: undefined,
});
}
}
}, [data, form]);
const onSubmit = async (formData: any) => {
setIsLoading(true);
try {
// If no type is selected, send null to clear the database
if (!formData.type) {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
modeSwarm: null,
});
toast.success("Mode updated successfully");
refetch();
setIsLoading(false);
return;
}
const modeData =
formData.type === "Replicated"
? { Replicated: { Replicas: formData.Replicas } }
: { Global: {} };
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
modeSwarm: modeData,
});
toast.success("Mode updated successfully");
refetch();
} catch {
toast.error("Error updating mode");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Mode Type</FormLabel>
<FormDescription>
Choose between replicated or global service mode
</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select mode type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Replicated">Replicated</SelectItem>
<SelectItem value="Global">Global</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{modeType === "Replicated" && (
<FormField
control={form.control}
name="Replicas"
render={({ field }) => (
<FormItem>
<FormLabel>Replicas</FormLabel>
<FormDescription>Number of replicas to run</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
type: undefined,
Replicas: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Mode
</Button>
</div>
</form>
</Form>
);
};
@@ -0,0 +1,342 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const PreferenceSchema = z.object({
Spread: z.object({
SpreadDescriptor: z.string(),
}),
});
const PlatformSchema = z.object({
Architecture: z.string(),
OS: z.string(),
});
export const placementFormSchema = z.object({
Constraints: z.array(z.string()).optional(),
Preferences: z.array(PreferenceSchema).optional(),
MaxReplicas: z.coerce.number().optional(),
Platforms: z.array(PlatformSchema).optional(),
});
interface PlacementFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(placementFormSchema),
defaultValues: {
Constraints: [],
Preferences: [],
MaxReplicas: undefined,
Platforms: [],
},
});
const constraints = form.watch("Constraints") || [];
const preferences = form.watch("Preferences") || [];
const platforms = form.watch("Platforms") || [];
useEffect(() => {
if (data?.placementSwarm) {
const placement = data.placementSwarm;
form.reset({
Constraints: placement.Constraints || [],
Preferences:
placement.Preferences?.map((p: any) => ({
SpreadDescriptor: p.Spread?.SpreadDescriptor || "",
})) || [],
MaxReplicas: placement.MaxReplicas,
Platforms: placement.Platforms || [],
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof placementFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
(formData.Constraints && formData.Constraints.length > 0) ||
(formData.Preferences && formData.Preferences.length > 0) ||
(formData.Platforms && formData.Platforms.length > 0) ||
formData.MaxReplicas !== undefined;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
placementSwarm: hasAnyValue ? formData : null,
});
toast.success("Placement updated successfully");
refetch();
} catch {
toast.error("Error updating placement");
} finally {
setIsLoading(false);
}
};
const addConstraint = () => {
form.setValue("Constraints", [...constraints, ""]);
};
const updateConstraint = (index: number, value: string) => {
const newConstraints = [...constraints];
newConstraints[index] = value;
form.setValue("Constraints", newConstraints);
};
const removeConstraint = (index: number) => {
form.setValue(
"Constraints",
constraints.filter((_: string, i: number) => i !== index),
);
};
const addPreference = () => {
form.setValue("Preferences", [...preferences, { SpreadDescriptor: "" }]);
};
const updatePreference = (index: number, value: string) => {
const newPreferences = [...preferences];
if (newPreferences[index]) {
newPreferences[index].SpreadDescriptor = value;
form.setValue("Preferences", newPreferences);
}
};
const removePreference = (index: number) => {
form.setValue(
"Preferences",
preferences.filter((_: any, i: number) => i !== index),
);
};
const addPlatform = () => {
form.setValue("Platforms", [...platforms, { Architecture: "", OS: "" }]);
};
const updatePlatform = (
index: number,
field: "Architecture" | "OS",
value: string,
) => {
const newPlatforms = [...platforms];
if (newPlatforms[index]) {
newPlatforms[index][field] = value;
form.setValue("Platforms", newPlatforms);
}
};
const removePlatform = (index: number) => {
form.setValue(
"Platforms",
platforms.filter((_: any, i: number) => i !== index),
);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Constraints</FormLabel>
<FormDescription>
Placement constraints (e.g., "node.role==manager")
</FormDescription>
<div className="space-y-2 mt-2">
{constraints.map((constraint: string, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={constraint}
onChange={(e) => updateConstraint(index, e.target.value)}
placeholder="node.role==manager"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeConstraint(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addConstraint}
>
Add Constraint
</Button>
</div>
</div>
<div>
<FormLabel>Preferences</FormLabel>
<FormDescription>
Spread preferences for task distribution (e.g.,
"node.labels.region")
</FormDescription>
<div className="space-y-2 mt-2">
{preferences.map((pref: any, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={pref.SpreadDescriptor}
onChange={(e) => updatePreference(index, e.target.value)}
placeholder="node.labels.region"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removePreference(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPreference}
>
Add Preference
</Button>
</div>
</div>
<FormField
control={form.control}
name="MaxReplicas"
render={({ field }) => (
<FormItem>
<FormLabel>Max Replicas</FormLabel>
<FormDescription>
Maximum number of replicas per node
</FormDescription>
<FormControl>
<Input type="number" placeholder="10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>Platforms</FormLabel>
<FormDescription>
Target platforms for task scheduling
</FormDescription>
<div className="space-y-2 mt-2">
{platforms.map((platform: any, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={platform.Architecture}
onChange={(e) =>
updatePlatform(index, "Architecture", e.target.value)
}
placeholder="amd64"
/>
<Input
value={platform.OS}
onChange={(e) => updatePlatform(index, "OS", e.target.value)}
placeholder="linux"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removePlatform(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPlatform}
>
Add Platform
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Constraints: [],
Preferences: [],
MaxReplicas: undefined,
Platforms: [],
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Placement
</Button>
</div>
</form>
</Form>
);
};
@@ -0,0 +1,219 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const restartPolicyFormSchema = z.object({
Condition: z.string().optional(),
Delay: z.coerce.number().optional(),
MaxAttempts: z.coerce.number().optional(),
Window: z.coerce.number().optional(),
});
interface RestartPolicyFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(restartPolicyFormSchema),
defaultValues: {
Condition: undefined,
Delay: undefined,
MaxAttempts: undefined,
Window: undefined,
},
});
useEffect(() => {
if (data?.restartPolicySwarm) {
form.reset({
Condition: data.restartPolicySwarm.Condition,
Delay: data.restartPolicySwarm.Delay,
MaxAttempts: data.restartPolicySwarm.MaxAttempts,
Window: data.restartPolicySwarm.Window,
});
}
}, [data, form]);
const onSubmit = async (
formData: z.infer<typeof restartPolicyFormSchema>,
) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
restartPolicySwarm: hasAnyValue ? formData : null,
});
toast.success("Restart policy updated successfully");
refetch();
} catch {
toast.error("Error updating restart policy");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Condition"
render={({ field }) => (
<FormItem>
<FormLabel>Condition</FormLabel>
<FormDescription>When to restart the container</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select restart condition" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="on-failure">On Failure</SelectItem>
<SelectItem value="any">Any</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>
Wait time between restart attempts
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxAttempts"
render={({ field }) => (
<FormItem>
<FormLabel>Max Attempts</FormLabel>
<FormDescription>
Maximum number of restart attempts
</FormDescription>
<FormControl>
<Input type="number" placeholder="3" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Window"
render={({ field }) => (
<FormItem>
<FormLabel>Window (nanoseconds)</FormLabel>
<FormDescription>
Time window to evaluate restart policy
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Condition: undefined,
Delay: undefined,
MaxAttempts: undefined,
Window: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Restart Policy
</Button>
</div>
</form>
</Form>
);
};
@@ -0,0 +1,257 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const rollbackConfigFormSchema = z.object({
Parallelism: z.coerce.number().optional(),
Delay: z.coerce.number().optional(),
FailureAction: z.string().optional(),
Monitor: z.coerce.number().optional(),
MaxFailureRatio: z.coerce.number().optional(),
Order: z.string().optional(),
});
interface RollbackConfigFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(rollbackConfigFormSchema),
defaultValues: {
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
},
});
useEffect(() => {
if (data?.rollbackConfigSwarm) {
form.reset(data.rollbackConfigSwarm);
}
}, [data, form]);
const onSubmit = async (
formData: z.infer<typeof rollbackConfigFormSchema>,
) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
});
toast.success("Rollback config updated successfully");
refetch();
} catch {
toast.error("Error updating rollback config");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Parallelism"
render={({ field }) => (
<FormItem>
<FormLabel>Parallelism</FormLabel>
<FormDescription>
Number of tasks to rollback simultaneously
</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>Delay between task rollbacks</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="FailureAction"
render={({ field }) => (
<FormItem>
<FormLabel>Failure Action</FormLabel>
<FormDescription>Action on rollback failure</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select failure action" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pause">Pause</SelectItem>
<SelectItem value="continue">Continue</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Monitor"
render={({ field }) => (
<FormItem>
<FormLabel>Monitor (nanoseconds)</FormLabel>
<FormDescription>
Duration to monitor for failure after rollback
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxFailureRatio"
render={({ field }) => (
<FormItem>
<FormLabel>Max Failure Ratio</FormLabel>
<FormDescription>
Maximum failure ratio tolerated (0-1)
</FormDescription>
<FormControl>
<Input type="number" step="0.01" placeholder="0.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Order"
render={({ field }) => (
<FormItem>
<FormLabel>Order</FormLabel>
<FormDescription>Rollback order strategy</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select order" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="stop-first">Stop First</SelectItem>
<SelectItem value="start-first">Start First</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Rollback Config
</Button>
</div>
</form>
</Form>
);
};
@@ -0,0 +1,158 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface StopGracePeriodFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
defaultValues: {
value: null as bigint | null,
},
});
useEffect(() => {
if (hasStopGracePeriodSwarm(data)) {
const value = data.stopGracePeriodSwarm;
const normalizedValue =
value === null || value === undefined
? null
: typeof value === "bigint"
? value
: BigInt(value);
form.reset({
value: normalizedValue,
});
}
}, [data, form]);
const onSubmit = async (formData: any) => {
setIsLoading(true);
try {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
stopGracePeriodSwarm: formData.value,
});
toast.success("Stop grace period updated successfully");
refetch();
} catch {
toast.error("Error updating stop grace period");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
<FormDescription>
Time to wait before forcefully killing the container
<br />
Examples: 30000000000 (30s), 120000000000 (2m)
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="30000000000"
{...field}
value={
field?.value !== null && field?.value !== undefined
? field.value.toString()
: ""
}
onChange={(e) =>
field.onChange(
e.target.value ? BigInt(e.target.value) : null,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
value: null,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Stop Grace Period
</Button>
</div>
</form>
</Form>
);
};
@@ -0,0 +1,264 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const updateConfigFormSchema = z.object({
Parallelism: z.coerce.number().optional(),
Delay: z.coerce.number().optional(),
FailureAction: z.string().optional(),
Monitor: z.coerce.number().optional(),
MaxFailureRatio: z.coerce.number().optional(),
Order: z.string().optional(),
});
interface UpdateConfigFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(updateConfigFormSchema),
defaultValues: {
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
},
});
useEffect(() => {
if (data?.updateConfigSwarm) {
const config = data.updateConfigSwarm;
form.reset({
Parallelism: config.Parallelism,
Delay: config.Delay,
FailureAction: config.FailureAction,
Monitor: config.Monitor,
MaxFailureRatio: config.MaxFailureRatio,
Order: config.Order,
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof updateConfigFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
});
toast.success("Update config updated successfully");
refetch();
} catch {
toast.error("Error updating update config");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Parallelism"
render={({ field }) => (
<FormItem>
<FormLabel>Parallelism</FormLabel>
<FormDescription>
Number of tasks to update simultaneously
</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>Delay between task updates</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="FailureAction"
render={({ field }) => (
<FormItem>
<FormLabel>Failure Action</FormLabel>
<FormDescription>Action on update failure</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select failure action" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pause">Pause</SelectItem>
<SelectItem value="continue">Continue</SelectItem>
<SelectItem value="rollback">Rollback</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Monitor"
render={({ field }) => (
<FormItem>
<FormLabel>Monitor (nanoseconds)</FormLabel>
<FormDescription>
Duration to monitor for failure after update
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxFailureRatio"
render={({ field }) => (
<FormItem>
<FormLabel>Max Failure Ratio</FormLabel>
<FormDescription>
Maximum failure ratio tolerated (0-1)
</FormDescription>
<FormControl>
<Input type="number" step="0.01" placeholder="0.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Order"
render={({ field }) => (
<FormItem>
<FormLabel>Order</FormLabel>
<FormDescription>Update order strategy</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select order" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="stop-first">Stop First</SelectItem>
<SelectItem value="start-first">Start First</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Update Config
</Button>
</div>
</form>
</Form>
);
};
@@ -0,0 +1,31 @@
/**
* Filters out undefined, null, and empty string values from form data
* Only returns fields that have actual values
*/
export const filterEmptyValues = (
formData: Record<string, any>,
): Record<string, any> => {
return Object.entries(formData).reduce(
(acc, [key, value]) => {
// Keep arrays even if empty (they might be intentionally cleared)
if (Array.isArray(value)) {
if (value.length > 0) {
acc[key] = value;
}
}
// For other values, filter out undefined, null, and empty strings
else if (value !== undefined && value !== null && value !== "") {
acc[key] = value;
}
return acc;
},
{} as Record<string, any>,
);
};
/**
* Checks if filtered data has any values to save
*/
export const hasValues = (data: Record<string, any>): boolean => {
return Object.keys(data).length > 0;
};
@@ -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,286 @@
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().optional(),
buildRegistryId: z.string().optional(),
})
.refine(
(data) => {
// Both empty/none is valid
const buildServerIsNone =
!data.buildServerId || data.buildServerId === "none";
const buildRegistryIsNone =
!data.buildRegistryId || data.buildRegistryId === "none";
// Both should be either filled or empty
if (buildServerIsNone && buildRegistryIsNone) return true;
if (!buildServerIsNone && !buildRegistryIsNone) return true;
return false;
},
{
message:
"Both Build Server and Build Registry must be selected together, or both set to None",
path: ["buildServerId"], // Show error on buildServerId field
},
);
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>
<AlertBlock type="info">
<strong>Note:</strong> Build Server and Build Registry must be
configured together. You can either select both or set both to None.
</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={(value) => {
field.onChange(value);
// If setting to "none", also reset build registry to "none"
if (value === "none") {
form.setValue("buildRegistryId", "none");
}
}}
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={(value) => {
field.onChange(value);
// If setting to "none", also reset build server to "none"
if (value === "none") {
form.setValue("buildServerId", "none");
}
}}
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>
);
};
@@ -21,7 +21,10 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
createConverter,
NumberInputWithSteps,
} from "@/components/ui/number-input";
import {
Tooltip,
TooltipContent,
@@ -30,6 +33,23 @@ import {
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
const CPU_STEP = 0.25;
const MEMORY_STEP_MB = 256;
const formatNumber = (value: number, decimals = 2): string =>
Number.isInteger(value) ? String(value) : value.toFixed(decimals);
const cpuConverter = createConverter(1_000_000_000, (cpu) =>
cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`,
);
const memoryConverter = createConverter(1024 * 1024, (mb) => {
if (mb <= 0) return "";
return mb >= 1024
? `${formatNumber(mb / 1024)} GB`
: `${formatNumber(mb)} MB`;
});
const addResourcesSchema = z.object({
memoryReservation: z.string().optional(),
cpuLimit: z.string().optional(),
@@ -51,6 +71,7 @@ interface Props {
}
type AddResources = z.infer<typeof addResourcesSchema>;
export const ShowResources = ({ id, type }: Props) => {
const queryMap = {
postgres: () =>
@@ -163,16 +184,20 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
Memory hard limit in bytes. Example: 1GB =
1073741824 bytes
1073741824 bytes. Use +/- buttons to adjust by
256 MB.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
placeholder="1073741824 (1GB in bytes)"
{...field}
step={MEMORY_STEP_MB}
converter={memoryConverter}
/>
</FormControl>
<FormMessage />
@@ -198,16 +223,20 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
Memory soft limit in bytes. Example: 256MB =
268435456 bytes
268435456 bytes. Use +/- buttons to adjust by 256
MB.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
placeholder="268435456 (256MB in bytes)"
{...field}
step={MEMORY_STEP_MB}
converter={memoryConverter}
/>
</FormControl>
<FormMessage />
@@ -234,17 +263,20 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
CPU quota in units of 10^-9 CPUs. Example: 2
CPUs = 2000000000
CPUs = 2000000000. Use +/- buttons to adjust by
0.25 CPU.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
placeholder="2000000000 (2 CPUs)"
{...field}
value={field.value?.toString() || ""}
step={CPU_STEP}
converter={cpuConverter}
/>
</FormControl>
<FormMessage />
@@ -271,14 +303,21 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
CPU shares (relative weight). Example: 1 CPU =
1000000000
1000000000. Use +/- buttons to adjust by 0.25
CPU.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input placeholder="1000000000 (1 CPU)" {...field} />
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
placeholder="1000000000 (1 CPU)"
step={CPU_STEP}
converter={cpuConverter}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -59,7 +59,13 @@ const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("volume"),
volumeName: z.string().min(1, "Volume name required"),
volumeName: z
.string()
.min(1, "Volume name required")
.regex(
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
),
})
.merge(mountSchema),
z
@@ -41,7 +41,13 @@ const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("volume"),
volumeName: z.string().min(1, "Volume name required"),
volumeName: z
.string()
.min(1, "Volume name required")
.regex(
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
),
})
.merge(mountSchema),
z
@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Cog } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -20,8 +20,39 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
// Railpack versions from https://github.com/railwayapp/railpack/releases
export const RAILPACK_VERSIONS = [
"0.15.4",
"0.15.3",
"0.15.2",
"0.15.1",
"0.15.0",
"0.14.0",
"0.13.0",
"0.12.0",
"0.11.0",
"0.10.0",
"0.9.2",
"0.9.1",
"0.9.0",
"0.8.0",
"0.7.0",
"0.6.0",
"0.5.0",
"0.4.0",
"0.3.0",
"0.2.2",
] as const;
export enum BuildType {
dockerfile = "dockerfile",
heroku_buildpacks = "heroku_buildpacks",
@@ -65,7 +96,7 @@ const mySchema = z.discriminatedUnion("buildType", [
}),
z.object({
buildType: z.literal(BuildType.railpack),
railpackVersion: z.string().nullable().default("0.2.2"),
railpackVersion: z.string().nullable().default("0.15.4"),
}),
z.object({
buildType: z.literal(BuildType.static),
@@ -152,6 +183,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
});
const buildType = form.watch("buildType");
const railpackVersion = form.watch("railpackVersion");
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
useEffect(() => {
if (data) {
@@ -163,9 +196,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
};
form.reset(resetData(typedData));
// Check if railpack version is manual (not in the predefined list)
if (
data.railpackVersion &&
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
) {
setIsManualRailpackVersion(true);
}
}
}, [data, form]);
// Hide builder section when Docker provider is selected
if (data?.sourceType === "docker") {
return null;
}
const onSubmit = async (data: AddTemplate) => {
await mutateAsync({
applicationId,
@@ -186,7 +232,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === BuildType.static ? data.isStaticSpa : null,
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.2.2"
? data.railpackVersion || "0.15.4"
: null,
})
.then(async () => {
@@ -403,23 +449,88 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
/>
)}
{buildType === BuildType.railpack && (
<FormField
control={form.control}
name="railpackVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Railpack Version</FormLabel>
<FormControl>
<Input
placeholder="Railpack Version"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<>
<FormField
control={form.control}
name="railpackVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Railpack Version</FormLabel>
<FormControl>
{isManualRailpackVersion ? (
<div className="space-y-2">
<Input
placeholder="Enter custom version (e.g., 0.15.4)"
{...field}
value={field.value ?? ""}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setIsManualRailpackVersion(false);
field.onChange("0.15.4");
}}
>
Use predefined versions
</Button>
</div>
) : (
<Select
onValueChange={(value) => {
if (value === "manual") {
setIsManualRailpackVersion(true);
field.onChange("");
} else {
field.onChange(value);
}
}}
value={field.value ?? "0.15.4"}
>
<SelectTrigger>
<SelectValue placeholder="Select Railpack version" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
<span className="font-medium">
✏️ Manual (Custom Version)
</span>
</SelectItem>
{RAILPACK_VERSIONS.map((version) => (
<SelectItem key={version} value={version}>
v{version}
{version === "0.15.4" && (
<Badge
variant="secondary"
className="ml-2 px-1 text-xs"
>
Latest
</Badge>
)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormControl>
<FormDescription>
Select a Railpack version or choose manual to enter a
custom version.{" "}
<a
href="https://github.com/railwayapp/railpack/releases"
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-4"
>
View releases
</a>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
@@ -0,0 +1,65 @@
import { Scissors } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
interface Props {
id: string;
type: "application" | "compose";
}
export const KillBuild = ({ id, type }: Props) => {
const { mutateAsync, isLoading } =
type === "application"
? api.application.killBuild.useMutation()
: api.compose.killBuild.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}>
Kill Build
<Scissors className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure to kill the build?</AlertDialogTitle>
<AlertDialogDescription>
This will kill the build process
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
})
.then(() => {
toast.success("Build killed successfully");
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -1,4 +1,12 @@
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
import {
ChevronDown,
ChevronUp,
Clock,
Loader2,
RefreshCcw,
RocketIcon,
Settings,
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -17,6 +25,7 @@ import {
import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues";
import { KillBuild } from "./kill-build";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
@@ -80,6 +89,23 @@ export const ShowDeployments = ({
} = api.compose.cancelDeployment.useMutation();
const [url, setUrl] = React.useState("");
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(
new Set(),
);
const MAX_DESCRIPTION_LENGTH = 200;
const truncateDescription = (description: string): string => {
if (description.length <= MAX_DESCRIPTION_LENGTH) {
return description;
}
const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH);
const lastSpace = truncated.lastIndexOf(" ");
if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) {
return `${truncated.slice(0, lastSpace)}...`;
}
return `${truncated}...`;
};
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
const stuckDeployment = useMemo(() => {
@@ -117,7 +143,10 @@ export const ShowDeployments = ({
See the last 10 deployments for this {type}
</CardDescription>
</div>
<div className="flex flex-row items-center gap-2">
<div className="flex flex-row items-center flex-wrap gap-2">
{(type === "application" || type === "compose") && (
<KillBuild id={id} type={type} />
)}
{(type === "application" || type === "compose") && (
<CancelQueues id={id} type={type} />
)}
@@ -217,122 +246,183 @@ export const ShowDeployments = ({
</div>
) : (
<div className="flex flex-col gap-4">
{deployments?.map((deployment, index) => (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{index + 1}. {deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
<span className="text-sm text-muted-foreground">
{deployment.title}
</span>
{deployment.description && (
<span className="break-all text-sm text-muted-foreground">
{deployment.description}
{deployments?.map((deployment, index) => {
const titleText = deployment?.title?.trim() || "";
const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH;
const isExpanded = expandedDescriptions.has(
deployment.deploymentId,
);
return (
<div
key={deployment.deploymentId}
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-1 flex-col min-w-0">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{index + 1}. {deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
<DateTooltip date={deployment.createdAt} />
{deployment.startedAt && deployment.finishedAt && (
<Badge
variant="outline"
className="text-[10px] gap-1 flex items-center"
>
<Clock className="size-3" />
{formatDuration(
Math.floor(
(new Date(deployment.finishedAt).getTime() -
new Date(deployment.startedAt).getTime()) /
1000,
),
)}
</Badge>
)}
</div>
<div className="flex flex-row items-center gap-2">
{deployment.pid && deployment.status === "running" && (
<DialogAction
title="Kill Process"
description="Are you sure you want to kill the process?"
type="default"
onClick={async () => {
await killProcess({
deploymentId: deployment.deploymentId,
})
.then(() => {
toast.success("Process killed successfully");
})
.catch(() => {
toast.error("Error killing process");
});
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isKillingProcess}
<div className="flex flex-col gap-1">
<span className="break-words text-sm text-muted-foreground whitespace-pre-wrap">
{isExpanded || !needsTruncation
? titleText
: truncateDescription(titleText)}
</span>
{needsTruncation && (
<button
type="button"
onClick={() => {
const next = new Set(expandedDescriptions);
if (next.has(deployment.deploymentId)) {
next.delete(deployment.deploymentId);
} else {
next.add(deployment.deploymentId);
}
setExpandedDescriptions(next);
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mt-1 cursor-pointer"
aria-label={
isExpanded
? "Collapse commit message"
: "Expand commit message"
}
>
Kill Process
</Button>
</DialogAction>
)}
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
{isExpanded ? (
<>
<ChevronUp className="size-3" />
Show less
</>
) : (
<>
<ChevronDown className="size-3" />
Show more
</>
)}
</button>
)}
{/* Hash (from description) - shown in compact form */}
{deployment.description?.trim() && (
<span className="text-xs text-muted-foreground font-mono">
{deployment.description}
</span>
)}
</div>
</div>
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
<DateTooltip date={deployment.createdAt} />
{deployment.startedAt && deployment.finishedAt && (
<Badge
variant="outline"
className="text-[10px] gap-1 flex items-center"
>
<Clock className="size-3" />
{formatDuration(
Math.floor(
(new Date(deployment.finishedAt).getTime() -
new Date(deployment.startedAt).getTime()) /
1000,
),
)}
</Badge>
)}
</div>
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
{deployment.pid && deployment.status === "running" && (
<DialogAction
title="Rollback to this deployment"
description="Are you sure you want to rollback to this deployment?"
title="Kill Process"
description="Are you sure you want to kill the process?"
type="default"
onClick={async () => {
await rollback({
rollbackId: deployment.rollback.rollbackId,
await killProcess({
deploymentId: deployment.deploymentId,
})
.then(() => {
toast.success(
"Rollback initiated successfully",
);
toast.success("Process killed successfully");
})
.catch(() => {
toast.error("Error initiating rollback");
toast.error("Error killing process");
});
}}
>
<Button
variant="secondary"
variant="destructive"
size="sm"
isLoading={isRollingBack}
isLoading={isKillingProcess}
className="w-full sm:w-auto"
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
Rollback
Kill Process
</Button>
</DialogAction>
)}
<Button
onClick={() => {
setActiveLog(deployment);
}}
className="w-full sm:w-auto"
>
View
</Button>
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (
<DialogAction
title="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({
rollbackId: deployment.rollback.rollbackId,
})
.then(() => {
toast.success(
"Rollback initiated successfully",
);
})
.catch(() => {
toast.error("Error initiating rollback");
});
}}
>
<Button
variant="secondary"
size="sm"
isLoading={isRollingBack}
className="w-full sm:w-auto"
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
Rollback
</Button>
</DialogAction>
)}
</div>
</div>
</div>
</div>
))}
);
})}
</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(),
@@ -202,6 +208,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
const certificateType = form.watch("certificateType");
const https = form.watch("https");
const domainType = form.watch("domainType");
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
@@ -299,6 +307,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"
@@ -489,6 +504,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
to make your traefik.me domain work.
</AlertBlock>
)}
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
@@ -5,14 +5,23 @@ import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Form } from "@/components/ui/form";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Secrets } from "@/components/ui/secrets";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const addEnvironmentSchema = z.object({
env: z.string(),
buildArgs: z.string(),
buildSecrets: z.string(),
createEnvFile: z.boolean(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -39,6 +48,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: "",
buildArgs: "",
buildSecrets: "",
createEnvFile: true,
},
resolver: zodResolver(addEnvironmentSchema),
});
@@ -47,10 +57,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const currentEnv = form.watch("env");
const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
const currentCreateEnvFile = form.watch("createEnvFile");
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "") ||
currentBuildSecrets !== (data?.buildSecrets || "");
currentBuildSecrets !== (data?.buildSecrets || "") ||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
useEffect(() => {
if (data) {
@@ -58,6 +70,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: data.env || "",
buildArgs: data.buildArgs || "",
buildSecrets: data.buildSecrets || "",
createEnvFile: data.createEnvFile ?? true,
});
}
}, [data, form]);
@@ -67,6 +80,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: formData.env,
buildArgs: formData.buildArgs,
buildSecrets: formData.buildSecrets,
createEnvFile: formData.createEnvFile,
applicationId,
})
.then(async () => {
@@ -83,6 +97,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: data?.env || "",
buildArgs: data?.buildArgs || "",
buildSecrets: data?.buildSecrets || "",
createEnvFile: data?.createEnvFile ?? true,
});
};
@@ -167,6 +182,31 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
placeholder="NPM_TOKEN=xyz"
/>
)}
{data?.buildType === "dockerfile" && (
<FormField
control={form.control}
name="createEnvFile"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Environment File</FormLabel>
<FormDescription>
When enabled, an .env file will be created in the same
directory as your Dockerfile during the build process.
Disable this if you don't want to generate an environment
file.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>
@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
slug: z.string().optional(),
})
.required(),
branch: z.string().min(1, "Branch is required"),
@@ -82,6 +83,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
repository: {
owner: "",
repo: "",
slug: "",
},
bitbucketId: "",
branch: "",
@@ -114,11 +116,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
} = api.bitbucket.getBitbucketBranches.useQuery(
{
owner: repository?.owner,
repo: repository?.repo,
repo: repository?.slug || repository?.repo || "",
bitbucketId,
},
{
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
enabled:
!!repository?.owner &&
!!(repository?.slug || repository?.repo) &&
!!bitbucketId,
},
);
@@ -129,6 +134,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
repository: {
repo: data.bitbucketRepository || "",
owner: data.bitbucketOwner || "",
slug: data.bitbucketRepositorySlug || "",
},
buildPath: data.bitbucketBuildPath || "/",
bitbucketId: data.bitbucketId || "",
@@ -142,6 +148,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
await mutateAsync({
bitbucketBranch: data.branch,
bitbucketRepository: data.repository.repo,
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
bitbucketOwner: data.repository.owner,
bitbucketBuildPath: data.buildPath,
bitbucketId: data.bitbucketId,
@@ -181,6 +188,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("repository", {
owner: "",
repo: "",
slug: "",
});
form.setValue("branch", "");
}}
@@ -217,7 +225,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -271,6 +279,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("repository", {
owner: repo.owner.username as string,
repo: repo.name,
slug: repo.slug,
});
form.setValue("branch", "");
}}
@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
{field.value.gitlabPathNamespace && (
<Link
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -86,6 +86,9 @@ export const AddPreviewDomain = ({
resolver: zodResolver(domain),
});
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
form.reset({
@@ -157,6 +160,13 @@ export const AddPreviewDomain = ({
name="host"
render={({ field }) => (
<FormItem>
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
@@ -1,7 +1,9 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import {
ExternalLink,
FileText,
GitPullRequest,
Hammer,
Loader2,
PenSquare,
RocketIcon,
@@ -22,6 +24,12 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
@@ -38,6 +46,9 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
const { mutateAsync: redeployPreviewDeployment } =
api.previewDeployment.redeploy.useMutation();
const {
data: previewDeployments,
refetch: refetchPreviewDeployments,
@@ -46,6 +57,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
{ applicationId },
{
enabled: !!applicationId,
refetchInterval: (data) =>
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
},
);
@@ -182,7 +195,68 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
id={deployment.previewDeploymentId}
type="previewDeployment"
serverId={data?.serverId || ""}
/>
>
<Button
variant="outline"
size="sm"
className="gap-2"
>
<RocketIcon className="size-4" />
Deployments
</Button>
</ShowDeploymentsModal>
<DialogAction
title="Rebuild Preview Deployment"
description="Are you sure you want to rebuild this preview deployment?"
type="default"
onClick={async () => {
await redeployPreviewDeployment({
previewDeploymentId:
deployment.previewDeploymentId,
})
.then(() => {
toast.success(
"Preview deployment rebuild started",
);
refetchPreviewDeployments();
})
.catch(() => {
toast.error(
"Error rebuilding preview deployment",
);
});
}}
>
<Button
variant="outline"
size="sm"
isLoading={status === "running"}
className="gap-2"
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Hammer className="size-4" />
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent
sideOffset={5}
className="z-[60]"
>
<p>
Rebuild the preview deployment without
downloading new code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</TooltipProvider>
</Button>
</DialogAction>
<AddPreviewDomain
previewDeploymentId={`${deployment.previewDeploymentId}`}
@@ -4,6 +4,7 @@ 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -100,6 +101,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
});
const previewHttps = form.watch("previewHttps");
const wildcardDomain = form.watch("wildcardDomain");
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false);
@@ -120,7 +123,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewCertificateType: data.previewCertificateType || "none",
previewCustomCertResolver: data.previewCustomCertResolver || "",
previewRequireCollaboratorPermissions:
data.previewRequireCollaboratorPermissions || true,
data.previewRequireCollaboratorPermissions ?? true,
});
}
}, [data]);
@@ -168,6 +171,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP service and
does not support SSL/HTTPS. HTTPS and certificate options will
not have any effect.
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@@ -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>
@@ -1,5 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
CheckIcon,
ChevronsUpDown,
DatabaseZap,
Info,
PenBoxIcon,
@@ -13,6 +15,14 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -31,6 +41,12 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -48,6 +64,7 @@ import {
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import type { CacheType } from "../domains/handle-domain";
import { getTimezoneLabel, TIMEZONES } from "./timezones";
export const commonCronExpressions = [
{ label: "Every minute", value: "* * * * *" },
@@ -75,6 +92,7 @@ const formSchema = z
"dokploy-server",
]),
script: z.string(),
timezone: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.scheduleType === "compose" && !data.serviceName) {
@@ -213,6 +231,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
serviceName: "",
scheduleType: scheduleType || "application",
script: "",
timezone: undefined,
},
});
@@ -251,6 +270,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
serviceName: schedule.serviceName || "",
scheduleType: schedule.scheduleType,
script: schedule.script || "",
timezone: schedule.timezone || undefined,
});
}
}, [form, schedule, scheduleId]);
@@ -464,6 +484,89 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
formControl={form.control}
/>
<FormField
control={form.control}
name="timezone"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Timezone
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Select a timezone for the schedule. If not
specified, UTC will be used.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{getTimezoneLabel(field.value)}
<ChevronsUpDown 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 timezone..."
className="h-9"
/>
<CommandList>
<CommandEmpty>No timezone found.</CommandEmpty>
<ScrollArea className="h-72">
{Object.entries(TIMEZONES).map(
([region, zones]) => (
<CommandGroup key={region} heading={region}>
{zones.map((tz) => (
<CommandItem
key={tz.value}
value={`${region} ${tz.label} ${tz.value}`}
onSelect={() => {
field.onChange(tz.value);
}}
>
{tz.value}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
field.value === tz.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
),
)}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Optional: Choose a timezone for the schedule execution time
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(scheduleTypeForm === "application" ||
scheduleTypeForm === "compose") && (
<>
@@ -0,0 +1,458 @@
// Complete list of IANA timezones grouped by region
export const TIMEZONES: Record<
string,
Array<{ label: string; value: string }>
> = {
Common: [{ label: "UTC (Coordinated Universal Time)", value: "UTC" }],
Africa: [
{ label: "Abidjan", value: "Africa/Abidjan" },
{ label: "Accra", value: "Africa/Accra" },
{ label: "Addis Ababa", value: "Africa/Addis_Ababa" },
{ label: "Algiers", value: "Africa/Algiers" },
{ label: "Asmara", value: "Africa/Asmara" },
{ label: "Bamako", value: "Africa/Bamako" },
{ label: "Bangui", value: "Africa/Bangui" },
{ label: "Banjul", value: "Africa/Banjul" },
{ label: "Bissau", value: "Africa/Bissau" },
{ label: "Blantyre", value: "Africa/Blantyre" },
{ label: "Brazzaville", value: "Africa/Brazzaville" },
{ label: "Bujumbura", value: "Africa/Bujumbura" },
{ label: "Cairo", value: "Africa/Cairo" },
{ label: "Casablanca", value: "Africa/Casablanca" },
{ label: "Ceuta", value: "Africa/Ceuta" },
{ label: "Conakry", value: "Africa/Conakry" },
{ label: "Dakar", value: "Africa/Dakar" },
{ label: "Dar es Salaam", value: "Africa/Dar_es_Salaam" },
{ label: "Djibouti", value: "Africa/Djibouti" },
{ label: "Douala", value: "Africa/Douala" },
{ label: "El Aaiun", value: "Africa/El_Aaiun" },
{ label: "Freetown", value: "Africa/Freetown" },
{ label: "Gaborone", value: "Africa/Gaborone" },
{ label: "Harare", value: "Africa/Harare" },
{ label: "Johannesburg", value: "Africa/Johannesburg" },
{ label: "Juba", value: "Africa/Juba" },
{ label: "Kampala", value: "Africa/Kampala" },
{ label: "Khartoum", value: "Africa/Khartoum" },
{ label: "Kigali", value: "Africa/Kigali" },
{ label: "Kinshasa", value: "Africa/Kinshasa" },
{ label: "Lagos", value: "Africa/Lagos" },
{ label: "Libreville", value: "Africa/Libreville" },
{ label: "Lome", value: "Africa/Lome" },
{ label: "Luanda", value: "Africa/Luanda" },
{ label: "Lubumbashi", value: "Africa/Lubumbashi" },
{ label: "Lusaka", value: "Africa/Lusaka" },
{ label: "Malabo", value: "Africa/Malabo" },
{ label: "Maputo", value: "Africa/Maputo" },
{ label: "Maseru", value: "Africa/Maseru" },
{ label: "Mbabane", value: "Africa/Mbabane" },
{ label: "Mogadishu", value: "Africa/Mogadishu" },
{ label: "Monrovia", value: "Africa/Monrovia" },
{ label: "Nairobi", value: "Africa/Nairobi" },
{ label: "Ndjamena", value: "Africa/Ndjamena" },
{ label: "Niamey", value: "Africa/Niamey" },
{ label: "Nouakchott", value: "Africa/Nouakchott" },
{ label: "Ouagadougou", value: "Africa/Ouagadougou" },
{ label: "Porto-Novo", value: "Africa/Porto-Novo" },
{ label: "Sao Tome", value: "Africa/Sao_Tome" },
{ label: "Tripoli", value: "Africa/Tripoli" },
{ label: "Tunis", value: "Africa/Tunis" },
{ label: "Windhoek", value: "Africa/Windhoek" },
],
America: [
{ label: "Adak", value: "America/Adak" },
{ label: "Anchorage", value: "America/Anchorage" },
{ label: "Anguilla", value: "America/Anguilla" },
{ label: "Antigua", value: "America/Antigua" },
{ label: "Araguaina", value: "America/Araguaina" },
{
label: "Argentina/Buenos Aires",
value: "America/Argentina/Buenos_Aires",
},
{ label: "Argentina/Catamarca", value: "America/Argentina/Catamarca" },
{ label: "Argentina/Cordoba", value: "America/Argentina/Cordoba" },
{ label: "Argentina/Jujuy", value: "America/Argentina/Jujuy" },
{ label: "Argentina/La Rioja", value: "America/Argentina/La_Rioja" },
{ label: "Argentina/Mendoza", value: "America/Argentina/Mendoza" },
{
label: "Argentina/Rio Gallegos",
value: "America/Argentina/Rio_Gallegos",
},
{ label: "Argentina/Salta", value: "America/Argentina/Salta" },
{ label: "Argentina/San Juan", value: "America/Argentina/San_Juan" },
{ label: "Argentina/San Luis", value: "America/Argentina/San_Luis" },
{ label: "Argentina/Tucuman", value: "America/Argentina/Tucuman" },
{ label: "Argentina/Ushuaia", value: "America/Argentina/Ushuaia" },
{ label: "Aruba", value: "America/Aruba" },
{ label: "Asuncion", value: "America/Asuncion" },
{ label: "Atikokan", value: "America/Atikokan" },
{ label: "Bahia", value: "America/Bahia" },
{ label: "Bahia Banderas", value: "America/Bahia_Banderas" },
{ label: "Barbados", value: "America/Barbados" },
{ label: "Belem", value: "America/Belem" },
{ label: "Belize", value: "America/Belize" },
{ label: "Blanc-Sablon", value: "America/Blanc-Sablon" },
{ label: "Boa Vista", value: "America/Boa_Vista" },
{ label: "Bogota", value: "America/Bogota" },
{ label: "Boise", value: "America/Boise" },
{ label: "Cambridge Bay", value: "America/Cambridge_Bay" },
{ label: "Campo Grande", value: "America/Campo_Grande" },
{ label: "Cancun", value: "America/Cancun" },
{ label: "Caracas", value: "America/Caracas" },
{ label: "Cayenne", value: "America/Cayenne" },
{ label: "Cayman", value: "America/Cayman" },
{ label: "Chicago (Central Time)", value: "America/Chicago" },
{ label: "Chihuahua", value: "America/Chihuahua" },
{ label: "Ciudad Juarez", value: "America/Ciudad_Juarez" },
{ label: "Costa Rica", value: "America/Costa_Rica" },
{ label: "Creston", value: "America/Creston" },
{ label: "Cuiaba", value: "America/Cuiaba" },
{ label: "Curacao", value: "America/Curacao" },
{ label: "Danmarkshavn", value: "America/Danmarkshavn" },
{ label: "Dawson", value: "America/Dawson" },
{ label: "Dawson Creek", value: "America/Dawson_Creek" },
{ label: "Denver (Mountain Time)", value: "America/Denver" },
{ label: "Detroit", value: "America/Detroit" },
{ label: "Dominica", value: "America/Dominica" },
{ label: "Edmonton", value: "America/Edmonton" },
{ label: "Eirunepe", value: "America/Eirunepe" },
{ label: "El Salvador", value: "America/El_Salvador" },
{ label: "Fort Nelson", value: "America/Fort_Nelson" },
{ label: "Fortaleza", value: "America/Fortaleza" },
{ label: "Glace Bay", value: "America/Glace_Bay" },
{ label: "Goose Bay", value: "America/Goose_Bay" },
{ label: "Grand Turk", value: "America/Grand_Turk" },
{ label: "Grenada", value: "America/Grenada" },
{ label: "Guadeloupe", value: "America/Guadeloupe" },
{ label: "Guatemala", value: "America/Guatemala" },
{ label: "Guayaquil", value: "America/Guayaquil" },
{ label: "Guyana", value: "America/Guyana" },
{ label: "Halifax", value: "America/Halifax" },
{ label: "Havana", value: "America/Havana" },
{ label: "Hermosillo", value: "America/Hermosillo" },
{ label: "Indiana/Indianapolis", value: "America/Indiana/Indianapolis" },
{ label: "Indiana/Knox", value: "America/Indiana/Knox" },
{ label: "Indiana/Marengo", value: "America/Indiana/Marengo" },
{ label: "Indiana/Petersburg", value: "America/Indiana/Petersburg" },
{ label: "Indiana/Tell City", value: "America/Indiana/Tell_City" },
{ label: "Indiana/Vevay", value: "America/Indiana/Vevay" },
{ label: "Indiana/Vincennes", value: "America/Indiana/Vincennes" },
{ label: "Indiana/Winamac", value: "America/Indiana/Winamac" },
{ label: "Inuvik", value: "America/Inuvik" },
{ label: "Iqaluit", value: "America/Iqaluit" },
{ label: "Jamaica", value: "America/Jamaica" },
{ label: "Juneau", value: "America/Juneau" },
{ label: "Kentucky/Louisville", value: "America/Kentucky/Louisville" },
{ label: "Kentucky/Monticello", value: "America/Kentucky/Monticello" },
{ label: "Kralendijk", value: "America/Kralendijk" },
{ label: "La Paz", value: "America/La_Paz" },
{ label: "Lima", value: "America/Lima" },
{ label: "Los Angeles (Pacific Time)", value: "America/Los_Angeles" },
{ label: "Lower Princes", value: "America/Lower_Princes" },
{ label: "Maceio", value: "America/Maceio" },
{ label: "Managua", value: "America/Managua" },
{ label: "Manaus", value: "America/Manaus" },
{ label: "Marigot", value: "America/Marigot" },
{ label: "Martinique", value: "America/Martinique" },
{ label: "Matamoros", value: "America/Matamoros" },
{ label: "Mazatlan", value: "America/Mazatlan" },
{ label: "Menominee", value: "America/Menominee" },
{ label: "Merida", value: "America/Merida" },
{ label: "Metlakatla", value: "America/Metlakatla" },
{ label: "Mexico City (Central Mexico)", value: "America/Mexico_City" },
{ label: "Miquelon", value: "America/Miquelon" },
{ label: "Moncton", value: "America/Moncton" },
{ label: "Monterrey", value: "America/Monterrey" },
{ label: "Montevideo", value: "America/Montevideo" },
{ label: "Montserrat", value: "America/Montserrat" },
{ label: "Nassau", value: "America/Nassau" },
{ label: "New York (Eastern Time)", value: "America/New_York" },
{ label: "Nome", value: "America/Nome" },
{ label: "Noronha", value: "America/Noronha" },
{ label: "North Dakota/Beulah", value: "America/North_Dakota/Beulah" },
{ label: "North Dakota/Center", value: "America/North_Dakota/Center" },
{
label: "North Dakota/New Salem",
value: "America/North_Dakota/New_Salem",
},
{ label: "Nuuk", value: "America/Nuuk" },
{ label: "Ojinaga", value: "America/Ojinaga" },
{ label: "Panama", value: "America/Panama" },
{ label: "Paramaribo", value: "America/Paramaribo" },
{ label: "Phoenix", value: "America/Phoenix" },
{ label: "Port-au-Prince", value: "America/Port-au-Prince" },
{ label: "Port of Spain", value: "America/Port_of_Spain" },
{ label: "Porto Velho", value: "America/Porto_Velho" },
{ label: "Puerto Rico", value: "America/Puerto_Rico" },
{ label: "Punta Arenas", value: "America/Punta_Arenas" },
{ label: "Rankin Inlet", value: "America/Rankin_Inlet" },
{ label: "Recife", value: "America/Recife" },
{ label: "Regina", value: "America/Regina" },
{ label: "Resolute", value: "America/Resolute" },
{ label: "Rio Branco", value: "America/Rio_Branco" },
{ label: "Santarem", value: "America/Santarem" },
{ label: "Santiago", value: "America/Santiago" },
{ label: "Santo Domingo", value: "America/Santo_Domingo" },
{ label: "Sao Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
{ label: "Scoresbysund", value: "America/Scoresbysund" },
{ label: "Sitka", value: "America/Sitka" },
{ label: "St Barthelemy", value: "America/St_Barthelemy" },
{ label: "St Johns", value: "America/St_Johns" },
{ label: "St Kitts", value: "America/St_Kitts" },
{ label: "St Lucia", value: "America/St_Lucia" },
{ label: "St Thomas", value: "America/St_Thomas" },
{ label: "St Vincent", value: "America/St_Vincent" },
{ label: "Swift Current", value: "America/Swift_Current" },
{ label: "Tegucigalpa", value: "America/Tegucigalpa" },
{ label: "Thule", value: "America/Thule" },
{ label: "Tijuana", value: "America/Tijuana" },
{ label: "Toronto", value: "America/Toronto" },
{ label: "Tortola", value: "America/Tortola" },
{ label: "Vancouver", value: "America/Vancouver" },
{ label: "Whitehorse", value: "America/Whitehorse" },
{ label: "Winnipeg", value: "America/Winnipeg" },
{ label: "Yakutat", value: "America/Yakutat" },
],
Antarctica: [
{ label: "Casey", value: "Antarctica/Casey" },
{ label: "Davis", value: "Antarctica/Davis" },
{ label: "DumontDUrville", value: "Antarctica/DumontDUrville" },
{ label: "Macquarie", value: "Antarctica/Macquarie" },
{ label: "Mawson", value: "Antarctica/Mawson" },
{ label: "McMurdo", value: "Antarctica/McMurdo" },
{ label: "Palmer", value: "Antarctica/Palmer" },
{ label: "Rothera", value: "Antarctica/Rothera" },
{ label: "Syowa", value: "Antarctica/Syowa" },
{ label: "Troll", value: "Antarctica/Troll" },
{ label: "Vostok", value: "Antarctica/Vostok" },
],
Arctic: [{ label: "Longyearbyen", value: "Arctic/Longyearbyen" }],
Asia: [
{ label: "Aden", value: "Asia/Aden" },
{ label: "Almaty", value: "Asia/Almaty" },
{ label: "Amman", value: "Asia/Amman" },
{ label: "Anadyr", value: "Asia/Anadyr" },
{ label: "Aqtau", value: "Asia/Aqtau" },
{ label: "Aqtobe", value: "Asia/Aqtobe" },
{ label: "Ashgabat", value: "Asia/Ashgabat" },
{ label: "Atyrau", value: "Asia/Atyrau" },
{ label: "Baghdad", value: "Asia/Baghdad" },
{ label: "Bahrain", value: "Asia/Bahrain" },
{ label: "Baku", value: "Asia/Baku" },
{ label: "Bangkok", value: "Asia/Bangkok" },
{ label: "Barnaul", value: "Asia/Barnaul" },
{ label: "Beirut", value: "Asia/Beirut" },
{ label: "Bishkek", value: "Asia/Bishkek" },
{ label: "Brunei", value: "Asia/Brunei" },
{ label: "Chita", value: "Asia/Chita" },
{ label: "Choibalsan", value: "Asia/Choibalsan" },
{ label: "Colombo", value: "Asia/Colombo" },
{ label: "Damascus", value: "Asia/Damascus" },
{ label: "Dhaka", value: "Asia/Dhaka" },
{ label: "Dili", value: "Asia/Dili" },
{ label: "Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
{ label: "Dushanbe", value: "Asia/Dushanbe" },
{ label: "Famagusta", value: "Asia/Famagusta" },
{ label: "Gaza", value: "Asia/Gaza" },
{ label: "Hebron", value: "Asia/Hebron" },
{ label: "Ho Chi Minh", value: "Asia/Ho_Chi_Minh" },
{ label: "Hong Kong", value: "Asia/Hong_Kong" },
{ label: "Hovd", value: "Asia/Hovd" },
{ label: "Irkutsk", value: "Asia/Irkutsk" },
{ label: "Jakarta", value: "Asia/Jakarta" },
{ label: "Jayapura", value: "Asia/Jayapura" },
{ label: "Jerusalem", value: "Asia/Jerusalem" },
{ label: "Kabul", value: "Asia/Kabul" },
{ label: "Kamchatka", value: "Asia/Kamchatka" },
{ label: "Karachi", value: "Asia/Karachi" },
{ label: "Kathmandu", value: "Asia/Kathmandu" },
{ label: "Khandyga", value: "Asia/Khandyga" },
{ label: "Kolkata (India Standard Time)", value: "Asia/Kolkata" },
{ label: "Krasnoyarsk", value: "Asia/Krasnoyarsk" },
{ label: "Kuala Lumpur", value: "Asia/Kuala_Lumpur" },
{ label: "Kuching", value: "Asia/Kuching" },
{ label: "Kuwait", value: "Asia/Kuwait" },
{ label: "Macau", value: "Asia/Macau" },
{ label: "Magadan", value: "Asia/Magadan" },
{ label: "Makassar", value: "Asia/Makassar" },
{ label: "Manila", value: "Asia/Manila" },
{ label: "Muscat", value: "Asia/Muscat" },
{ label: "Nicosia", value: "Asia/Nicosia" },
{ label: "Novokuznetsk", value: "Asia/Novokuznetsk" },
{ label: "Novosibirsk", value: "Asia/Novosibirsk" },
{ label: "Omsk", value: "Asia/Omsk" },
{ label: "Oral", value: "Asia/Oral" },
{ label: "Phnom Penh", value: "Asia/Phnom_Penh" },
{ label: "Pontianak", value: "Asia/Pontianak" },
{ label: "Pyongyang", value: "Asia/Pyongyang" },
{ label: "Qatar", value: "Asia/Qatar" },
{ label: "Qostanay", value: "Asia/Qostanay" },
{ label: "Qyzylorda", value: "Asia/Qyzylorda" },
{ label: "Riyadh", value: "Asia/Riyadh" },
{ label: "Sakhalin", value: "Asia/Sakhalin" },
{ label: "Samarkand", value: "Asia/Samarkand" },
{ label: "Seoul", value: "Asia/Seoul" },
{ label: "Shanghai (China Standard Time)", value: "Asia/Shanghai" },
{ label: "Singapore", value: "Asia/Singapore" },
{ label: "Srednekolymsk", value: "Asia/Srednekolymsk" },
{ label: "Taipei", value: "Asia/Taipei" },
{ label: "Tashkent", value: "Asia/Tashkent" },
{ label: "Tbilisi", value: "Asia/Tbilisi" },
{ label: "Tehran", value: "Asia/Tehran" },
{ label: "Thimphu", value: "Asia/Thimphu" },
{ label: "Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
{ label: "Tomsk", value: "Asia/Tomsk" },
{ label: "Ulaanbaatar", value: "Asia/Ulaanbaatar" },
{ label: "Urumqi", value: "Asia/Urumqi" },
{ label: "Ust-Nera", value: "Asia/Ust-Nera" },
{ label: "Vientiane", value: "Asia/Vientiane" },
{ label: "Vladivostok", value: "Asia/Vladivostok" },
{ label: "Yakutsk", value: "Asia/Yakutsk" },
{ label: "Yangon", value: "Asia/Yangon" },
{ label: "Yekaterinburg", value: "Asia/Yekaterinburg" },
{ label: "Yerevan", value: "Asia/Yerevan" },
],
Atlantic: [
{ label: "Azores", value: "Atlantic/Azores" },
{ label: "Bermuda", value: "Atlantic/Bermuda" },
{ label: "Canary", value: "Atlantic/Canary" },
{ label: "Cape Verde", value: "Atlantic/Cape_Verde" },
{ label: "Faroe", value: "Atlantic/Faroe" },
{ label: "Madeira", value: "Atlantic/Madeira" },
{ label: "Reykjavik", value: "Atlantic/Reykjavik" },
{ label: "South Georgia", value: "Atlantic/South_Georgia" },
{ label: "St Helena", value: "Atlantic/St_Helena" },
{ label: "Stanley", value: "Atlantic/Stanley" },
],
Australia: [
{ label: "Adelaide", value: "Australia/Adelaide" },
{ label: "Brisbane", value: "Australia/Brisbane" },
{ label: "Broken Hill", value: "Australia/Broken_Hill" },
{ label: "Darwin", value: "Australia/Darwin" },
{ label: "Eucla", value: "Australia/Eucla" },
{ label: "Hobart", value: "Australia/Hobart" },
{ label: "Lindeman", value: "Australia/Lindeman" },
{ label: "Lord Howe", value: "Australia/Lord_Howe" },
{ label: "Melbourne", value: "Australia/Melbourne" },
{ label: "Perth", value: "Australia/Perth" },
{ label: "Sydney (Australian Eastern Time)", value: "Australia/Sydney" },
],
Europe: [
{ label: "Amsterdam", value: "Europe/Amsterdam" },
{ label: "Andorra", value: "Europe/Andorra" },
{ label: "Astrakhan", value: "Europe/Astrakhan" },
{ label: "Athens", value: "Europe/Athens" },
{ label: "Belgrade", value: "Europe/Belgrade" },
{ label: "Berlin (Central European Time)", value: "Europe/Berlin" },
{ label: "Bratislava", value: "Europe/Bratislava" },
{ label: "Brussels", value: "Europe/Brussels" },
{ label: "Bucharest", value: "Europe/Bucharest" },
{ label: "Budapest", value: "Europe/Budapest" },
{ label: "Busingen", value: "Europe/Busingen" },
{ label: "Chisinau", value: "Europe/Chisinau" },
{ label: "Copenhagen", value: "Europe/Copenhagen" },
{ label: "Dublin", value: "Europe/Dublin" },
{ label: "Gibraltar", value: "Europe/Gibraltar" },
{ label: "Guernsey", value: "Europe/Guernsey" },
{ label: "Helsinki", value: "Europe/Helsinki" },
{ label: "Isle of Man", value: "Europe/Isle_of_Man" },
{ label: "Istanbul", value: "Europe/Istanbul" },
{ label: "Jersey", value: "Europe/Jersey" },
{ label: "Kaliningrad", value: "Europe/Kaliningrad" },
{ label: "Kirov", value: "Europe/Kirov" },
{ label: "Kyiv", value: "Europe/Kyiv" },
{ label: "Lisbon", value: "Europe/Lisbon" },
{ label: "Ljubljana", value: "Europe/Ljubljana" },
{ label: "London (Greenwich Mean Time)", value: "Europe/London" },
{ label: "Luxembourg", value: "Europe/Luxembourg" },
{ label: "Madrid", value: "Europe/Madrid" },
{ label: "Malta", value: "Europe/Malta" },
{ label: "Mariehamn", value: "Europe/Mariehamn" },
{ label: "Minsk", value: "Europe/Minsk" },
{ label: "Monaco", value: "Europe/Monaco" },
{ label: "Moscow", value: "Europe/Moscow" },
{ label: "Oslo", value: "Europe/Oslo" },
{ label: "Paris (Central European Time)", value: "Europe/Paris" },
{ label: "Podgorica", value: "Europe/Podgorica" },
{ label: "Prague", value: "Europe/Prague" },
{ label: "Riga", value: "Europe/Riga" },
{ label: "Rome", value: "Europe/Rome" },
{ label: "Samara", value: "Europe/Samara" },
{ label: "San Marino", value: "Europe/San_Marino" },
{ label: "Sarajevo", value: "Europe/Sarajevo" },
{ label: "Saratov", value: "Europe/Saratov" },
{ label: "Simferopol", value: "Europe/Simferopol" },
{ label: "Skopje", value: "Europe/Skopje" },
{ label: "Sofia", value: "Europe/Sofia" },
{ label: "Stockholm", value: "Europe/Stockholm" },
{ label: "Tallinn", value: "Europe/Tallinn" },
{ label: "Tirane", value: "Europe/Tirane" },
{ label: "Ulyanovsk", value: "Europe/Ulyanovsk" },
{ label: "Vaduz", value: "Europe/Vaduz" },
{ label: "Vatican", value: "Europe/Vatican" },
{ label: "Vienna", value: "Europe/Vienna" },
{ label: "Vilnius", value: "Europe/Vilnius" },
{ label: "Volgograd", value: "Europe/Volgograd" },
{ label: "Warsaw", value: "Europe/Warsaw" },
{ label: "Zagreb", value: "Europe/Zagreb" },
{ label: "Zurich", value: "Europe/Zurich" },
],
Indian: [
{ label: "Antananarivo", value: "Indian/Antananarivo" },
{ label: "Chagos", value: "Indian/Chagos" },
{ label: "Christmas", value: "Indian/Christmas" },
{ label: "Cocos", value: "Indian/Cocos" },
{ label: "Comoro", value: "Indian/Comoro" },
{ label: "Kerguelen", value: "Indian/Kerguelen" },
{ label: "Mahe", value: "Indian/Mahe" },
{ label: "Maldives", value: "Indian/Maldives" },
{ label: "Mauritius", value: "Indian/Mauritius" },
{ label: "Mayotte", value: "Indian/Mayotte" },
{ label: "Reunion", value: "Indian/Reunion" },
],
Pacific: [
{ label: "Apia", value: "Pacific/Apia" },
{ label: "Auckland", value: "Pacific/Auckland" },
{ label: "Bougainville", value: "Pacific/Bougainville" },
{ label: "Chatham", value: "Pacific/Chatham" },
{ label: "Chuuk", value: "Pacific/Chuuk" },
{ label: "Easter", value: "Pacific/Easter" },
{ label: "Efate", value: "Pacific/Efate" },
{ label: "Fakaofo", value: "Pacific/Fakaofo" },
{ label: "Fiji", value: "Pacific/Fiji" },
{ label: "Funafuti", value: "Pacific/Funafuti" },
{ label: "Galapagos", value: "Pacific/Galapagos" },
{ label: "Gambier", value: "Pacific/Gambier" },
{ label: "Guadalcanal", value: "Pacific/Guadalcanal" },
{ label: "Guam", value: "Pacific/Guam" },
{ label: "Honolulu", value: "Pacific/Honolulu" },
{ label: "Kanton", value: "Pacific/Kanton" },
{ label: "Kiritimati", value: "Pacific/Kiritimati" },
{ label: "Kosrae", value: "Pacific/Kosrae" },
{ label: "Kwajalein", value: "Pacific/Kwajalein" },
{ label: "Majuro", value: "Pacific/Majuro" },
{ label: "Marquesas", value: "Pacific/Marquesas" },
{ label: "Midway", value: "Pacific/Midway" },
{ label: "Nauru", value: "Pacific/Nauru" },
{ label: "Niue", value: "Pacific/Niue" },
{ label: "Norfolk", value: "Pacific/Norfolk" },
{ label: "Noumea", value: "Pacific/Noumea" },
{ label: "Pago Pago", value: "Pacific/Pago_Pago" },
{ label: "Palau", value: "Pacific/Palau" },
{ label: "Pitcairn", value: "Pacific/Pitcairn" },
{ label: "Pohnpei", value: "Pacific/Pohnpei" },
{ label: "Port Moresby", value: "Pacific/Port_Moresby" },
{ label: "Rarotonga", value: "Pacific/Rarotonga" },
{ label: "Saipan", value: "Pacific/Saipan" },
{ label: "Tahiti", value: "Pacific/Tahiti" },
{ label: "Tarawa", value: "Pacific/Tarawa" },
{ label: "Tongatapu", value: "Pacific/Tongatapu" },
{ label: "Wake", value: "Pacific/Wake" },
{ label: "Wallis", value: "Pacific/Wallis" },
],
};
// Helper to get display label for a timezone value
export function getTimezoneLabel(value: string | undefined): string {
if (!value) return "UTC (default)";
return value;
}
@@ -47,7 +47,13 @@ const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
cronExpression: z.string().min(1, "Cron expression is required"),
volumeName: z.string().min(1, "Volume name is required"),
volumeName: z
.string()
.min(1, "Volume name is required")
.regex(
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
),
prefix: z.string(),
keepLatestCount: z.coerce
.number()
@@ -86,7 +86,7 @@ export const ShowVolumeBackups = ({
</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified
intervals.
intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
slug: z.string().optional(),
})
.required(),
branch: z.string().min(1, "Branch is required"),
@@ -82,6 +83,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
repository: {
owner: "",
repo: "",
slug: "",
},
bitbucketId: "",
branch: "",
@@ -114,11 +116,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
} = api.bitbucket.getBitbucketBranches.useQuery(
{
owner: repository?.owner,
repo: repository?.repo,
repo: repository?.slug || repository?.repo || "",
bitbucketId,
},
{
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
enabled:
!!repository?.owner &&
!!(repository?.slug || repository?.repo) &&
!!bitbucketId,
},
);
@@ -129,6 +134,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
repository: {
repo: data.bitbucketRepository || "",
owner: data.bitbucketOwner || "",
slug: data.bitbucketRepositorySlug || "",
},
composePath: data.composePath,
bitbucketId: data.bitbucketId || "",
@@ -142,6 +148,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
await mutateAsync({
bitbucketBranch: data.branch,
bitbucketRepository: data.repository.repo,
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
bitbucketOwner: data.repository.owner,
bitbucketId: data.bitbucketId,
composePath: data.composePath,
@@ -183,6 +190,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("repository", {
owner: "",
repo: "",
slug: "",
});
form.setValue("branch", "");
}}
@@ -219,7 +227,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -273,6 +281,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("repository", {
owner: repo.owner.username as string,
repo: repo.name,
slug: repo.slug,
});
form.setValue("branch", "");
}}
@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const gitlabId = form.watch("gitlabId");
const gitlabUrl = useMemo(() => {
const url = gitlabProviders?.find(
(provider) => provider.gitlabId === gitlabId,
)?.gitlabUrl;
const gitlabUrl = url?.replace(/\/$/, "");
return gitlabUrl || "https://gitlab.com";
}, [gitlabId, gitlabProviders]);
const {
data: repositories,
isLoading: isLoadingRepositories,
@@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
{field.value.gitlabPathNamespace && (
<Link
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { debounce } from "lodash";
import _ from "lodash";
import {
CheckIcon,
ChevronsUpDown,
@@ -236,7 +236,7 @@ export const RestoreBackup = ({
const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata");
const debouncedSetSearch = debounce((value: string) => {
const debouncedSetSearch = _.debounce((value: string) => {
setDebouncedSearchTerm(value);
}, 350);
@@ -1,5 +1,5 @@
import { FancyAnsi } from "fancy-ansi";
import { escapeRegExp } from "lodash";
import _ from "lodash";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
@@ -47,7 +47,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
}
const htmlContent = fancyAnsi.toHtml(text);
const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
const searchRegex = new RegExp(`(${_.escapeRegExp(term)})`, "gi");
const modifiedContent = htmlContent.replace(
searchRegex,
@@ -49,7 +49,7 @@ export function parseLogs(logString: string): LogLine[] {
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
// message: "The server is running on port 8080" }
const logRegex =
/^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/;
/^(?:(?<lineNumber>\d+)\s+)?(?<timestamp>(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC))?\s*(?<message>[\s\S]*)$/;
return logString
.split("\n")
@@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] {
const match = line.match(logRegex);
if (!match) return null;
const [, , timestamp, message] = match;
const { timestamp, message } = match.groups ?? {};
if (!message?.trim()) return null;
@@ -108,7 +108,8 @@ export const getLogType = (message: string): LogStyle => {
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
/\b(?:unstable|experimental)\b/i.test(lowerMessage)
/\b(?:unstable|experimental)\b/i.test(lowerMessage) ||
/⚠|⚠️/i.test(lowerMessage)
) {
return LOG_STYLES.warning;
}
@@ -103,7 +103,7 @@ export const ImpersonationBar = () => {
setOpen(false);
toast.success("Successfully impersonating user", {
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
description: `You are now viewing as ${`${selectedUser.name} ${selectedUser.lastName}`.trim() || selectedUser.email}`,
});
window.location.reload();
} catch (error) {
@@ -195,7 +195,8 @@ export const ImpersonationBar = () => {
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate flex flex-col items-start">
<span className="text-sm font-medium">
{selectedUser.name || ""}
{`${selectedUser.name} ${selectedUser.lastName}`.trim() ||
""}
</span>
<span className="text-xs text-muted-foreground">
{selectedUser.email}
@@ -242,7 +243,8 @@ export const ImpersonationBar = () => {
<UserIcon className="h-4 w-4 flex-shrink-0" />
<span className="flex flex-col items-start">
<span className="text-sm font-medium">
{user.name || ""}
{`${user.name} ${user.lastName}`.trim() ||
""}
</span>
<span className="text-xs text-muted-foreground">
{user.email} • {user.role}
@@ -283,10 +285,14 @@ export const ImpersonationBar = () => {
<AvatarImage
className="object-cover"
src={data?.user?.image || ""}
alt={data?.user?.name || ""}
alt={
`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
""
}
/>
<AvatarFallback>
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
{`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() ||
"U"}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1">
@@ -299,7 +305,8 @@ export const ImpersonationBar = () => {
Impersonating
</Badge>
<span className="font-medium">
{data?.user?.name || ""}
{`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
""}
</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
@@ -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");
@@ -113,7 +129,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
<FormItem>
<FormLabel>Docker Image</FormLabel>
<FormControl>
<Input placeholder="postgres:15" {...field} />
<Input placeholder="postgres:18" {...field} />
</FormControl>
<FormMessage />
@@ -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
@@ -150,8 +150,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
placeholder="Frontend"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
const serviceName = slugify(val);
const val = e.target.value || "";
const serviceName = slugify(val.trim());
form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}
@@ -161,8 +161,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
placeholder="Frontend"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
const serviceName = slugify(val);
const val = e.target.value || "";
const serviceName = slugify(val.trim());
form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}
@@ -58,7 +58,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mongo: "mongo:7",
mariadb: "mariadb:11",
mysql: "mysql:8",
postgres: "postgres:15",
postgres: "postgres:18",
redis: "redis:7",
};
@@ -395,8 +395,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
placeholder="Name"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
const serviceName = slugify(val);
const val = e.target.value || "";
const serviceName = slugify(val.trim());
form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}
@@ -559,6 +559,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
type="password"
placeholder="******************"
autoComplete="one-time-code"
enablePasswordGenerator={true}
{...field}
/>
</FormControl>
@@ -578,6 +579,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
<Input
type="password"
placeholder="******************"
enablePasswordGenerator={true}
{...field}
/>
</FormControl>
@@ -1,15 +1,8 @@
import type { findEnvironmentsByProjectId } from "@dokploy/server";
import {
ChevronDownIcon,
PencilIcon,
PlusIcon,
Terminal,
TrashIcon,
} from "lucide-react";
import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
@@ -109,7 +102,9 @@ export const AdvancedEnvironmentSelector = ({
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to create environment");
toast.error(
`Failed to create environment: ${error instanceof Error ? error.message : error}`,
);
}
};
@@ -130,7 +125,9 @@ export const AdvancedEnvironmentSelector = ({
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to update environment");
toast.error(
`Failed to update environment: ${error instanceof Error ? error.message : error}`,
);
}
};
@@ -147,15 +144,18 @@ export const AdvancedEnvironmentSelector = ({
setIsDeleteDialogOpen(false);
setSelectedEnvironment(null);
// Redirect to production if we deleted the current environment
// Redirect to first available environment if we deleted the current environment
if (selectedEnvironment.environmentId === currentEnvironmentId) {
const productionEnv = environments?.find(
(env) => env.name === "production",
const firstEnv = environments?.find(
(env) => env.environmentId !== selectedEnvironment.environmentId,
);
if (productionEnv) {
if (firstEnv) {
router.push(
`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
`/dashboard/project/${projectId}/environment/${firstEnv.environmentId}`,
);
} else {
// No other environments, redirect to project page
router.push(`/dashboard/project/${projectId}`);
}
}
} catch (error) {
@@ -246,22 +246,8 @@ export const AdvancedEnvironmentSelector = ({
)}
</div>
</DropdownMenuItem>
{/* Action buttons for non-production environments */}
{/* <EnvironmentVariables environmentId={environment.environmentId}>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
}}
>
<Terminal className="h-3 w-3" />
</Button>
</EnvironmentVariables> */}
{environment.name !== "production" && (
<div className="flex items-center gap-1 px-2">
<div className="flex items-center gap-1 px-2">
{!environment.isDefault && (
<Button
variant="ghost"
size="sm"
@@ -273,22 +259,21 @@ export const AdvancedEnvironmentSelector = ({
>
<PencilIcon className="h-3 w-3" />
</Button>
{canDeleteEnvironments && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(environment);
}}
>
<TrashIcon className="h-3 w-3" />
</Button>
)}
</div>
)}
)}
{canDeleteEnvironments && !environment.isDefault && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(environment);
}}
>
<TrashIcon className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
})}
@@ -10,10 +10,12 @@ import {
TrashIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import {
AlertDialog,
@@ -44,7 +46,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import {
Select,
SelectContent,
@@ -52,16 +53,25 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TimeBadge } from "@/components/ui/time-badge";
import { api } from "@/utils/api";
import { useDebounce } from "@/utils/hooks/use-debounce";
import { HandleProject } from "./handle-project";
import { ProjectEnvironment } from "./project-environment";
export const ShowProjects = () => {
const utils = api.useUtils();
const router = useRouter();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isLoading } = api.project.all.useQuery();
const { data: auth } = api.user.get.useQuery();
const { mutateAsync } = api.project.remove.useMutation();
const [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState(
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
);
const debouncedSearchQuery = useDebounce(searchQuery, 500);
const [sortBy, setSortBy] = useState<string>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("projectsSort") || "createdAt-desc";
@@ -73,14 +83,41 @@ export const ShowProjects = () => {
localStorage.setItem("projectsSort", sortBy);
}, [sortBy]);
useEffect(() => {
if (!router.isReady) return;
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
if (urlQuery !== searchQuery) {
setSearchQuery(urlQuery);
}
}, [router.isReady, router.query.q]);
useEffect(() => {
if (!router.isReady) return;
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
if (debouncedSearchQuery === urlQuery) return;
const newQuery = { ...router.query };
if (debouncedSearchQuery) {
newQuery.q = debouncedSearchQuery;
} else {
delete newQuery.q;
}
router.replace({ pathname: router.pathname, query: newQuery }, undefined, {
shallow: true,
});
}, [debouncedSearchQuery]);
const filteredProjects = useMemo(() => {
if (!data) return [];
// First filter by search query
const filtered = data.filter(
(project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
project.description?.toLowerCase().includes(searchQuery.toLowerCase()),
project.name
.toLowerCase()
.includes(debouncedSearchQuery.toLowerCase()) ||
project.description
?.toLowerCase()
.includes(debouncedSearchQuery.toLowerCase()),
);
// Then sort the filtered results
@@ -128,13 +165,18 @@ export const ShowProjects = () => {
}
return direction === "asc" ? comparison : -comparison;
});
}, [data, searchQuery, sortBy]);
}, [data, debouncedSearchQuery, sortBy]);
return (
<>
<BreadcrumbSidebar
list={[{ name: "Projects", href: "/dashboard/projects" }]}
/>
{!isCloud && (
<div className="absolute top-4 right-4">
<TimeBadge />
</div>
)}
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
<div className="rounded-xl bg-background shadow-md ">
@@ -148,8 +190,9 @@ export const ShowProjects = () => {
Create and manage your projects
</CardDescription>
</CardHeader>
{(auth?.role === "owner" || auth?.canCreateProjects) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canCreateProjects) && (
<div className="">
<HandleProject />
</div>
@@ -245,13 +288,29 @@ export const ShowProjects = () => {
)
.some(Boolean);
// Find default environment from accessible environments, or fall back to first accessible environment
const accessibleEnvironment =
project?.environments.find((env) => env.isDefault) ||
project?.environments?.[0];
const hasNoEnvironments = !accessibleEnvironment;
return (
<div
key={project.projectId}
className="w-full lg:max-w-md"
>
<Link
href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`}
href={
hasNoEnvironments
? "#"
: `/dashboard/project/${project.projectId}/environment/${accessibleEnvironment?.environmentId}`
}
onClick={(e) => {
if (hasNoEnvironments) {
e.preventDefault();
}
}}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
{haveServicesWithDomains ? (
@@ -298,7 +357,13 @@ export const ShowProjects = () => {
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}
>
<span className="truncate">
{domain.host}
@@ -340,7 +405,13 @@ export const ShowProjects = () => {
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}
>
<span className="truncate">
{domain.host}
@@ -360,7 +431,7 @@ export const ShowProjects = () => {
) : null}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5">
<span className="flex flex-col gap-1.5 ">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none">
@@ -368,9 +439,19 @@ export const ShowProjects = () => {
</span>
</div>
<span className="text-sm font-medium text-muted-foreground">
<span className="text-sm font-medium text-muted-foreground break-all">
{project.description}
</span>
{hasNoEnvironments && (
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
<span className="text-xs text-yellow-600 dark:text-yellow-400">
You have access to this project but no
environments are available
</span>
</div>
)}
</span>
<div className="flex self-start space-x-1">
<DropdownMenu>
@@ -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
@@ -89,24 +89,26 @@ export const SearchCommand = () => {
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => {
const productionEnvironment = project.environments.find(
(environment) => environment.name === "production",
);
// Find default environment from accessible environments, or fall back to first accessible environment
const defaultEnvironment =
project.environments.find(
(environment) => environment.isDefault,
) || project?.environments?.[0];
if (!productionEnvironment) return null;
if (!defaultEnvironment) return null;
return (
<CommandItem
key={project.projectId}
onSelect={() => {
router.push(
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
`/dashboard/project/${project.projectId}/environment/${defaultEnvironment.environmentId}`,
);
setOpen(false);
}}
>
<BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name} / {productionEnvironment!.name}
{project.name} / {defaultEnvironment.name}
</CommandItem>
);
})}
@@ -0,0 +1,74 @@
import { CreditCard, FileText } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { ShowInvoices } from "./show-invoices";
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
export const ShowBillingInvoices = () => {
const router = useRouter();
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = router.pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
)}
>
<Icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
<div className="mt-6">
<ShowInvoices />
</div>
</CardContent>
</div>
</Card>
</div>
);
};
@@ -4,11 +4,13 @@ import {
AlertTriangle,
CheckIcon,
CreditCard,
FileText,
Loader2,
MinusIcon,
PlusIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => {
if (count <= 1) return 4.5;
return count * 3.5;
};
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
export const ShowBilling = () => {
const router = useRouter();
const { data: servers } = api.server.count.useQuery();
const { data: admin } = api.user.get.useQuery();
const { data, isLoading } = api.stripe.getProducts.useQuery();
@@ -76,17 +93,41 @@ export const ShowBilling = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>Manage your subscription</CardDescription>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="flex flex-col gap-4 w-full">
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = router.pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
)}
>
<Icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
<div className="flex flex-col gap-4 w-full mt-6">
<Tabs
defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"}
@@ -0,0 +1,137 @@
import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
import type Stripe from "stripe";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const formatDate = (timestamp: number | null) => {
if (!timestamp) return "-";
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
const formatAmount = (amount: number, currency: string) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
};
const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
const statusConfig: Record<
Stripe.Invoice.Status,
{ label: string; variant: "default" | "secondary" | "destructive" }
> = {
paid: { label: "Paid", variant: "default" },
open: { label: "Open", variant: "secondary" },
draft: { label: "Draft", variant: "secondary" },
void: { label: "Void", variant: "destructive" },
uncollectible: { label: "Uncollectible", variant: "destructive" },
};
if (!status) {
return <Badge variant="secondary">Unknown</Badge>;
}
const config = statusConfig[status] || {
label: status,
variant: "secondary" as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
};
export const ShowInvoices = () => {
const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery();
return (
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center min-h-[20vh]">
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center">
Loading invoices...
<Loader2 className="animate-spin" />
</span>
</div>
) : invoices && invoices.length > 0 ? (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Date</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell className="font-medium">
{invoice.number || invoice.id.slice(0, 12)}
</TableCell>
<TableCell>{formatDate(invoice.created)}</TableCell>
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
<TableCell>
{formatAmount(invoice.amountDue, invoice.currency)}
</TableCell>
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{invoice.hostedInvoiceUrl && (
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
invoice.hostedInvoiceUrl || "",
"_blank",
)
}
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
{invoice.invoicePdf && (
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(invoice.invoicePdf || "", "_blank")
}
>
<Download className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center justify-center min-h-[20vh] gap-2">
<FileText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No invoices found</p>
<p className="text-sm text-muted-foreground">
Your invoices will appear here once you have a subscription
</p>
</div>
)}
</div>
);
};
@@ -42,12 +42,38 @@ const AddRegistrySchema = z.object({
username: z.string().min(1, {
message: "Username is required",
}),
password: z.string().min(1, {
message: "Password is required",
}),
registryUrl: z.string(),
password: z.string(),
registryUrl: z
.string()
.optional()
.refine(
(val) => {
// If empty or undefined, skip validation (field is optional)
if (!val || val.trim().length === 0) {
return true;
}
// Validate that it's a valid hostname (no protocol, no path, optional port)
// Valid formats: example.com, registry.example.com, [::1], example.com:5000
// Invalid: https://example.com, example.com/path
const trimmed = val.trim();
// Check for protocol or path - these are not allowed
if (/^https?:\/\//i.test(trimmed) || trimmed.includes("/")) {
return false;
}
// Basic hostname validation: allow alphanumeric, dots, hyphens, underscores, and IPv6 in brackets
// Allow optional port at the end
const hostnameRegex =
/^(?:\[[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?)(?::\d+)?$/;
return hostnameRegex.test(trimmed);
},
{
message:
"Invalid registry URL. Please enter only the hostname (e.g., example.com or registry.example.com). Do not include protocol (https://) or paths.",
},
),
imagePrefix: z.string(),
serverId: z.string().optional(),
isEditing: z.boolean().optional(),
});
type AddRegistry = z.infer<typeof AddRegistrySchema>;
@@ -74,13 +100,21 @@ export const HandleRegistry = ({ registryId }: Props) => {
const { mutateAsync, error, isError } = registryId
? api.registry.update.useMutation()
: api.registry.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: deployServers } = api.server.withSSHKey.useQuery();
const { data: buildServers } = api.server.buildServers.useQuery();
const servers = [...(deployServers || []), ...(buildServers || [])];
const {
mutateAsync: testRegistry,
isLoading,
error: testRegistryError,
isError: testRegistryIsError,
} = api.registry.testRegistry.useMutation();
const {
mutateAsync: testRegistryById,
isLoading: isLoadingById,
error: testRegistryByIdError,
isError: testRegistryByIdIsError,
} = api.registry.testRegistryById.useMutation();
const form = useForm<AddRegistry>({
defaultValues: {
username: "",
@@ -89,8 +123,26 @@ export const HandleRegistry = ({ registryId }: Props) => {
imagePrefix: "",
registryName: "",
serverId: "",
isEditing: !!registryId,
},
resolver: zodResolver(AddRegistrySchema),
resolver: zodResolver(
AddRegistrySchema.refine(
(data) => {
// When creating a new registry, password is required
if (
!data.isEditing &&
(!data.password || data.password.length === 0)
) {
return false;
}
return true;
},
{
message: "Password is required",
path: ["password"],
},
),
),
});
const password = form.watch("password");
@@ -99,6 +151,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
const registryName = form.watch("registryName");
const imagePrefix = form.watch("imagePrefix");
const serverId = form.watch("serverId");
const selectedServer = servers?.find(
(server) => server.serverId === serverId,
);
useEffect(() => {
if (registry) {
@@ -108,6 +163,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryUrl: registry.registryUrl,
imagePrefix: registry.imagePrefix || "",
registryName: registry.registryName,
isEditing: true,
});
} else {
form.reset({
@@ -116,21 +172,29 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryUrl: "",
imagePrefix: "",
serverId: "",
isEditing: false,
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
const onSubmit = async (data: AddRegistry) => {
await mutateAsync({
password: data.password,
const payload: any = {
registryName: data.registryName,
username: data.username,
registryUrl: data.registryUrl,
registryUrl: data.registryUrl || "",
registryType: "cloud",
imagePrefix: data.imagePrefix,
serverId: data.serverId,
registryId: registryId || "",
})
};
// Only include password if it's been provided (not empty)
// When editing, empty password means "keep the existing password"
if (data.password && data.password.length > 0) {
payload.password = data.password;
}
await mutateAsync(payload)
.then(async (_data) => {
await utils.registry.all.invalidate();
toast.success(registryId ? "Registry updated" : "Registry added");
@@ -168,11 +232,14 @@ export const HandleRegistry = ({ registryId }: Props) => {
Fill the next fields to add a external registry.
</DialogDescription>
</DialogHeader>
{(isError || testRegistryIsError) && (
{(isError || testRegistryIsError || testRegistryByIdIsError) && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{testRegistryError?.message || error?.message || ""}
{testRegistryError?.message ||
testRegistryByIdError?.message ||
error?.message ||
""}
</span>
</div>
)}
@@ -223,10 +290,20 @@ export const HandleRegistry = ({ registryId }: Props) => {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>Password{registryId && " (Optional)"}</FormLabel>
{registryId && (
<FormDescription>
Leave blank to keep existing password. Enter new
password to test or update it.
</FormDescription>
)}
<FormControl>
<Input
placeholder="Password"
placeholder={
registryId
? "Leave blank to keep existing"
: "Password"
}
autoComplete="one-time-code"
{...field}
type="password"
@@ -261,6 +338,10 @@ export const HandleRegistry = ({ registryId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel>Registry URL</FormLabel>
<FormDescription>
Enter only the hostname (e.g.,
aws_account_id.dkr.ecr.us-west-2.amazonaws.com).
</FormDescription>
<FormControl>
<Input
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
@@ -282,8 +363,40 @@ export const HandleRegistry = ({ registryId }: Props) => {
<FormItem>
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
<FormDescription>
Select a server to test the registry. this will run the
following command on the server
{!isCloud ? (
<>
{serverId && serverId !== "none" && selectedServer ? (
<>
Authentication will be performed on{" "}
<strong>{selectedServer.name}</strong>. This
registry will be available on this server.
</>
) : (
<>
Choose where to authenticate with the registry. By
default, authentication occurs on the Dokploy
server. Select a specific server to authenticate
from that server instead.
</>
)}
</>
) : (
<>
{serverId && serverId !== "none" && selectedServer ? (
<>
Authentication will be performed on{" "}
<strong>{selectedServer.name}</strong>. This
registry will be available on this server.
</>
) : (
<>
Select a server to authenticate with the registry.
The authentication will be performed from the
selected server.
</>
)}
</>
)}
</FormDescription>
<FormControl>
<Select
@@ -294,16 +407,33 @@ export const HandleRegistry = ({ registryId }: Props) => {
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
{deployServers && deployServers.length > 0 && (
<SelectGroup>
<SelectLabel>Deploy Servers</SelectLabel>
{deployServers.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectGroup>
)}
{buildServers && buildServers.length > 0 && (
<SelectGroup>
<SelectLabel>Build Servers</SelectLabel>
{buildServers.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectGroup>
)}
<SelectGroup>
<SelectLabel>Servers</SelectLabel>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem>
</SelectGroup>
</SelectContent>
@@ -321,8 +451,37 @@ export const HandleRegistry = ({ registryId }: Props) => {
<Button
type="button"
variant={"secondary"}
isLoading={isLoading}
isLoading={isLoading || isLoadingById}
onClick={async () => {
// When editing with empty password, use the existing password from DB
if (registryId && (!password || password.length === 0)) {
await testRegistryById({
registryId: registryId || "",
...(serverId && { serverId }),
})
.then((data) => {
if (data) {
toast.success("Registry Tested Successfully");
} else {
toast.error("Registry Test Failed");
}
})
.catch(() => {
toast.error("Error testing the registry");
});
return;
}
// When creating, password is required
if (!registryId && (!password || password.length === 0)) {
form.setError("password", {
type: "manual",
message: "Password is required",
});
return;
}
// When creating or editing with new password, validate and test with provided credentials
const validationResult = AddRegistrySchema.safeParse({
username,
password,
@@ -330,6 +489,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryName: "Dokploy Registry",
imagePrefix,
serverId,
isEditing: !!registryId,
});
if (!validationResult.success) {
@@ -345,7 +505,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
await testRegistry({
username: username,
password: password,
registryUrl: registryUrl,
registryUrl: registryUrl || "",
registryName: registryName,
registryType: "cloud",
imagePrefix: imagePrefix,
@@ -122,6 +122,9 @@ export const HandleDestinations = ({ destinationId }: Props) => {
.then(async () => {
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
await utils.destination.all.invalidate();
if (destinationId) {
await utils.destination.one.invalidate({ destinationId });
}
setOpen(false);
})
.catch(() => {
@@ -48,7 +48,7 @@ export const ShowGitProviders = () => {
) => {
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
const scope = "api read_user read_repository";
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scopes=${encodeURIComponent(scope)}`;
return authUrl;
};
@@ -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>
);
}}
/>
)}
@@ -1,5 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
import {
AlertTriangle,
Mail,
PenBoxIcon,
PlusIcon,
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -10,6 +16,7 @@ import {
GotifyIcon,
LarkIcon,
NtfyIcon,
PushoverIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -45,6 +52,7 @@ const notificationBaseSchema = z.object({
appDeploy: z.boolean().default(false),
appBuildError: z.boolean().default(false),
databaseBackup: z.boolean().default(false),
volumeBackup: z.boolean().default(false),
dokployRestart: z.boolean().default(false),
dockerCleanup: z.boolean().default(false),
serverThreshold: z.boolean().default(false),
@@ -104,7 +112,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),
@@ -116,7 +124,32 @@ export const notificationSchema = z.discriminatedUnion("type", [
username: z.string().optional(),
})
.merge(notificationBaseSchema),
z
z
.object({
type: z.literal("pushover"),
userKey: z.string().min(1, { message: "User Key is required" }),
apiToken: z.string().min(1, { message: "API Token is required" }),
priority: z.number().min(-2).max(2).default(0),
retry: z.number().min(30).nullish(),
expire: z.number().min(1).max(10800).nullish(),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("custom"),
endpoint: z.string().min(1, { message: "Endpoint URL is required" }),
headers: z
.array(
z.object({
key: z.string(),
value: z.string(),
}),
)
.optional()
.default([]),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("lark"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
@@ -157,6 +190,14 @@ export const notificationsMap = {
icon: <MattermostIcon />,
label: "Mattermost",
},
pushover: {
icon: <PushoverIcon />,
label: "Pushover",
},
custom: {
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
label: "Custom",
},
};
export type NotificationSchema = z.infer<typeof notificationSchema>;
@@ -194,6 +235,14 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testMattermostConnection.useMutation();
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
api.notification.testLarkConnection.useMutation();
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
api.notification.testCustomConnection.useMutation();
const { mutateAsync: testPushoverConnection, isLoading: isLoadingPushover } =
api.notification.testPushoverConnection.useMutation();
const customMutation = notificationId
? api.notification.updateCustom.useMutation()
: api.notification.createCustom.useMutation();
const slackMutation = notificationId
? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation();
@@ -218,6 +267,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
const pushoverMutation = notificationId
? api.notification.updatePushover.useMutation()
: api.notification.createPushover.useMutation();
const form = useForm<NotificationSchema>({
defaultValues: {
@@ -235,6 +287,15 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: "toAddresses" as never,
});
const {
fields: headerFields,
append: appendHeader,
remove: removeHeader,
} = useFieldArray({
control: form.control,
name: "headers" as never,
});
useEffect(() => {
if (type === "email" && fields.length === 0) {
append("");
@@ -249,6 +310,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
dockerCleanup: notification.dockerCleanup,
webhookUrl: notification.slack?.webhookUrl,
channel: notification.slack?.channel || "",
@@ -262,6 +324,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
botToken: notification.telegram?.botToken,
messageThreadId: notification.telegram?.messageThreadId || "",
chatId: notification.telegram?.chatId,
@@ -276,6 +339,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.discord?.webhookUrl,
decoration: notification.discord?.decoration || undefined,
@@ -289,6 +353,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
smtpServer: notification.email?.smtpServer,
smtpPort: notification.email?.smtpPort,
@@ -306,6 +371,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
appToken: notification.gotify?.appToken,
decoration: notification.gotify?.decoration || undefined,
@@ -320,8 +386,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
accessToken: notification.ntfy?.accessToken,
accessToken: notification.ntfy?.accessToken || "",
topic: notification.ntfy?.topic,
priority: notification.ntfy?.priority,
serverUrl: notification.ntfy?.serverUrl,
@@ -329,6 +396,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "mattermost") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.mattermost?.webhookUrl,
channel: notification.mattermost?.channel || "",
username: notification.mattermost?.username || "",
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "lark") {
form.reset({
appBuildError: notification.appBuildError,
@@ -339,18 +421,43 @@ export const HandleNotifications = ({ notificationId }: Props) => {
webhookUrl: notification.lark?.webhookUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
volumeBackup: notification.volumeBackup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "mattermost") {
} else if (notification.notificationType === "custom") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
webhookUrl: notification.mattermost?.webhookUrl,
channel: notification.mattermost?.channel || "",
username: notification.mattermost?.username || "",
endpoint: notification.custom?.endpoint || "",
headers: notification.custom?.headers
? Object.entries(notification.custom.headers).map(
([key, value]) => ({
key,
value,
}),
)
: [],
name: notification.name,
volumeBackup: notification.volumeBackup,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "pushover") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
userKey: notification.pushover?.userKey,
apiToken: notification.pushover?.apiToken,
priority: notification.pushover?.priority,
retry: notification.pushover?.retry ?? undefined,
expire: notification.pushover?.expire ?? undefined,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
@@ -370,6 +477,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
ntfy: ntfyMutation,
mattermost: mattermostMutation,
lark: larkMutation,
custom: customMutation,
pushover: pushoverMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -378,6 +487,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy,
dokployRestart,
databaseBackup,
volumeBackup,
dockerCleanup,
serverThreshold,
} = data;
@@ -388,6 +498,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
channel: data.channel,
name: data.name,
@@ -402,6 +513,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
botToken: data.botToken,
messageThreadId: data.messageThreadId || "",
chatId: data.chatId,
@@ -417,6 +529,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
decoration: data.decoration,
name: data.name,
@@ -431,6 +544,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
smtpServer: data.smtpServer,
smtpPort: data.smtpPort,
username: data.username,
@@ -449,6 +563,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
serverUrl: data.serverUrl,
appToken: data.appToken,
priority: data.priority,
@@ -464,8 +579,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
serverUrl: data.serverUrl,
accessToken: data.accessToken,
accessToken: data.accessToken || "",
topic: data.topic,
priority: data.priority,
name: data.name,
@@ -479,6 +595,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
channel: data.channel || undefined,
username: data.username || undefined,
@@ -494,15 +611,63 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
larkId: notification?.larkId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "custom") {
// Convert headers array to object
const headersRecord =
data.headers && data.headers.length > 0
? data.headers.reduce(
(acc, { key, value }) => {
if (key.trim()) acc[key] = value;
return acc;
},
{} as Record<string, string>,
)
: undefined;
promise = customMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
endpoint: data.endpoint,
headers: headersRecord,
name: data.name,
dockerCleanup: dockerCleanup,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
customId: notification?.customId || "",
});
} else if (data.type === "pushover") {
if (data.priority === 2 && (data.retry == null || data.expire == null)) {
toast.error("Retry and expire are required for emergency priority (2)");
return;
}
promise = pushoverMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
userKey: data.userKey,
apiToken: data.apiToken,
priority: data.priority,
retry: data.priority === 2 ? data.retry : undefined,
expire: data.priority === 2 ? data.expire : undefined,
name: data.name,
dockerCleanup: dockerCleanup,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
pushoverId: notification?.pushoverId || "",
});
}
if (promise) {
@@ -1051,8 +1216,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<Input
placeholder="AzxcvbnmKjhgfdsa..."
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
Optional. Leave blank for public topics.
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -1144,6 +1313,94 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "custom" && (
<div className="space-y-4">
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://api.example.com/webhook"
{...field}
/>
</FormControl>
<FormDescription>
The URL where POST requests will be sent with
notification data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<div>
<FormLabel>Headers</FormLabel>
<FormDescription>
Optional. Custom headers for your POST request (e.g.,
Authorization, Content-Type).
</FormDescription>
</div>
<div className="space-y-2">
{headerFields.map((field, index) => (
<div
key={field.id}
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
>
<FormField
control={form.control}
name={`headers.${index}.key` as never}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="Key" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`headers.${index}.value` as never}
render={({ field }) => (
<FormItem className="flex-[2]">
<FormControl>
<Input placeholder="Value" {...field} />
</FormControl>
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeHeader(index)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendHeader({ key: "", value: "" })}
className="w-full"
>
<PlusIcon className="h-4 w-4 mr-2" />
Add header
</Button>
</div>
</div>
)}
{type === "lark" && (
<>
<FormField
@@ -1164,6 +1421,148 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "pushover" && (
<>
<FormField
control={form.control}
name="userKey"
render={({ field }) => (
<FormItem>
<FormLabel>User Key</FormLabel>
<FormControl>
<Input placeholder="ub3de9kl2q..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input placeholder="a3d9k2q7m4..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
defaultValue={0}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
placeholder="0"
value={field.value ?? 0}
onChange={(e) => {
const value = e.target.value;
if (value === "" || value === "-") {
field.onChange(0);
} else {
const priority = Number.parseInt(value);
if (
!Number.isNaN(priority) &&
priority >= -2 &&
priority <= 2
) {
field.onChange(priority);
}
}
}}
type="number"
min={-2}
max={2}
/>
</FormControl>
<FormDescription>
Message priority (-2 to 2, default: 0, emergency: 2)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("priority") === 2 && (
<>
<FormField
control={form.control}
name="retry"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Retry (seconds)</FormLabel>
<FormControl>
<Input
placeholder="30"
{...field}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
} else {
const retry = Number.parseInt(value);
if (!Number.isNaN(retry)) {
field.onChange(retry);
}
}
}}
type="number"
min={30}
/>
</FormControl>
<FormDescription>
How often (in seconds) to retry. Minimum 30
seconds.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expire"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Expire (seconds)</FormLabel>
<FormControl>
<Input
placeholder="3600"
{...field}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
} else {
const expire = Number.parseInt(value);
if (!Number.isNaN(expire)) {
field.onChange(expire);
}
}
}}
type="number"
min={1}
max={10800}
/>
</FormControl>
<FormDescription>
How long to keep retrying (max 10800 seconds / 3
hours).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</>
)}
</div>
</div>
<div className="flex flex-col gap-4">
@@ -1234,6 +1633,27 @@ export const HandleNotifications = ({ notificationId }: Props) => {
)}
/>
<FormField
control={form.control}
name="volumeBackup"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>Volume Backup</FormLabel>
<FormDescription>
Trigger the action when a volume backup is created.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="dockerCleanup"
@@ -1316,64 +1736,105 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingGotify ||
isLoadingNtfy ||
isLoadingMattermost ||
isLoadingLark
isLoadingLark ||
isLoadingCustom ||
isLoadingPushover
}
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 === "mattermost") {
} else if (data.type === "mattermost") {
await testMattermostConnection({
webhookUrl: form.getValues("webhookUrl"),
channel: form.getValues("channel") || undefined,
username: form.getValues("username") || undefined,
webhookUrl: data.webhookUrl,
channel: data.channel || undefined,
username: data.username || undefined,
});
} else if (type === "lark") {
} else if (data.type === "lark") {
await testLarkConnection({
webhookUrl: form.getValues("webhookUrl"),
webhookUrl: data.webhookUrl,
});
} else if (data.type === "custom") {
const headersRecord =
data.headers && data.headers.length > 0
? data.headers.reduce(
(acc, { key, value }) => {
if (key.trim()) acc[key] = value;
return acc;
},
{} as Record<string, string>,
)
: undefined;
await testCustomConnection({
endpoint: data.endpoint,
headers: headersRecord,
});
} else if (data.type === "pushover") {
if (
data.priority === 2 &&
(data.retry == null || data.expire == null)
) {
throw new Error(
"Retry and expire are required for emergency priority (2)",
);
}
await testPushoverConnection({
userKey: data.userKey,
apiToken: data.apiToken,
priority: data.priority,
retry: data.priority === 2 ? data.retry : undefined,
expire: data.priority === 2 ? data.expire : undefined,
});
}
toast.success("Connection Success");
} catch {
toast.error("Error testing the provider");
} catch (error) {
toast.error(
`Error testing the provider: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}}
>
@@ -1,4 +1,4 @@
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
import { Bell, Loader2, Mail, PenBoxIcon, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
DiscordIcon,
@@ -96,6 +96,11 @@ export const ShowNotifications = () => {
<NtfyIcon className="size-6" />
</div>
)}
{notification.notificationType === "custom" && (
<div className="flex items-center justify-center rounded-lg ">
<PenBoxIcon className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "lark" && (
<div className="flex items-center justify-center rounded-lg">
<LarkIcon className="size-7 text-muted-foreground" />
@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, User } from "lucide-react";
import { Loader2, Palette, User } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -27,6 +27,7 @@ import {
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils";
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import { Configure2FA } from "./configure-2fa";
@@ -40,7 +41,8 @@ const profileSchema = z.object({
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),
name: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
allowImpersonation: z.boolean().optional().default(false),
});
@@ -73,6 +75,7 @@ export const ProfileForm = () => {
} = api.user.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
const availableAvatars = useMemo(() => {
if (gravatarHash === null) return randomImages;
@@ -88,7 +91,8 @@ export const ProfileForm = () => {
image: data?.user?.image || "",
currentPassword: "",
allowImpersonation: data?.user?.allowImpersonation || false,
name: data?.user?.name || "",
firstName: data?.user?.firstName || "",
lastName: data?.user?.lastName || "",
},
resolver: zodResolver(profileSchema),
});
@@ -102,7 +106,8 @@ export const ProfileForm = () => {
image: data?.user?.image || "",
currentPassword: form.getValues("currentPassword") || "",
allowImpersonation: data?.user?.allowImpersonation,
name: data?.user?.name || "",
firstName: data?.user?.firstName || "",
lastName: data?.user?.lastName || "",
},
{
keepValues: true,
@@ -126,7 +131,8 @@ export const ProfileForm = () => {
image: values.image,
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
name: values.name || undefined,
firstName: values.firstName || undefined,
lastName: values.lastName || undefined,
});
await refetch();
toast.success("Profile Updated");
@@ -135,7 +141,8 @@ export const ProfileForm = () => {
password: "",
image: values.image,
currentPassword: "",
name: values.name || "",
firstName: values.firstName || "",
lastName: values.lastName || "",
});
} catch (error) {
toast.error("Error updating the profile");
@@ -177,12 +184,25 @@ export const ProfileForm = () => {
<div className="space-y-4">
<FormField
control={form.control}
name="name"
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Name" {...field} />
<Input placeholder="John" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -256,16 +276,8 @@ export const ProfileForm = () => {
onValueChange={(e) => {
field.onChange(e);
}}
defaultValue={
field.value?.startsWith("data:")
? "upload"
: field.value
}
value={
field.value?.startsWith("data:")
? "upload"
: field.value
}
defaultValue={getAvatarType(field.value)}
value={getAvatarType(field.value)}
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
>
<FormItem key="no-avatar">
@@ -280,7 +292,7 @@ export const ProfileForm = () => {
<Avatar className="default-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform">
<AvatarFallback className="rounded-lg">
{getFallbackAvatarInitials(
data?.user?.name,
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
)}
</AvatarFallback>
</Avatar>
@@ -352,6 +364,40 @@ export const ProfileForm = () => {
/>
</FormLabel>
</FormItem>
<FormItem key="color-avatar">
<FormLabel className="[&:has([data-state=checked])>.color-avatar]:border-primary [&:has([data-state=checked])>.color-avatar]:border-1 [&:has([data-state=checked])>.color-avatar]:p-px cursor-pointer relative">
<FormControl>
<RadioGroupItem
value="color"
className="sr-only"
/>
</FormControl>
<div
className="color-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-colors flex items-center justify-center overflow-hidden cursor-pointer"
style={{
backgroundColor: isSolidColorAvatar(
field.value,
)
? field.value
: undefined,
}}
onClick={() =>
colorInputRef.current?.click()
}
>
{!isSolidColorAvatar(field.value) && (
<Palette className="h-5 w-5 text-muted-foreground" />
)}
</div>
<input
ref={colorInputRef}
type="color"
className="absolute opacity-0 pointer-events-none w-12 h-12 top-0 left-0"
value={field.value}
onChange={field.onChange}
/>
</FormLabel>
</FormItem>
{availableAvatars.map((image) => (
<FormItem key={image}>
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
@@ -1,4 +1,6 @@
import { Activity } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -13,20 +15,30 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
interface Props {
serverId: string;
asButton?: boolean;
}
export const ShowServerActions = ({ serverId }: Props) => {
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Activity className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
View Actions
</DropdownMenuItem>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-xl">
<div className="flex flex-col gap-1">
<DialogTitle className="text-xl">Web server settings</DialogTitle>
@@ -173,7 +173,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
serverId: serverId,
})
.then(async () => {
toast.success("Cleaned all");
toast.success("Cleaning in progress... Please wait");
})
.catch(() => {
toast.error("Error cleaning all");
@@ -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()}
@@ -7,9 +7,12 @@ interface Props {
serverId?: string;
}
export const ToggleDockerCleanup = ({ serverId }: Props) => {
const { data, refetch } = api.user.get.useQuery(undefined, {
enabled: !serverId,
});
const { data, refetch } = api.settings.getWebServerSettings.useQuery(
undefined,
{
enabled: !serverId,
},
);
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
{
@@ -22,7 +25,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
const enabled = serverId
? server?.enableDockerCleanup
: data?.user.enableDockerCleanup;
: data?.enableDockerCleanup;
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
@@ -30,7 +33,10 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
try {
await mutateAsync({
enableDockerCleanup: checked,
serverId: serverId,
...(serverId && { serverId }),
} as {
enableDockerCleanup: boolean;
serverId?: string;
});
if (serverId) {
await refetchServer();
@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { Pencil, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";
@@ -52,15 +52,17 @@ 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>;
interface Props {
serverId?: string;
asButton?: boolean;
}
export const HandleServers = ({ serverId }: Props) => {
export const HandleServers = ({ serverId, asButton = false }: Props) => {
const { t } = useTranslation("settings");
const utils = api.useUtils();
@@ -89,6 +91,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: 22,
username: "root",
sshKeyId: "",
serverType: "deploy",
},
resolver: zodResolver(Schema),
});
@@ -101,6 +104,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 +120,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) => {
@@ -133,21 +138,32 @@ export const HandleServers = ({ serverId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{serverId ? (
{serverId ? (
asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
Edit Server
</DropdownMenuItem>
) : (
)
) : (
<DialogTrigger asChild>
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Create Server
</Button>
)}
</DialogTrigger>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-3xl ">
<DialogHeader>
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
@@ -266,6 +282,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"
@@ -80,7 +80,7 @@ const Schema = z.object({
type Schema = z.infer<typeof Schema>;
export const SetupMonitoring = ({ serverId }: Props) => {
const { data } = serverId
const { data: serverData } = serverId
? api.server.one.useQuery(
{
serverId: serverId || "",
@@ -89,7 +89,14 @@ export const SetupMonitoring = ({ serverId }: Props) => {
enabled: !!serverId,
},
)
: api.user.getServerMetrics.useQuery();
: { data: null };
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery(undefined, {
enabled: !serverId,
});
const data = serverId ? serverData : webServerSettings;
const url = useUrl();
@@ -1,5 +1,5 @@
import copy from "copy-to-clipboard";
import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react";
import { CopyIcon, ExternalLinkIcon, ServerIcon, Settings } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
@@ -22,7 +22,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
@@ -36,9 +35,10 @@ import { ValidateServer } from "./validate-server";
interface Props {
serverId: string;
asButton?: boolean;
}
export const SetupServer = ({ serverId }: Props) => {
export const SetupServer = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery(
{
@@ -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);
@@ -80,14 +81,23 @@ export const SetupServer = ({ serverId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
{asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<Button
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
size="sm"
onClick={() => {
setIsOpen(true);
}}
>
Setup Server
</DropdownMenuItem>
</DialogTrigger>
Setup Server <Settings className="size-4" />
</Button>
)}
<DialogContent className="sm:max-w-4xl ">
<DialogHeader>
<div className="flex flex-col gap-1.5">
@@ -117,17 +127,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 +330,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>
)}
@@ -1,5 +1,16 @@
import { format } from "date-fns";
import { KeyIcon, Loader2, MoreHorizontal, ServerIcon } from "lucide-react";
import {
Clock,
Key,
KeyIcon,
Loader2,
MoreHorizontal,
Network,
ServerIcon,
Terminal,
Trash2,
User,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
@@ -18,20 +29,15 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal";
@@ -59,7 +65,7 @@ export const ShowServers = () => {
return (
<div className="w-full">
{query?.success && isCloud && <WelcomeSuscription />}
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -114,185 +120,44 @@ export const ShowServers = () => {
<HandleServers />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<Table>
<TableCaption>
<div className="flex flex-col gap-4">
See all servers
</div>
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="text-left">Name</TableHead>
{isCloud && (
<TableHead className="text-center">
Status
</TableHead>
)}
<TableHead className="text-center">
IP Address
</TableHead>
<TableHead className="text-center">
Port
</TableHead>
<TableHead className="text-center">
Username
</TableHead>
<TableHead className="text-center">
SSH Key
</TableHead>
<TableHead className="text-center">
Created
</TableHead>
<TableHead className="text-right">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
return (
<TableRow key={server.serverId}>
<TableCell className="text-left">
{server.name}
</TableCell>
{isCloud && (
<TableHead className="text-center">
<Badge
variant={
server.serverStatus === "active"
? "default"
: "destructive"
}
>
{server.serverStatus}
</Badge>
</TableHead>
)}
<TableCell className="text-center">
<Badge>{server.ipAddress}</Badge>
</TableCell>
<TableCell className="text-center">
{server.port}
</TableCell>
<TableCell className="text-center">
{server.username}
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-muted-foreground">
{server.sshKeyId ? "Yes" : "No"}
</span>
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-muted-foreground">
{format(
new Date(server.createdAt),
"PPpp",
)}
</span>
</TableCell>
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
{isActive && (
<>
{server.sshKeyId && (
<TerminalModal
serverId={server.serverId}
>
<span>
{t(
"settings.common.enterTerminal",
)}
</span>
</TerminalModal>
)}
<SetupServer
serverId={server.serverId}
/>
<HandleServers
serverId={server.serverId}
/>
{server.sshKeyId && (
<ShowServerActions
serverId={server.serverId}
/>
)}
</>
)}
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this server
because it has active services.
<AlertBlock type="warning">
You have active services
associated with this server,
please delete them first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Delete Server
</DropdownMenuItem>
</DialogAction>
{isActive && server.sshKeyId && (
<>
<DropdownMenuSeparator />
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
const isBuildServer = server.serverType === "build";
return (
<Card
key={server.serverId}
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<ServerIcon className="size-5 text-muted-foreground" />
<CardTitle className="text-lg">
{server.name}
</CardTitle>
</div>
{isActive &&
server.sshKeyId &&
!isBuildServer && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
More options
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Extra
Advanced
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
@@ -308,29 +173,267 @@ export const ShowServers = () => {
}
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<TooltipProvider>
<div className="flex gap-2 mt-2 flex-wrap">
{isCloud && (
<>
{server.serverStatus === "active" ? (
<Badge variant="default">
{server.serverStatus}
</Badge>
) : (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="inline-block">
<Badge
variant="destructive"
className="cursor-help"
>
{server.serverStatus}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent
className="max-w-xs"
side="bottom"
>
<p className="text-sm">
This server is deactivated due
to lack of payment. Please pay
your invoice to reactivate it.
If you think this is an error,
please contact support.
</p>
</TooltipContent>
</Tooltip>
)}
</>
)}
<Badge
variant={
isBuildServer
? "secondary"
: "default"
}
>
{server.serverType}
</Badge>
</div>
</TooltipProvider>
</CardHeader>
<CardContent className="space-y-3 flex-1 flex flex-col">
<div className="flex items-center gap-2 text-sm">
<Network className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
IP:
</span>
<Badge variant="outline">
{server.ipAddress}
</Badge>
<span className="text-muted-foreground">
Port:
</span>
<span className="font-medium">
{server.port}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<User className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
User:
</span>
<span className="font-medium">
{server.username}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Key className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
SSH Key:
</span>
<span className="font-medium">
{server.sshKeyId ? "Yes" : "No"}
</span>
</div>
<div className="flex items-center gap-2 text-sm pt-2 border-t">
<Clock className="size-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Created{" "}
{format(
new Date(server.createdAt),
"PPp",
)}
</span>
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
{/* Compact Actions */}
{isActive && (
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
<div className="flex items-center gap-2 w-full">
<Tooltip>
<TooltipTrigger asChild>
<SetupServer
serverId={server.serverId}
/>
</TooltipTrigger>
<TooltipContent
className="max-w-xs"
side="bottom"
>
<div className="space-y-1">
<p className="font-semibold">
Setup Server
</p>
<p className="text-xs text-muted-foreground">
Configure and initialize your
server with Docker, Traefik, and
other essential services
</p>
</div>
</TooltipContent>
</Tooltip>
</div>
<TooltipProvider>
{server.sshKeyId && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div>
<HandleServers
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Edit Server</p>
</TooltipContent>
</Tooltip>
{server.sshKeyId && !isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowServerActions
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Web Server Actions</p>
</TooltipContent>
</Tooltip>
)}
<div className="flex-1" />
<Tooltip>
<TooltipTrigger asChild>
<div>
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this
server because it has
active services.
<AlertBlock type="warning">
You have active services
associated with this
server, please delete
them first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Button
variant="ghost"
size="icon"
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogAction>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{canDelete
? "Delete Server"
: "Cannot delete - has active services"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mt-4">
{data && data?.length > 0 && (
<div>
<HandleServers />
@@ -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");
@@ -158,6 +158,7 @@ export const AddInvitation = () => {
</FormControl>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormDescription>
@@ -1,4 +1,3 @@
import type { findEnvironmentById } from "@dokploy/server/index";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -27,12 +26,10 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { api, type RouterOutputs } from "@/utils/api";
type Environment = Omit<
Awaited<ReturnType<typeof findEnvironmentById>>,
"project"
>;
type Project = RouterOutputs["project"]["all"][number];
type Environment = Project["environments"][number];
export type Services = {
appName: string;
@@ -53,17 +50,16 @@ export type Services = {
};
export const extractServices = (data: Environment | undefined) => {
const applications: Services[] =
data?.applications.map((item) => ({
appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const applications: Services[] = (data?.applications?.map((item) => ({
appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) ?? []) as Services[];
const mariadb: Services[] =
data?.mariadb.map((item) => ({
@@ -125,17 +121,16 @@ export const extractServices = (data: Environment | undefined) => {
serverId: item.serverId,
})) || [];
const compose: Services[] =
data?.compose.map((item) => ({
appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,
createdAt: item.createdAt,
status: item.composeStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const compose: Services[] = (data?.compose?.map((item) => ({
appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,
createdAt: item.createdAt,
status: item.composeStatus,
description: item.description,
serverId: item.serverId,
})) ?? []) as Services[];
applications.push(
...mysql,
@@ -0,0 +1,159 @@
import { zodResolver } from "@hookform/resolvers/zod";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const changeRoleSchema = z.object({
role: z.enum(["admin", "member"]),
});
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
interface Props {
memberId: string;
currentRole: "admin" | "member";
userEmail: string;
}
export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isError, error, isLoading } =
api.organization.updateMemberRole.useMutation();
const form = useForm<ChangeRoleSchema>({
defaultValues: {
role: currentRole,
},
resolver: zodResolver(changeRoleSchema),
});
useEffect(() => {
if (isOpen) {
form.reset({
role: currentRole,
});
}
}, [form, currentRole, isOpen]);
const onSubmit = async (data: ChangeRoleSchema) => {
await mutateAsync({
memberId,
role: data.role,
})
.then(async () => {
toast.success("Role updated successfully");
await utils.user.all.invalidate();
setIsOpen(false);
})
.catch((error) => {
toast.error(error?.message || "Error updating role");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
Change Role
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-[85vh] sm:max-w-lg">
<DialogHeader>
<DialogTitle>Change User Role</DialogTitle>
<DialogDescription>
Change the role for <strong>{userEmail}</strong>
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-change-role"
onSubmit={form.handleSubmit(onSubmit)}
className="w-full space-y-4"
>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
</SelectContent>
</Select>
<FormDescription>
<strong>Admin:</strong> Can manage users and settings.
<br />
<strong>Member:</strong> Limited permissions, can be
customized.
<br />
<em className="text-muted-foreground text-xs">
Note: Owner role is intransferible.
</em>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-change-role"
type="submit"
>
Update Role
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -21,7 +21,6 @@ import {
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
@@ -30,12 +29,15 @@ import {
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { AddUserPermissions } from "./add-permissions";
import { ChangeRole } from "./change-role";
export const ShowUsers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isLoading, refetch } = api.user.all.useQuery();
const { mutateAsync } = api.user.remove.useMutation();
const utils = api.useUtils();
const { data: session } = authClient.useSession();
return (
<div className="w-full">
@@ -68,7 +70,6 @@ export const ShowUsers = () => {
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<Table>
<TableCaption>See all users</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Email</TableHead>
@@ -83,6 +84,52 @@ export const ShowUsers = () => {
</TableHeader>
<TableBody>
{data?.map((member) => {
const currentUserRole = data?.find(
(m) => m.user.id === session?.user?.id,
)?.role;
// Owner never has "Edit Permissions" (they're absolute owner)
// Other users can edit permissions if target is not themselves and target is a member
const canEditPermissions =
member.role !== "owner" &&
member.role === "member" &&
member.user.id !== session?.user?.id;
// Can change role based on hierarchy:
// - Owner: Can change anyone's role (except themselves and other owners)
// - Admin: Can only change member roles (not other admins or owners)
// - Owner role is intransferible
const canChangeRole =
member.role !== "owner" &&
member.user.id !== session?.user?.id &&
(currentUserRole === "owner" ||
(currentUserRole === "admin" &&
member.role === "member"));
// Delete/Unlink follow same hierarchy as role changes
// - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted)
// - Admin: Can only delete/unlink members (not other admins or owner)
const canDelete =
member.role !== "owner" &&
!isCloud &&
member.user.id !== session?.user?.id &&
(currentUserRole === "owner" ||
(currentUserRole === "admin" &&
member.role === "member"));
const canUnlink =
member.role !== "owner" &&
member.user.id !== session?.user?.id &&
(currentUserRole === "owner" ||
(currentUserRole === "admin" &&
member.role === "member"));
const hasAnyAction =
canEditPermissions ||
canChangeRole ||
canDelete ||
canUnlink;
return (
<TableRow key={member.id}>
<TableCell className="w-[100px]">
@@ -111,62 +158,73 @@ export const ShowUsers = () => {
</TableCell>
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
{hasAnyAction ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
{member.role !== "owner" && (
<AddUserPermissions
userId={member.user.id}
/>
)}
{canChangeRole && (
<ChangeRole
memberId={member.id}
currentRole={
member.role as "admin" | "member"
}
userEmail={member.user.email}
/>
)}
{member.role !== "owner" && (
<>
{!isCloud && (
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
onClick={async () => {
await mutateAsync({
userId: member.user.id,
{canEditPermissions && (
<AddUserPermissions
userId={member.user.id}
/>
)}
{canDelete && (
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
onClick={async () => {
await mutateAsync({
userId: member.user.id,
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting destination",
);
});
}}
.catch((err) => {
toast.error(
err?.message ||
"Error deleting user",
);
});
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) =>
e.preventDefault()
}
>
Delete User
</DropdownMenuItem>
</DialogAction>
)}
Delete User
</DropdownMenuItem>
</DialogAction>
)}
{canUnlink && (
<DialogAction
title="Unlink User"
description="Are you sure you want to unlink this user?"
@@ -180,8 +238,6 @@ export const ShowUsers = () => {
},
);
console.log(orgCount);
if (orgCount === 1) {
await mutateAsync({
userId: member.user.id,
@@ -227,10 +283,21 @@ export const ShowUsers = () => {
Unlink User
</DropdownMenuItem>
</DialogAction>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
variant="ghost"
className="h-8 w-8 p-0"
disabled
>
<span className="sr-only">
No actions available
</span>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</TableCell>
</TableRow>
);
@@ -36,7 +36,7 @@ import { api } from "@/utils/api";
const addServerDomain = z
.object({
domain: z.string(),
domain: z.string().trim().toLowerCase(),
letsEncryptEmail: z.string(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]),
@@ -49,7 +49,11 @@ const addServerDomain = z
message: "Required",
});
}
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
if (
data.https &&
data.certificateType === "letsencrypt" &&
!data.letsEncryptEmail
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
@@ -63,7 +67,7 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { t } = useTranslation("settings");
const { data, refetch } = api.user.get.useQuery();
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { mutateAsync, isLoading } =
api.settings.assignDomainServer.useMutation();
@@ -78,15 +82,15 @@ export const WebDomain = () => {
});
const https = form.watch("https");
const domain = form.watch("domain") || "";
const host = data?.user?.host || "";
const host = data?.host || "";
const hasChanged = domain !== host;
useEffect(() => {
if (data) {
form.reset({
domain: data?.user?.host || "",
certificateType: data?.user?.certificateType,
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
https: data?.user?.https || false,
domain: data?.host || "",
certificateType: data?.certificateType || "none",
letsEncryptEmail: data?.letsEncryptEmail || "",
https: data?.https || false,
});
}
}, [form, form.reset, data]);
@@ -16,7 +16,8 @@ import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
const { t } = useTranslation("settings");
const { data } = api.user.get.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
@@ -53,7 +54,7 @@ export const WebServer = () => {
<div className="flex items-center flex-wrap justify-between gap-4">
<span className="text-sm text-muted-foreground">
Server IP: {data?.user.serverIp}
Server IP: {webServerSettings?.serverIp}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}
@@ -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
@@ -24,10 +24,16 @@ const getTerminalKey = () => {
interface Props {
children?: React.ReactNode;
serverId: string;
asButton?: boolean;
}
export const TerminalModal = ({ children, serverId }: Props) => {
export const TerminalModal = ({
children,
serverId,
asButton = false,
}: Props) => {
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
const [isOpen, setIsOpen] = useState(false);
const isLocalServer = serverId === "local";
const { data } = api.server.one.useQuery(
@@ -43,15 +49,20 @@ export const TerminalModal = ({ children, serverId }: Props) => {
};
return (
<Dialog>
<DialogTrigger asChild>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{asButton ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
)}
<DialogContent
className="sm:max-w-7xl"
onEscapeKeyDown={(event) => event.preventDefault()}
@@ -46,15 +46,15 @@ interface Props {
export const UpdateServerIp = ({ children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data } = api.user.get.useQuery();
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { data: ip } = api.server.publicIp.useQuery();
const { mutateAsync, isLoading, error, isError } =
api.user.update.useMutation();
api.settings.updateServerIp.useMutation();
const form = useForm<Schema>({
defaultValues: {
serverIp: data?.user.serverIp || "",
serverIp: data?.serverIp || "",
},
resolver: zodResolver(schema),
});
@@ -62,13 +62,11 @@ export const UpdateServerIp = ({ children }: Props) => {
useEffect(() => {
if (data) {
form.reset({
serverIp: data.user.serverIp || "",
serverIp: data.serverIp || "",
});
}
}, [form, form.reset, data]);
const utils = api.useUtils();
const setCurrentIp = () => {
if (!ip) return;
form.setValue("serverIp", ip);
@@ -80,7 +78,7 @@ export const UpdateServerIp = ({ children }: Props) => {
})
.then(async () => {
toast.success("Server IP Updated");
await utils.user.get.invalidate();
await refetch();
setIsOpen(false);
})
.catch(() => {
@@ -245,3 +245,29 @@ export const NtfyIcon = ({ className }: Props) => {
</svg>
);
};
export const PushoverIcon = ({ className }: Props) => {
return (
<svg
viewBox="0 0 600 600"
className={cn("size-8", className)}
xmlns="http://www.w3.org/2000/svg"
>
<g stroke="none" strokeWidth="1">
<ellipse
style={{ fillRule: "evenodd" }}
fill="#249DF1"
transform="matrix(-0.674571, 0.73821, -0.73821, -0.674571, 556.833239, 241.613465)"
cx="216.308"
cy="152.076"
rx="296.855"
ry="296.855"
/>
<path
fill="#FFFFFF"
d="M 280.949 172.514 L 355.429 162.714 L 282.909 326.374 L 282.909 326.374 C 295.649 325.394 308.142 321.067 320.389 313.394 L 320.389 313.394 L 320.389 313.394 C 332.642 305.714 343.916 296.077 354.209 284.484 L 354.209 284.484 L 354.209 284.484 C 364.496 272.884 373.396 259.981 380.909 245.774 L 380.909 245.774 L 380.909 245.774 C 388.422 231.561 393.812 217.594 397.079 203.874 L 397.079 203.874 L 397.079 203.874 C 399.039 195.381 399.939 187.214 399.779 179.374 L 399.779 179.374 L 399.779 179.374 C 399.612 171.534 397.569 164.674 393.649 158.794 L 393.649 158.794 L 393.649 158.794 C 389.729 152.914 383.766 148.177 375.759 144.584 L 375.759 144.584 L 375.759 144.584 C 367.759 140.991 356.899 139.194 343.179 139.194 L 343.179 139.194 L 343.179 139.194 C 327.172 139.194 311.409 141.807 295.889 147.034 L 295.889 147.034 L 295.889 147.034 C 280.376 152.261 266.002 159.857 252.769 169.824 L 252.769 169.824 L 252.769 169.824 C 239.542 179.784 228.029 192.197 218.229 207.064 L 218.229 207.064 L 218.229 207.064 C 208.429 221.924 201.406 238.827 197.159 257.774 L 197.159 257.774 L 197.159 257.774 C 195.526 263.981 194.546 268.961 194.219 272.714 L 194.219 272.714 L 194.219 272.714 C 193.892 276.474 193.812 279.577 193.979 282.024 L 193.979 282.024 L 193.979 282.024 C 194.139 284.477 194.462 286.357 194.949 287.664 L 194.949 287.664 L 194.949 287.664 C 195.442 288.971 195.852 290.277 196.179 291.584 L 196.179 291.584 L 196.179 291.584 C 179.519 291.584 167.349 288.234 159.669 281.534 L 159.669 281.534 L 159.669 281.534 C 151.996 274.841 150.119 263.164 154.039 246.504 L 154.039 246.504 L 154.039 246.504 C 157.959 229.191 166.862 212.694 180.749 197.014 L 180.749 197.014 L 180.749 197.014 C 194.629 181.334 211.122 167.531 230.229 155.604 L 230.229 155.604 L 230.229 155.604 C 249.342 143.684 270.249 134.214 292.949 127.194 L 292.949 127.194 L 292.949 127.194 C 315.656 120.167 337.789 116.654 359.349 116.654 L 359.349 116.654 L 359.349 116.654 C 378.296 116.654 394.219 119.347 407.119 124.734 L 407.119 124.734 L 407.119 124.734 C 420.026 130.127 430.072 137.234 437.259 146.054 L 437.259 146.054 L 437.259 146.054 C 444.446 154.874 448.936 165.164 450.729 176.924 L 450.729 176.924 L 450.729 176.924 C 452.529 188.684 451.959 200.934 449.019 213.674 L 449.019 213.674 L 449.019 213.674 C 445.426 229.027 438.646 244.464 428.679 259.984 L 428.679 259.984 L 428.679 259.984 C 418.719 275.497 406.226 289.544 391.199 302.124 L 391.199 302.124 L 391.199 302.124 C 376.172 314.697 358.939 324.904 339.499 332.744 L 339.499 332.744 L 339.499 332.744 C 320.066 340.584 299.406 344.504 277.519 344.504 L 277.519 344.504 L 275.069 344.504 L 212.839 484.154 L 142.279 484.154 L 280.949 172.514 Z"
/>
</g>
</svg>
);
};
@@ -1,6 +1,6 @@
import { api } from "@/utils/api";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { ChatwootWidget } from "../shared/ChatwootWidget";
import { HubSpotWidget } from "../shared/HubSpotWidget";
import Page from "./side";
interface Props {
@@ -25,7 +25,9 @@ export const DashboardLayout = ({ children }: Props) => {
<>
<Page>{children}</Page>
{isCloud === true && isUserSubscribed === true && (
<ChatwootWidget websiteToken="USCpQRKzHvFMssf3p6Eacae5" />
<>
<HubSpotWidget />
</>
)}
{haveRootAccess === true && <ImpersonationBar />}
+166 -77
View File
@@ -18,7 +18,6 @@ import {
Forward,
GalleryVerticalEnd,
GitBranch,
HeartIcon,
KeyRound,
Loader2,
type LucideIcon,
@@ -26,6 +25,7 @@ import {
PieChart,
Server,
ShieldCheck,
Star,
Trash2,
User,
Users,
@@ -82,6 +82,7 @@ import { AddOrganization } from "../dashboard/organization/handle-organization";
import { DialogAction } from "../shared/dialog-action";
import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
import { TimeBadge } from "../ui/time-badge";
import { UpdateServerButton } from "./update-server";
import { UserNav } from "./user-nav";
@@ -156,7 +157,8 @@ const MENU: Menu = {
url: "/dashboard/schedules",
icon: Clock,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner",
isEnabled: ({ isCloud, auth }) =>
!isCloud && (auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -166,7 +168,9 @@ const MENU: Menu = {
// Only enabled for admins and users with access to Traefik files in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" || auth?.canAccessToTraefikFiles) &&
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToTraefikFiles) &&
!isCloud
),
},
@@ -177,7 +181,12 @@ const MENU: Menu = {
icon: BlocksIcon,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
},
{
isSingle: true,
@@ -186,7 +195,12 @@ const MENU: Menu = {
icon: PieChart,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
},
{
isSingle: true,
@@ -195,7 +209,12 @@ const MENU: Menu = {
icon: Forward,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
},
// Legacy unused menu, adjusted to the new structure
@@ -262,7 +281,8 @@ const MENU: Menu = {
url: "/dashboard/settings/server",
icon: Activity,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
},
{
isSingle: true,
@@ -276,7 +296,8 @@ const MENU: Menu = {
url: "/dashboard/settings/servers",
icon: Server,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -284,7 +305,8 @@ const MENU: Menu = {
icon: Users,
url: "/dashboard/settings/users",
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -293,14 +315,19 @@ const MENU: Menu = {
url: "/dashboard/settings/ssh-keys",
// Only enabled for admins and users with access to SSH keys
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
!!(
auth?.role === "owner" ||
auth?.canAccessToSSHKeys ||
auth?.role === "admin"
),
},
{
title: "AI",
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -309,7 +336,11 @@ const MENU: Menu = {
icon: GitBranch,
// Only enabled for admins and users with access to Git providers
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.canAccessToGitProviders),
!!(
auth?.role === "owner" ||
auth?.canAccessToGitProviders ||
auth?.role === "admin"
),
},
{
isSingle: true,
@@ -317,7 +348,8 @@ const MENU: Menu = {
url: "/dashboard/settings/registry",
icon: Package,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -325,7 +357,8 @@ const MENU: Menu = {
url: "/dashboard/settings/destinations",
icon: Database,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
@@ -334,7 +367,8 @@ const MENU: Menu = {
url: "/dashboard/settings/certificates",
icon: ShieldCheck,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -342,7 +376,8 @@ const MENU: Menu = {
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
},
{
isSingle: true,
@@ -350,7 +385,8 @@ const MENU: Menu = {
url: "/dashboard/settings/notifications",
icon: Bell,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
@@ -373,18 +409,6 @@ const MENU: Menu = {
url: "https://discord.gg/2tBnJ3jDJc",
icon: CircleHelp,
},
{
name: "Sponsor",
url: "https://opencollective.com/dokploy",
icon: ({ className }) => (
<HeartIcon
className={cn(
"text-red-500 fill-red-600 animate-heartbeat",
className,
)}
/>
),
},
],
} as const;
@@ -497,7 +521,6 @@ function SidebarLogo() {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery();
const { data: session } = authClient.useSession();
const {
data: organizations,
refetch,
@@ -505,6 +528,8 @@ function SidebarLogo() {
} = api.organization.all.useQuery();
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
api.organization.delete.useMutation();
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
api.organization.setDefault.useMutation();
const { isMobile } = useSidebar();
const { data: activeOrganization } = authClient.useActiveOrganization();
const _utils = api.useUtils();
@@ -594,67 +619,130 @@ function SidebarLogo() {
<DropdownMenuLabel className="text-xs text-muted-foreground">
Organizations
</DropdownMenuLabel>
{organizations?.map((org) => (
<div className="flex flex-row justify-between" key={org.name}>
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="w-full gap-2 p-2"
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<div
className="flex flex-row justify-between"
key={org.name}
>
<div className="flex flex-col gap-4">{org.name}</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
</DropdownMenuItem>
{org.ownerId === session?.user?.id && (
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="w-full gap-2 p-2"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{org.name}
</div>
</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
</DropdownMenuItem>
<div className="flex items-center gap-2">
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
toast.success("Default organization updated");
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
"Error setting default organization",
);
});
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
</div>
)}
</div>
))}
{(user?.role === "owner" || isCloud) && (
</div>
);
})}
{(user?.role === "owner" ||
user?.role === "admin" ||
isCloud) && (
<>
<DropdownMenuSeparator />
<AddOrganization />
@@ -1018,7 +1106,7 @@ export default function Page({ children }: Props) {
</SidebarContent>
<SidebarFooter>
<SidebarMenu className="flex flex-col gap-2">
{!isCloud && auth?.role === "owner" && (
{!isCloud && (auth?.role === "owner" || auth?.role === "admin") && (
<SidebarMenuItem>
<UpdateServerButton />
</SidebarMenuItem>
@@ -1062,6 +1150,7 @@ export default function Page({ children }: Props) {
</BreadcrumbList>
</Breadcrumb>
</div>
{!isCloud && <TimeBadge />}
</div>
</header>
)}

Some files were not shown because too many files have changed in this diff Show More