mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-20 23:45:24 +00:00
Merge branch 'canary' into multiple-admins
This commit is contained in:
@@ -20,6 +20,32 @@ jobs:
|
||||
with:
|
||||
node-version: 20.16.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Nixpacks
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export NIXPACKS_VERSION=1.39.0
|
||||
curl -sSL https://nixpacks.com/install.sh | bash
|
||||
echo "Nixpacks installed $NIXPACKS_VERSION"
|
||||
|
||||
- name: Install Railpack
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export RAILPACK_VERSION=0.15.0
|
||||
curl -sSL https://railpack.com/install.sh | bash
|
||||
echo "Railpack installed $RAILPACK_VERSION"
|
||||
|
||||
- name: Add build tools to PATH
|
||||
if: matrix.job == 'test'
|
||||
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Initialize Docker Swarm
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
docker swarm init
|
||||
docker network create --driver overlay dokploy-network || true
|
||||
echo "✅ Docker Swarm initialized"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm server:build
|
||||
- run: pnpm ${{ matrix.job }}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Generate and Sync OpenAPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
- main
|
||||
paths:
|
||||
- 'apps/dokploy/server/api/routers/**'
|
||||
- 'packages/server/src/services/**'
|
||||
- 'packages/server/src/db/schema/**'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
generate-and-commit:
|
||||
name: Generate OpenAPI and commit to Dokploy repo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Dokploy repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.16.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate OpenAPI specification
|
||||
run: |
|
||||
pnpm generate:openapi
|
||||
|
||||
# Verifica que se generó correctamente
|
||||
if [ ! -f openapi.json ]; then
|
||||
echo "❌ openapi.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ OpenAPI specification generated successfully"
|
||||
|
||||
- name: Sync to website repository
|
||||
run: |
|
||||
# Clona el repositorio de website
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
|
||||
|
||||
cd website-repo
|
||||
|
||||
# Copia el openapi.json al website (sobrescribe)
|
||||
mkdir -p apps/docs/public
|
||||
cp -f ../openapi.json apps/docs/public/openapi.json
|
||||
|
||||
# Configura git
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
# Agrega y commitea siempre
|
||||
git add apps/docs/public/openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to website successfully"
|
||||
|
||||
@@ -13,6 +13,8 @@ node_modules
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
openapi.json
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ COPY --from=build /prod/dokploy/node_modules ./node_modules
|
||||
|
||||
|
||||
# Install docker
|
||||
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh && curl https://rclone.org/install.sh | bash
|
||||
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash
|
||||
|
||||
# Install Nixpacks and tsx
|
||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
NODE_ENV=development
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Domain } from "@dokploy/server";
|
||||
import { createDomainLabels } from "@dokploy/server";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Regression tests for Traefik Host rule label format.
|
||||
*
|
||||
* These tests verify that the Host rule is generated with the correct format:
|
||||
* - Host(`domain.com`) - with opening and closing parentheses
|
||||
* - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
|
||||
*
|
||||
* Issue: https://github.com/Dokploy/dokploy/issues/3161
|
||||
* The bug caused Host rules to be malformed as Host`domain.com`)
|
||||
* (missing opening parenthesis) which broke all domain routing.
|
||||
*/
|
||||
describe("Host rule format regression tests", () => {
|
||||
const baseDomain: Domain = {
|
||||
host: "example.com",
|
||||
port: 8080,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
certificateType: "none",
|
||||
applicationId: "",
|
||||
composeId: "",
|
||||
domainType: "compose",
|
||||
serviceName: "test-app",
|
||||
domainId: "",
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
it("should generate Host rule with correct parentheses format", async () => {
|
||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
// Verify exact format: Host(`domain`)
|
||||
expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
|
||||
// Ensure opening parenthesis is present after Host
|
||||
expect(ruleLabel).toContain("Host(`example.com`)");
|
||||
// Ensure it does NOT have the malformed format
|
||||
expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should generate PathPrefix with correct parentheses format", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api" },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
// Verify PathPrefix format
|
||||
expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
|
||||
expect(ruleLabel).toContain("PathPrefix(`/api`)");
|
||||
// Ensure opening parenthesis is present
|
||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should generate combined Host and PathPrefix with correct format", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api/v1" },
|
||||
"websecure",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toBe(
|
||||
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("YAML serialization preserves Host rule format", () => {
|
||||
it("should preserve Host rule format through YAML stringify/parse", async () => {
|
||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
// Simulate compose file structure
|
||||
const composeSpec = {
|
||||
services: {
|
||||
myapp: {
|
||||
image: "nginx",
|
||||
labels: labels,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Stringify to YAML
|
||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||
|
||||
// Parse back
|
||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||
l.includes(".rule="),
|
||||
);
|
||||
|
||||
// Verify format is preserved
|
||||
expect(parsedRuleLabel).toBe(ruleLabel);
|
||||
expect(parsedRuleLabel).toContain("Host(`example.com`)");
|
||||
expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should preserve complex rule format through YAML serialization", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api", https: true },
|
||||
"websecure",
|
||||
);
|
||||
|
||||
const composeSpec = {
|
||||
services: {
|
||||
myapp: {
|
||||
labels: labels,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||
l.includes(".rule="),
|
||||
);
|
||||
|
||||
expect(parsedRuleLabel).toContain(
|
||||
"Host(`example.com`) && PathPrefix(`/api`)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases for domain names", () => {
|
||||
const domainCases = [
|
||||
{ name: "simple domain", host: "example.com" },
|
||||
{ name: "subdomain", host: "app.example.com" },
|
||||
{ name: "deep subdomain", host: "api.v1.app.example.com" },
|
||||
{ name: "numeric domain", host: "123.example.com" },
|
||||
{ name: "hyphenated domain", host: "my-app.example-host.com" },
|
||||
{ name: "localhost", host: "localhost" },
|
||||
{ name: "IP address style", host: "192.168.1.100" },
|
||||
];
|
||||
|
||||
for (const { name, host } of domainCases) {
|
||||
it(`should generate correct Host rule for ${name}: ${host}`, async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, host },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toContain(`Host(\`${host}\`)`);
|
||||
// Verify parenthesis is present
|
||||
expect(ruleLabel).toMatch(
|
||||
new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Multiple domains scenario", () => {
|
||||
it("should generate correct format for both web and websecure entrypoints", async () => {
|
||||
const webLabels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const websecureLabels = await createDomainLabels(
|
||||
"test-app",
|
||||
baseDomain,
|
||||
"websecure",
|
||||
);
|
||||
|
||||
const webRule = webLabels.find((l) => l.includes(".rule="));
|
||||
const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
|
||||
|
||||
// Both should have correct format
|
||||
expect(webRule).toContain("Host(`example.com`)");
|
||||
expect(websecureRule).toContain("Host(`example.com`)");
|
||||
|
||||
// Neither should have malformed format
|
||||
expect(webRule).not.toMatch(/Host`[^`]+`\)/);
|
||||
expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Special characters in paths", () => {
|
||||
const pathCases = [
|
||||
{ name: "simple path", path: "/api" },
|
||||
{ name: "nested path", path: "/api/v1/users" },
|
||||
{ name: "path with hyphen", path: "/api-v1" },
|
||||
{ name: "path with underscore", path: "/api_v1" },
|
||||
];
|
||||
|
||||
for (const { name, path } of pathCases) {
|
||||
it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
|
||||
// Verify parenthesis is present
|
||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import * as adminService from "@dokploy/server/services/admin";
|
||||
import * as applicationService from "@dokploy/server/services/application";
|
||||
import { deployApplication } from "@dokploy/server/services/application";
|
||||
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||
import * as builders from "@dokploy/server/utils/builders";
|
||||
import * as notifications from "@dokploy/server/utils/notifications/build-success";
|
||||
import * as execProcess from "@dokploy/server/utils/process/execAsync";
|
||||
import * as gitProvider from "@dokploy/server/utils/providers/git";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const createChainableMock = (): any => {
|
||||
const chain = {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}] as any),
|
||||
} as any;
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
query: {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/application", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/services/application")
|
||||
>("@dokploy/server/services/application");
|
||||
return {
|
||||
...actual,
|
||||
findApplicationById: vi.fn(),
|
||||
updateApplicationStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/admin", () => ({
|
||||
getDokployUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||
createDeployment: vi.fn(),
|
||||
updateDeploymentStatus: vi.fn(),
|
||||
updateDeployment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/providers/git", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/utils/providers/git")
|
||||
>("@dokploy/server/utils/providers/git");
|
||||
return {
|
||||
...actual,
|
||||
getGitCommitInfo: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: vi.fn(),
|
||||
ExecError: class ExecError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/builders", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/utils/builders")
|
||||
>("@dokploy/server/utils/builders");
|
||||
return {
|
||||
...actual,
|
||||
mechanizeDockerContainer: vi.fn(),
|
||||
getBuildCommand: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
|
||||
sendBuildSuccessNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
|
||||
sendBuildErrorNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||
createRollback: vi.fn(),
|
||||
}));
|
||||
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
|
||||
|
||||
const createMockApplication = (overrides = {}) => ({
|
||||
applicationId: "test-app-id",
|
||||
name: "Test App",
|
||||
appName: "test-app",
|
||||
sourceType: "git" as const,
|
||||
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||
customGitBranch: "main",
|
||||
customGitSSHKeyId: null,
|
||||
buildType: "nixpacks" as const,
|
||||
buildPath: "/astro",
|
||||
env: "NODE_ENV=production",
|
||||
serverId: null,
|
||||
rollbackActive: false,
|
||||
enableSubmodules: false,
|
||||
environmentId: "env-id",
|
||||
environment: {
|
||||
projectId: "project-id",
|
||||
env: "",
|
||||
name: "production",
|
||||
project: {
|
||||
name: "Test Project",
|
||||
organizationId: "org-id",
|
||||
env: "",
|
||||
},
|
||||
},
|
||||
domains: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockDeployment = () => ({
|
||||
deploymentId: "deployment-id",
|
||||
logPath: "/tmp/test-deployment.log",
|
||||
});
|
||||
|
||||
describe("deployApplication - Command Generation Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
createMockApplication() as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
createMockApplication() as any,
|
||||
);
|
||||
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
|
||||
"http://localhost:3000",
|
||||
);
|
||||
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
|
||||
createMockDeployment() as any,
|
||||
);
|
||||
vi.mocked(execProcess.execAsync).mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
} as any);
|
||||
vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
|
||||
{} as any,
|
||||
);
|
||||
vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({
|
||||
message: "test commit",
|
||||
hash: "abc123",
|
||||
});
|
||||
vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
it("should generate correct git clone command for astro example", async () => {
|
||||
const app = createMockApplication();
|
||||
const command = await cloneGitRepository(app);
|
||||
console.log(command);
|
||||
|
||||
expect(command).toContain("https://github.com/Dokploy/examples.git");
|
||||
expect(command).not.toContain("--recurse-submodules");
|
||||
expect(command).toContain("--branch main");
|
||||
expect(command).toContain("--depth 1");
|
||||
expect(command).toContain("git clone");
|
||||
});
|
||||
|
||||
it("should generate git clone with submodules when enabled", async () => {
|
||||
const app = createMockApplication({ enableSubmodules: true });
|
||||
const command = await cloneGitRepository(app);
|
||||
|
||||
expect(command).toContain("--recurse-submodules");
|
||||
expect(command).toContain("https://github.com/Dokploy/examples.git");
|
||||
});
|
||||
|
||||
it("should verify nixpacks command is called with correct app", async () => {
|
||||
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Test deployment",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(builders.getBuildCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildType: "nixpacks",
|
||||
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||
buildPath: "/astro",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(execProcess.execAsync).toHaveBeenCalledWith(
|
||||
expect.stringContaining("nixpacks build"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should verify railpack command includes correct parameters", async () => {
|
||||
const mockApp = createMockApplication({ buildType: "railpack" });
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
|
||||
const mockRailpackCommand = "railpack prepare /path/to/app";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Railpack test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(builders.getBuildCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildType: "railpack",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(execProcess.execAsync).toHaveBeenCalledWith(
|
||||
expect.stringContaining("railpack prepare"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should execute commands in correct order", async () => {
|
||||
const mockNixpacksCommand = "nixpacks build";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
|
||||
expect(execCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const fullCommand = execCalls[0]?.[0];
|
||||
expect(fullCommand).toContain("set -e");
|
||||
expect(fullCommand).toContain("git clone");
|
||||
expect(fullCommand).toContain("nixpacks build");
|
||||
});
|
||||
|
||||
it("should include log redirection in command", async () => {
|
||||
const mockCommand = "nixpacks build";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
|
||||
const fullCommand = execCalls[0]?.[0];
|
||||
|
||||
expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,479 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ApplicationNested } from "@dokploy/server";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { execAsync } from "@dokploy/server/utils/process/execAsync";
|
||||
import { format } from "date-fns";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
|
||||
|
||||
// Mock ONLY database and notifications
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const createChainableMock = (): any => {
|
||||
const chain: any = {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}]),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
query: {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/application", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/services/application")
|
||||
>("@dokploy/server/services/application");
|
||||
return {
|
||||
...actual,
|
||||
findApplicationById: vi.fn(),
|
||||
updateApplicationStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/admin", () => ({
|
||||
getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||
createDeployment: vi.fn(),
|
||||
updateDeploymentStatus: vi.fn(),
|
||||
updateDeployment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
|
||||
sendBuildSuccessNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
|
||||
sendBuildErrorNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||
createRollback: vi.fn(),
|
||||
}));
|
||||
|
||||
// NOT mocked (executed for real):
|
||||
// - execAsync
|
||||
// - cloneGitRepository
|
||||
// - getBuildCommand
|
||||
// - mechanizeDockerContainer (requires Docker Swarm)
|
||||
|
||||
import { db } from "@dokploy/server/db";
|
||||
import * as adminService from "@dokploy/server/services/admin";
|
||||
import * as applicationService from "@dokploy/server/services/application";
|
||||
import { deployApplication } from "@dokploy/server/services/application";
|
||||
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||
|
||||
const createMockApplication = (
|
||||
overrides: Partial<ApplicationNested> = {},
|
||||
): ApplicationNested =>
|
||||
({
|
||||
applicationId: "test-app-id",
|
||||
name: "Real Test App",
|
||||
appName: `real-test-${Date.now()}`,
|
||||
sourceType: "git" as const,
|
||||
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||
customGitBranch: "main",
|
||||
customGitSSHKeyId: null,
|
||||
customGitBuildPath: "/astro",
|
||||
buildType: "nixpacks" as const,
|
||||
env: "NODE_ENV=production",
|
||||
serverId: null,
|
||||
rollbackActive: false,
|
||||
enableSubmodules: false,
|
||||
environmentId: "env-id",
|
||||
environment: {
|
||||
projectId: "project-id",
|
||||
env: "",
|
||||
name: "production",
|
||||
project: {
|
||||
name: "Test Project",
|
||||
organizationId: "org-id",
|
||||
env: "",
|
||||
},
|
||||
},
|
||||
domains: [],
|
||||
mounts: [],
|
||||
security: [],
|
||||
redirects: [],
|
||||
ports: [],
|
||||
registry: null,
|
||||
...overrides,
|
||||
}) as ApplicationNested;
|
||||
|
||||
const createMockDeployment = async (appName: string) => {
|
||||
const { LOGS_PATH } = paths(false); // false = local, no remote server
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${appName}-${formattedDateTime}.log`;
|
||||
const logFilePath = path.join(LOGS_PATH, appName, fileName);
|
||||
|
||||
// Actually create the log directory
|
||||
await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
|
||||
await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
|
||||
|
||||
return {
|
||||
deploymentId: "deployment-id",
|
||||
logPath: logFilePath,
|
||||
};
|
||||
};
|
||||
|
||||
async function cleanupDocker(appName: string) {
|
||||
try {
|
||||
await execAsync(`docker stop ${appName} 2>/dev/null || true`);
|
||||
await execAsync(`docker rm ${appName} 2>/dev/null || true`);
|
||||
await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
|
||||
} catch (error) {
|
||||
console.log("Docker cleanup completed");
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupFiles(appName: string) {
|
||||
try {
|
||||
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||
|
||||
// Clean cloned code directories
|
||||
const appPath = path.join(APPLICATIONS_PATH, appName);
|
||||
await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
|
||||
|
||||
// Clean logs for appName - removes entire folder
|
||||
const logPath = path.join(LOGS_PATH, appName);
|
||||
await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
|
||||
|
||||
console.log(`✅ Cleaned up files and logs for ${appName}`);
|
||||
} catch (error) {
|
||||
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
describe(
|
||||
"deployApplication - REAL Execution Tests",
|
||||
() => {
|
||||
let currentAppName: string;
|
||||
let currentDeployment: any;
|
||||
const allTestAppNames: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
currentAppName = `real-test-${Date.now()}`;
|
||||
currentDeployment = await createMockDeployment(currentAppName);
|
||||
allTestAppNames.push(currentAppName);
|
||||
|
||||
const mockApp = createMockApplication({ appName: currentAppName });
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
|
||||
"http://localhost:3000",
|
||||
);
|
||||
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
|
||||
currentDeployment as any,
|
||||
);
|
||||
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
|
||||
{} as any,
|
||||
);
|
||||
vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
|
||||
{} as any,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// ALWAYS cleanup, even if test failed or passed
|
||||
console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
|
||||
|
||||
// Clean current appName
|
||||
try {
|
||||
await cleanupDocker(currentAppName);
|
||||
await cleanupFiles(currentAppName);
|
||||
} catch (error) {
|
||||
console.error("⚠️ Error cleaning current app:", error);
|
||||
}
|
||||
|
||||
// Clean ALL test folders just in case
|
||||
try {
|
||||
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||
await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
|
||||
await execAsync(
|
||||
`rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
|
||||
);
|
||||
console.log("✅ Cleaned up all test artifacts");
|
||||
} catch (error) {
|
||||
console.error("⚠️ Error cleaning all artifacts:", error);
|
||||
}
|
||||
|
||||
console.log("✅ Cleanup completed\n");
|
||||
});
|
||||
|
||||
it(
|
||||
"should REALLY clone git repo and build with nixpacks",
|
||||
async () => {
|
||||
console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Nixpacks Test",
|
||||
descriptionLog: "Testing real execution",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify that Docker image was actually created
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
console.log("dockerImages", dockerImages);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||
|
||||
// Verify log exists and has content
|
||||
expect(existsSync(currentDeployment.logPath)).toBe(true);
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Cloning");
|
||||
expect(logContent).toContain("nixpacks");
|
||||
console.log(`✅ Build log created with ${logContent.length} chars`);
|
||||
|
||||
// Verify update functions were called
|
||||
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||
"deployment-id",
|
||||
"done",
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it.skip(
|
||||
"should REALLY build with railpack (SKIPPED: requires special permissions)",
|
||||
async () => {
|
||||
const railpackAppName = `real-railpack-${Date.now()}`;
|
||||
const railpackApp = createMockApplication({
|
||||
appName: railpackAppName,
|
||||
buildType: "railpack",
|
||||
railpackVersion: "3",
|
||||
});
|
||||
currentAppName = railpackAppName;
|
||||
allTestAppNames.push(railpackAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
railpackApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
railpackApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Railpack Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Railpack image created: ${currentAppName}`);
|
||||
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("railpack");
|
||||
console.log("✅ Railpack build completed");
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle REAL git clone errors",
|
||||
async () => {
|
||||
const errorAppName = `real-error-${Date.now()}`;
|
||||
const errorApp = createMockApplication({
|
||||
appName: errorAppName,
|
||||
customGitUrl:
|
||||
"https://github.com/invalid/nonexistent-repo-123456.git",
|
||||
});
|
||||
currentAppName = errorAppName;
|
||||
allTestAppNames.push(errorAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
errorApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
errorApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
|
||||
|
||||
await expect(
|
||||
deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Error Test",
|
||||
descriptionLog: "",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Verify error status was called
|
||||
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||
"deployment-id",
|
||||
"error",
|
||||
);
|
||||
|
||||
// Verify log contains error
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent.toLowerCase()).toContain("error");
|
||||
console.log("✅ Error handling verified");
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should REALLY clone with submodules when enabled",
|
||||
async () => {
|
||||
const submodulesAppName = `real-submodules-${Date.now()}`;
|
||||
const submodulesApp = createMockApplication({
|
||||
appName: submodulesAppName,
|
||||
enableSubmodules: true,
|
||||
});
|
||||
currentAppName = submodulesAppName;
|
||||
allTestAppNames.push(submodulesAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
submodulesApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
submodulesApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Submodules Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify deployment completed successfully
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Cloning");
|
||||
expect(logContent.length).toBeGreaterThan(100);
|
||||
console.log("✅ Submodules deployment completed");
|
||||
|
||||
// Verify image
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should verify REAL commit info extraction",
|
||||
async () => {
|
||||
console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Commit Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
// Verify updateDeployment was called with commit info
|
||||
expect(deploymentService.updateDeployment).toHaveBeenCalled();
|
||||
const updateCall = vi.mocked(deploymentService.updateDeployment).mock
|
||||
.calls[0];
|
||||
|
||||
// Real commit info should have title and hash
|
||||
expect(updateCall?.[1]).toHaveProperty("title");
|
||||
expect(updateCall?.[1]).toHaveProperty("description");
|
||||
expect(updateCall?.[1]?.description).toContain("Commit:");
|
||||
|
||||
console.log(
|
||||
`✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should REALLY build with Dockerfile",
|
||||
async () => {
|
||||
const dockerfileAppName = `real-dockerfile-${Date.now()}`;
|
||||
const dockerfileApp = createMockApplication({
|
||||
appName: dockerfileAppName,
|
||||
buildType: "dockerfile",
|
||||
customGitBuildPath: "/deno",
|
||||
dockerfile: "Dockerfile",
|
||||
});
|
||||
currentAppName = dockerfileAppName;
|
||||
allTestAppNames.push(dockerfileAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
dockerfileApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
dockerfileApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Dockerfile Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify log
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Building");
|
||||
expect(logContent).toContain(dockerfileAppName);
|
||||
console.log("✅ Dockerfile build log verified");
|
||||
|
||||
// Verify image
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
console.log("dockerImages", dockerImages);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,10 @@ const baseApp: ApplicationNested = {
|
||||
previewLabels: [],
|
||||
herokuVersion: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
args: [],
|
||||
giteaBuildPath: "",
|
||||
previewRequireCollaboratorPermissions: false,
|
||||
giteaId: "",
|
||||
@@ -37,6 +41,9 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
cleanCache: false,
|
||||
watchPaths: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
enableSubmodules: false,
|
||||
applicationStatus: "done",
|
||||
triggerType: "push",
|
||||
|
||||
+310
-1
@@ -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é");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||
|
||||
type MockCreateServiceOptions = {
|
||||
StopGracePeriod?: number;
|
||||
TaskTemplate?: {
|
||||
ContainerSpec?: {
|
||||
StopGracePeriod?: number;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -82,8 +86,10 @@ describe("mechanizeDockerContainer", () => {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.StopGracePeriod).toBe(0);
|
||||
expect(typeof settings.StopGracePeriod).toBe("number");
|
||||
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
|
||||
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
|
||||
"number",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
||||
@@ -97,6 +103,8 @@ describe("mechanizeDockerContainer", () => {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings).not.toHaveProperty("StopGracePeriod");
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
|
||||
"StopGracePeriod",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,15 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
giteaOwner: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
giteaBuildPath: "",
|
||||
giteaId: "",
|
||||
args: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
cleanCache: false,
|
||||
applicationStatus: "done",
|
||||
endpointSpecSwarm: null,
|
||||
|
||||
@@ -13,7 +13,11 @@ export default defineConfig({
|
||||
NODE: "test",
|
||||
},
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: [path.resolve(__dirname, "../tsconfig.json")],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@dokploy/server": path.resolve(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -28,6 +29,13 @@ interface Props {
|
||||
|
||||
const AddRedirectSchema = z.object({
|
||||
command: z.string(),
|
||||
args: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.string().min(1, "Argument cannot be empty"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||
@@ -47,22 +55,30 @@ export const AddCommand = ({ applicationId }: Props) => {
|
||||
const form = useForm<AddCommand>({
|
||||
defaultValues: {
|
||||
command: "",
|
||||
args: [],
|
||||
},
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "args",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.command) {
|
||||
if (data) {
|
||||
form.reset({
|
||||
command: data?.command || "",
|
||||
args: data?.args?.map((arg) => ({ value: arg })) || [],
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (data: AddCommand) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
command: data?.command,
|
||||
args: data?.args?.map((arg) => arg.value).filter(Boolean),
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Command Updated");
|
||||
@@ -100,13 +116,65 @@ export const AddCommand = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Custom command" {...field} />
|
||||
<Input placeholder="/bin/sh" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Arguments (Args)</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Argument
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No arguments added yet. Click "Add Argument" to add one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`args.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
index === 0 ? "-c" : "echo Hello World"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
buildServerId: z.string().min(1, "Build server is required"),
|
||||
buildRegistryId: z.string().min(1, "Build registry is required"),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{ applicationId },
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
buildServerId: data?.buildServerId || "",
|
||||
buildRegistryId: data?.buildRegistryId || "",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
buildServerId: data?.buildServerId || "",
|
||||
buildRegistryId: data?.buildRegistryId || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (formData: Schema) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
buildServerId:
|
||||
formData?.buildServerId === "none" || !formData?.buildServerId
|
||||
? null
|
||||
: formData?.buildServerId,
|
||||
buildRegistryId:
|
||||
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
|
||||
? null
|
||||
: formData?.buildRegistryId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Build Server Settings Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating build server settings");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Server className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle className="text-xl">Build Server</CardTitle>
|
||||
<CardDescription>
|
||||
Configure a dedicated server for building your application.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<AlertBlock type="info">
|
||||
Build servers offload the build process from your deployment servers.
|
||||
Select a build server and registry to use for building your
|
||||
application.
|
||||
</AlertBlock>
|
||||
|
||||
<AlertBlock type="info">
|
||||
📊 <strong>Important:</strong> Once the build finishes, you'll need to
|
||||
wait a few seconds for the deployment server to download the image.
|
||||
These download logs will <strong>NOT</strong> appear in the build
|
||||
deployment logs. Check the <strong>Logs</strong> tab to see when the
|
||||
container starts running.
|
||||
</AlertBlock>
|
||||
|
||||
{!registries || registries.length === 0 ? (
|
||||
<AlertBlock type="warning">
|
||||
You need to add at least one registry to use build servers. Please
|
||||
go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/registry"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Settings
|
||||
</Link>{" "}
|
||||
to add a registry.
|
||||
</AlertBlock>
|
||||
) : null}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildServerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Build Server</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a build server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>None</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{buildServers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>{server.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{server.ipAddress}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Build Servers ({buildServers?.length || 0})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select a build server to handle the build process for this
|
||||
application.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildRegistryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Build Registry</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a registry" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>None</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{registries?.map((registry) => (
|
||||
<SelectItem
|
||||
key={registry.registryId}
|
||||
value={registry.registryId}
|
||||
>
|
||||
{registry.registryName}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Registries ({registries?.length || 0})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select a registry to store the built images from the build
|
||||
server.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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,180 @@ 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 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>
|
||||
)}
|
||||
</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 flex-col items-end gap-2 max-w-[300px] w-full justify-start">
|
||||
<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>
|
||||
|
||||
{deployment?.rollback &&
|
||||
deployment.status === "done" &&
|
||||
type === "application" && (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{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}
|
||||
>
|
||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||
Rollback
|
||||
Kill Process
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
>
|
||||
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}
|
||||
>
|
||||
<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(),
|
||||
@@ -299,6 +305,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
{type === "compose" && (
|
||||
<AlertBlock type="info" className="mb-4">
|
||||
Whenever you make changes to domains, remember to redeploy your
|
||||
compose to apply the changes.
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
|
||||
+10
-1
@@ -182,7 +182,16 @@ 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>
|
||||
|
||||
<AddPreviewDomain
|
||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||
|
||||
+104
-4
@@ -1,5 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -20,13 +21,37 @@ import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
rollbackActive: z.boolean(),
|
||||
});
|
||||
const formSchema = z
|
||||
.object({
|
||||
rollbackActive: z.boolean(),
|
||||
rollbackRegistryId: z.string().optional(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (
|
||||
values.rollbackActive &&
|
||||
(!values.rollbackRegistryId || values.rollbackRegistryId === "none")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["rollbackRegistryId"],
|
||||
message: "Registry is required when rollbacks are enabled",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -49,17 +74,33 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
const { mutateAsync: updateApplication, isLoading } =
|
||||
api.application.update.useMutation();
|
||||
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
rollbackActive: application?.rollbackActive ?? false,
|
||||
rollbackRegistryId: application?.rollbackRegistryId || "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (application) {
|
||||
form.reset({
|
||||
rollbackActive: application.rollbackActive ?? false,
|
||||
rollbackRegistryId: application.rollbackRegistryId || "",
|
||||
});
|
||||
}
|
||||
}, [application, form]);
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
rollbackActive: data.rollbackActive,
|
||||
rollbackRegistryId:
|
||||
data.rollbackRegistryId === "none" || !data.rollbackRegistryId
|
||||
? null
|
||||
: data.rollbackRegistryId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Rollback settings updated");
|
||||
@@ -112,6 +153,65 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("rollbackActive") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rollbackRegistryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Rollback Registry</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a registry" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>None</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{registries?.map((registry) => (
|
||||
<SelectItem
|
||||
key={registry.registryId}
|
||||
value={registry.registryId}
|
||||
>
|
||||
{registry.registryName}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Registries ({registries?.length || 0})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!registries || registries.length === 0 ? (
|
||||
<FormDescription className="text-amber-600 dark:text-amber-500">
|
||||
No registries available. Please{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/registry"
|
||||
className="underline font-medium hover:text-amber-700 dark:hover:text-amber-400"
|
||||
>
|
||||
configure a registry
|
||||
</Link>{" "}
|
||||
first to enable rollbacks.
|
||||
</FormDescription>
|
||||
) : (
|
||||
<FormDescription>
|
||||
Select a registry where rollback images will be stored.
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
Save Settings
|
||||
</Button>
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
+9
-3
@@ -10,7 +10,7 @@ import { DockerNetworkChart } from "./docker-network-chart";
|
||||
|
||||
const defaultData = {
|
||||
cpu: {
|
||||
value: 0,
|
||||
value: "0%",
|
||||
time: "",
|
||||
},
|
||||
memory: {
|
||||
@@ -46,7 +46,7 @@ interface Props {
|
||||
}
|
||||
export interface DockerStats {
|
||||
cpu: {
|
||||
value: number;
|
||||
value: string;
|
||||
time: string;
|
||||
};
|
||||
memory: {
|
||||
@@ -220,7 +220,13 @@ export const ContainerFreeMonitoring = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Used: {currentData.cpu.value}
|
||||
</span>
|
||||
<Progress value={currentData.cpu.value} className="w-[100%]" />
|
||||
<Progress
|
||||
value={Number.parseInt(
|
||||
currentData.cpu.value.replace("%", ""),
|
||||
10,
|
||||
)}
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,6 +21,13 @@ import type { ServiceType } from "../../application/advanced/show-resources";
|
||||
const addDockerImage = z.object({
|
||||
dockerImage: z.string().min(1, "Docker image is required"),
|
||||
command: z.string(),
|
||||
args: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.string().min(1, "Argument cannot be empty"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
@@ -61,18 +69,25 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
defaultValues: {
|
||||
dockerImage: "",
|
||||
command: "",
|
||||
args: [],
|
||||
},
|
||||
resolver: zodResolver(addDockerImage),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "args",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
dockerImage: data.dockerImage,
|
||||
command: data.command || "",
|
||||
args: data.args?.map((arg) => ({ value: arg })) || [],
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: AddDockerImage) => {
|
||||
await mutateAsync({
|
||||
@@ -83,6 +98,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
mariadbId: id || "",
|
||||
dockerImage: formData?.dockerImage,
|
||||
command: formData?.command,
|
||||
args: formData?.args?.map((arg) => arg.value).filter(Boolean),
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Custom Command Updated");
|
||||
@@ -128,13 +144,68 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Custom command" {...field} />
|
||||
<Input placeholder="/bin/sh" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Arguments (Args)</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Argument
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No arguments added yet. Click "Add Argument" to add one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`args.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
index === 0
|
||||
? "-c"
|
||||
: "redis-server --port 6379"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={form.formState.isSubmitting} type="submit">
|
||||
Save
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 +45,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -52,12 +52,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TimeBadge } from "@/components/ui/time-badge";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleProject } from "./handle-project";
|
||||
import { ProjectEnvironment } from "./project-environment";
|
||||
|
||||
export const ShowProjects = () => {
|
||||
const utils = api.useUtils();
|
||||
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();
|
||||
@@ -135,6 +137,11 @@ export const ShowProjects = () => {
|
||||
<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,7 +155,6 @@ export const ShowProjects = () => {
|
||||
Create and manage your projects
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{(auth?.role === "owner" || auth?.canCreateProjects) && (
|
||||
<div className="">
|
||||
<HandleProject />
|
||||
@@ -298,7 +304,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 +352,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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -44,6 +44,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),
|
||||
@@ -103,7 +104,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),
|
||||
@@ -231,6 +232,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 || "",
|
||||
@@ -244,6 +246,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,
|
||||
@@ -258,6 +261,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,
|
||||
@@ -271,6 +275,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,
|
||||
@@ -288,6 +293,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,
|
||||
@@ -302,8 +308,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,
|
||||
@@ -345,6 +352,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy,
|
||||
dokployRestart,
|
||||
databaseBackup,
|
||||
volumeBackup,
|
||||
dockerCleanup,
|
||||
serverThreshold,
|
||||
} = data;
|
||||
@@ -355,6 +363,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel,
|
||||
name: data.name,
|
||||
@@ -369,6 +378,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
botToken: data.botToken,
|
||||
messageThreadId: data.messageThreadId || "",
|
||||
chatId: data.chatId,
|
||||
@@ -384,6 +394,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
decoration: data.decoration,
|
||||
name: data.name,
|
||||
@@ -398,6 +409,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
smtpServer: data.smtpServer,
|
||||
smtpPort: data.smtpPort,
|
||||
username: data.username,
|
||||
@@ -416,6 +428,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
serverUrl: data.serverUrl,
|
||||
appToken: data.appToken,
|
||||
priority: data.priority,
|
||||
@@ -431,8 +444,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,
|
||||
@@ -1001,8 +1015,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
<Input
|
||||
placeholder="AzxcvbnmKjhgfdsa..."
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. Leave blank for public topics.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -1130,6 +1148,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"
|
||||
@@ -1214,55 +1253,63 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingLark
|
||||
}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
|
||||
try {
|
||||
if (type === "slack") {
|
||||
if (data.type === "slack") {
|
||||
await testSlackConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
channel: form.getValues("channel"),
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel,
|
||||
});
|
||||
} else if (type === "telegram") {
|
||||
} else if (data.type === "telegram") {
|
||||
await testTelegramConnection({
|
||||
botToken: form.getValues("botToken"),
|
||||
chatId: form.getValues("chatId"),
|
||||
messageThreadId: form.getValues("messageThreadId") || "",
|
||||
botToken: data.botToken,
|
||||
chatId: data.chatId,
|
||||
messageThreadId: data.messageThreadId || "",
|
||||
});
|
||||
} else if (type === "discord") {
|
||||
} else if (data.type === "discord") {
|
||||
await testDiscordConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
decoration: form.getValues("decoration"),
|
||||
webhookUrl: data.webhookUrl,
|
||||
decoration: data.decoration,
|
||||
});
|
||||
} else if (type === "email") {
|
||||
} else if (data.type === "email") {
|
||||
await testEmailConnection({
|
||||
smtpServer: form.getValues("smtpServer"),
|
||||
smtpPort: form.getValues("smtpPort"),
|
||||
username: form.getValues("username"),
|
||||
password: form.getValues("password"),
|
||||
toAddresses: form.getValues("toAddresses"),
|
||||
fromAddress: form.getValues("fromAddress"),
|
||||
smtpServer: data.smtpServer,
|
||||
smtpPort: data.smtpPort,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
});
|
||||
} else if (type === "gotify") {
|
||||
} else if (data.type === "gotify") {
|
||||
await testGotifyConnection({
|
||||
serverUrl: form.getValues("serverUrl"),
|
||||
appToken: form.getValues("appToken"),
|
||||
priority: form.getValues("priority"),
|
||||
decoration: form.getValues("decoration"),
|
||||
serverUrl: data.serverUrl,
|
||||
appToken: data.appToken,
|
||||
priority: data.priority,
|
||||
decoration: data.decoration,
|
||||
});
|
||||
} else if (type === "ntfy") {
|
||||
} else if (data.type === "ntfy") {
|
||||
await testNtfyConnection({
|
||||
serverUrl: form.getValues("serverUrl"),
|
||||
topic: form.getValues("topic"),
|
||||
accessToken: form.getValues("accessToken"),
|
||||
priority: form.getValues("priority"),
|
||||
serverUrl: data.serverUrl,
|
||||
topic: data.topic,
|
||||
accessToken: data.accessToken || "",
|
||||
priority: data.priority,
|
||||
});
|
||||
} else if (type === "lark") {
|
||||
} else if (data.type === "lark") {
|
||||
await testLarkConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
webhookUrl: data.webhookUrl,
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch {
|
||||
toast.error("Error testing the provider");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Error testing the provider: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
+40
-7
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -85,7 +87,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
</EditTraefikEnv>
|
||||
|
||||
<DropdownMenuItem
|
||||
<DialogAction
|
||||
title={
|
||||
haveTraefikDashboardPortEnabled
|
||||
? "Disable Traefik Dashboard"
|
||||
: "Enable Traefik Dashboard"
|
||||
}
|
||||
description={
|
||||
<div className="space-y-4">
|
||||
<AlertBlock type="warning">
|
||||
The Traefik container will be recreated from scratch. This
|
||||
means the container will be deleted and created again, which
|
||||
may cause downtime in your applications.
|
||||
</AlertBlock>
|
||||
<p>
|
||||
Are you sure you want to{" "}
|
||||
{haveTraefikDashboardPortEnabled ? "disable" : "enable"} the
|
||||
Traefik dashboard?
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
onClick={async () => {
|
||||
await toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
@@ -97,14 +118,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
);
|
||||
refetchDashboard();
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
}}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
disabled={toggleDashboardIsLoading}
|
||||
type="default"
|
||||
>
|
||||
<span>
|
||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
>
|
||||
<span>
|
||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
|
||||
Dashboard
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
<ManageTraefikPorts serverId={serverId}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
|
||||
@@ -52,6 +52,7 @@ const Schema = z.object({
|
||||
sshKeyId: z.string().min(1, {
|
||||
message: "SSH Key is required",
|
||||
}),
|
||||
serverType: z.enum(["deploy", "build"]).default("deploy"),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
@@ -89,6 +90,7 @@ export const HandleServers = ({ serverId }: Props) => {
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: "",
|
||||
serverType: "deploy",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -101,6 +103,7 @@ export const HandleServers = ({ serverId }: Props) => {
|
||||
port: data?.port || 22,
|
||||
username: data?.username || "root",
|
||||
sshKeyId: data?.sshKeyId || "",
|
||||
serverType: data?.serverType || "deploy",
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||
|
||||
@@ -116,6 +119,7 @@ export const HandleServers = ({ serverId }: Props) => {
|
||||
port: data.port || 22,
|
||||
username: data.username || "root",
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
serverType: data.serverType || "deploy",
|
||||
serverId: serverId || "",
|
||||
})
|
||||
.then(async (_data) => {
|
||||
@@ -266,6 +270,50 @@ export const HandleServers = ({ serverId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverType"
|
||||
render={({ field }) => {
|
||||
const serverTypeValue = form.watch("serverType");
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Server Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="deploy">Deploy Server</SelectItem>
|
||||
<SelectItem value="build">Build Server</SelectItem>
|
||||
<SelectLabel>Server Type</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
{serverTypeValue === "deploy" && (
|
||||
<AlertBlock type="info" className="mt-2">
|
||||
Deploy servers are used to run your applications,
|
||||
databases, and services. They handle the deployment and
|
||||
execution of your projects.
|
||||
</AlertBlock>
|
||||
)}
|
||||
{serverTypeValue === "build" && (
|
||||
<AlertBlock type="info" className="mt-2">
|
||||
Build servers are dedicated to building your
|
||||
applications. They handle the compilation and build
|
||||
process, offloading this work from your deployment
|
||||
servers. Build servers won't appear in deployment
|
||||
options.
|
||||
</AlertBlock>
|
||||
)}
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshKeyId"
|
||||
|
||||
@@ -51,6 +51,7 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
|
||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const isBuildServer = server?.serverType === "build";
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
@@ -117,17 +118,26 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
<TabsList
|
||||
className={cn(
|
||||
"grid w-[700px]",
|
||||
isCloud ? "grid-cols-6" : "grid-cols-5",
|
||||
isBuildServer
|
||||
? "grid-cols-3"
|
||||
: isCloud
|
||||
? "grid-cols-6"
|
||||
: "grid-cols-5",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="validate">Validate</TabsTrigger>
|
||||
<TabsTrigger value="audit">Security</TabsTrigger>
|
||||
{isCloud && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
|
||||
{!isBuildServer && (
|
||||
<>
|
||||
<TabsTrigger value="audit">Security</TabsTrigger>
|
||||
{isCloud && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="ssh-keys"
|
||||
@@ -311,32 +321,36 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
<ValidateServer serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="audit"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||
<SecurityAudit serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm pt-3">
|
||||
<div className="rounded-xl bg-background shadow-md border">
|
||||
<SetupMonitoring serverId={serverId} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="gpu-setup"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||
<GPUSupport serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
{!isBuildServer && (
|
||||
<>
|
||||
<TabsContent
|
||||
value="audit"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||
<SecurityAudit serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm pt-3">
|
||||
<div className="rounded-xl bg-background shadow-md border">
|
||||
<SetupMonitoring serverId={serverId} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="gpu-setup"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||
<GPUSupport serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -129,6 +129,9 @@ export const ShowServers = () => {
|
||||
Status
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-center">
|
||||
Type
|
||||
</TableHead>
|
||||
<TableHead className="text-center">
|
||||
IP Address
|
||||
</TableHead>
|
||||
@@ -153,6 +156,8 @@ export const ShowServers = () => {
|
||||
{data?.map((server) => {
|
||||
const canDelete = server.totalSum === 0;
|
||||
const isActive = server.serverStatus === "active";
|
||||
const isBuildServer =
|
||||
server.serverType === "build";
|
||||
return (
|
||||
<TableRow key={server.serverId}>
|
||||
<TableCell className="text-left">
|
||||
@@ -171,6 +176,15 @@ export const ShowServers = () => {
|
||||
</Badge>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={
|
||||
isBuildServer ? "secondary" : "default"
|
||||
}
|
||||
>
|
||||
{server.serverType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge>{server.ipAddress}</Badge>
|
||||
</TableCell>
|
||||
@@ -233,11 +247,12 @@ export const ShowServers = () => {
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
|
||||
{server.sshKeyId && (
|
||||
<ShowServerActions
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
)}
|
||||
{server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<ShowServerActions
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -286,41 +301,43 @@ export const ShowServers = () => {
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
|
||||
{isActive && server.sshKeyId && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>
|
||||
Extra
|
||||
</DropdownMenuLabel>
|
||||
{isActive &&
|
||||
server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>
|
||||
Extra
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<ShowTraefikFileSystemModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
<ShowDockerContainersModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
{isCloud && (
|
||||
<ShowMonitoringModal
|
||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||
token={
|
||||
server?.metricsConfig?.server
|
||||
?.token
|
||||
}
|
||||
<ShowTraefikFileSystemModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
)}
|
||||
<ShowDockerContainersModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
{isCloud && (
|
||||
<ShowMonitoringModal
|
||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||
token={
|
||||
server?.metricsConfig
|
||||
?.server?.token
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ShowSwarmOverviewModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
<ShowNodesModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
<ShowSwarmOverviewModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
<ShowNodesModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
|
||||
<ShowSchedulesModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ShowSchedulesModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
|
||||
@@ -25,6 +25,13 @@ export const ValidateServer = ({ serverId }: Props) => {
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
const { data: server } = api.server.one.useQuery(
|
||||
{ serverId },
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
const isBuildServer = server?.serverType === "build";
|
||||
const _utils = api.useUtils();
|
||||
return (
|
||||
<CardContent className="p-0">
|
||||
@@ -73,7 +80,9 @@ export const ValidateServer = ({ serverId }: Props) => {
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-1">Status</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Shows the server configuration status
|
||||
{isBuildServer
|
||||
? "Shows the build server configuration status"
|
||||
: "Shows the server configuration status"}
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
@@ -85,15 +94,17 @@ export const ValidateServer = ({ serverId }: Props) => {
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="RClone Installed"
|
||||
isEnabled={data?.rclone?.enabled}
|
||||
description={
|
||||
data?.rclone?.enabled
|
||||
? `Installed: ${data?.rclone?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{!isBuildServer && (
|
||||
<StatusRow
|
||||
label="RClone Installed"
|
||||
isEnabled={data?.rclone?.enabled}
|
||||
description={
|
||||
data?.rclone?.enabled
|
||||
? `Installed: ${data?.rclone?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<StatusRow
|
||||
label="Nixpacks Installed"
|
||||
isEnabled={data?.nixpacks?.enabled}
|
||||
@@ -113,23 +124,36 @@ export const ValidateServer = ({ serverId }: Props) => {
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Docker Swarm Initialized"
|
||||
isEnabled={data?.isSwarmInstalled}
|
||||
label="Railpack Installed"
|
||||
isEnabled={data?.railpack?.enabled}
|
||||
description={
|
||||
data?.isSwarmInstalled
|
||||
? "Initialized"
|
||||
: "Not Initialized"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Dokploy Network Created"
|
||||
isEnabled={data?.isDokployNetworkInstalled}
|
||||
description={
|
||||
data?.isDokployNetworkInstalled
|
||||
? "Created"
|
||||
: "Not Created"
|
||||
data?.railpack?.enabled
|
||||
? `Installed: ${data?.railpack?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{!isBuildServer && (
|
||||
<>
|
||||
<StatusRow
|
||||
label="Docker Swarm Initialized"
|
||||
isEnabled={data?.isSwarmInstalled}
|
||||
description={
|
||||
data?.isSwarmInstalled
|
||||
? "Initialized"
|
||||
: "Not Initialized"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Dokploy Network Created"
|
||||
isEnabled={data?.isDokployNetworkInstalled}
|
||||
description={
|
||||
data?.isDokployNetworkInstalled
|
||||
? "Created"
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<StatusRow
|
||||
label="Main Directory Created"
|
||||
isEnabled={data?.isMainDirectoryInstalled}
|
||||
@@ -139,15 +163,6 @@ export const ValidateServer = ({ serverId }: Props) => {
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Railpack Installed"
|
||||
isEnabled={data?.railpack?.enabled}
|
||||
description={
|
||||
data?.railpack?.enabled
|
||||
? `Installed: ${data?.railpack?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,6 +95,7 @@ export const CreateServer = ({ stepper }: Props) => {
|
||||
port: data.port || 22,
|
||||
username: data.username || "root",
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
serverType: "deploy",
|
||||
})
|
||||
.then(async (_data) => {
|
||||
toast.success("Server Created");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
@@ -71,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,6 +83,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";
|
||||
|
||||
@@ -1155,6 +1156,7 @@ export default function Page({ children }: Props) {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{!isCloud && <TimeBadge />}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export function TimeBadge() {
|
||||
const { data: serverTime } = api.server.getServerTime.useQuery(undefined);
|
||||
const [time, setTime] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverTime?.time) {
|
||||
setTime(new Date(serverTime.time));
|
||||
}
|
||||
}, [serverTime]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTime((prevTime) => {
|
||||
if (!prevTime) return null;
|
||||
const newTime = new Date(prevTime.getTime() + 1000);
|
||||
return newTime;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!time || !serverTime?.timezone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getUtcOffset = (timeZone: string) => {
|
||||
const date = new Date();
|
||||
const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" }));
|
||||
const tzDate = new Date(date.toLocaleString("en-US", { timeZone }));
|
||||
const offset = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60 * 60);
|
||||
const sign = offset >= 0 ? "+" : "-";
|
||||
const hours = Math.floor(Math.abs(offset));
|
||||
const minutes = (Math.abs(offset) * 60) % 60;
|
||||
return `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formattedTime = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: serverTime.timezone,
|
||||
timeStyle: "medium",
|
||||
hour12: false,
|
||||
}).format(time);
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center rounded-full border p-1 text-xs whitespace-nowrap max-w-full overflow-hidden gap-1">
|
||||
<div className="inline-flex items-center px-1 gap-1">
|
||||
<span className="hidden sm:inline">Server Time:</span>
|
||||
<span className="font-medium tabular-nums">{formattedTime}</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline text-primary/70 border rounded-full bg-foreground/5 px-1.5 py-0.5">
|
||||
{serverTime.timezone} | {getUtcOffset(serverTime.timezone)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Fix inconsistent date formats in environment.createdAt field
|
||||
-- Convert PostgreSQL timestamp format to ISO 8601 format
|
||||
-- This addresses issue #2992 where old environments have PostgreSQL timestamp format
|
||||
-- while new ones have ISO 8601 format
|
||||
|
||||
UPDATE "environment"
|
||||
SET "createdAt" = to_char("createdAt"::timestamptz, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
|
||||
WHERE "createdAt" NOT LIKE '%T%';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TYPE "public"."serverType" AS ENUM('deploy', 'build');--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "buildServerId" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "buildRegistryId" text;--> statement-breakpoint
|
||||
ALTER TABLE "deployment" ADD COLUMN "buildServerId" text;--> statement-breakpoint
|
||||
ALTER TABLE "server" ADD COLUMN "serverType" "serverType" DEFAULT 'deploy' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD CONSTRAINT "application_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD CONSTRAINT "application_buildRegistryId_registry_registryId_fk" FOREIGN KEY ("buildRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "application" ADD COLUMN "args" text[];--> statement-breakpoint
|
||||
ALTER TABLE "mariadb" ADD COLUMN "args" text[];--> statement-breakpoint
|
||||
ALTER TABLE "mongo" ADD COLUMN "args" text[];--> statement-breakpoint
|
||||
ALTER TABLE "mysql" ADD COLUMN "args" text[];--> statement-breakpoint
|
||||
ALTER TABLE "postgres" ADD COLUMN "args" text[];--> statement-breakpoint
|
||||
ALTER TABLE "redis" ADD COLUMN "args" text[];
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "ntfy" ALTER COLUMN "accessToken" DROP NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "application" ADD COLUMN "rollbackRegistryId" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD CONSTRAINT "application_rollbackRegistryId_registry_registryId_fk" FOREIGN KEY ("rollbackRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ALTER COLUMN "enableDockerCleanup" SET DEFAULT true;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "notification" ADD COLUMN "volumeBackup" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -848,6 +848,55 @@
|
||||
"when": 1762632540024,
|
||||
"tag": "0120_lame_captain_midlands",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 121,
|
||||
"version": "7",
|
||||
"when": 1763755037033,
|
||||
"tag": "0121_rainy_cargill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 122,
|
||||
"version": "7",
|
||||
"when": 1764479387555,
|
||||
"tag": "0122_absent_frightful_four",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 123,
|
||||
"version": "7",
|
||||
"when": 1764525308939,
|
||||
"tag": "0123_cloudy_piledriver",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 124,
|
||||
"version": "7",
|
||||
"when": 1764571454170,
|
||||
"tag": "0124_certain_cloak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 125,
|
||||
"version": "7",
|
||||
"when": 1764573207555,
|
||||
"tag": "0125_neat_the_phantom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 126,
|
||||
"version": "7",
|
||||
"when": 1765065295708,
|
||||
"tag": "0126_nifty_monster_badoon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 127,
|
||||
"version": "7",
|
||||
"when": 1765095189368,
|
||||
"tag": "0127_superb_alice",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,9 +6,6 @@
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.25.6",
|
||||
"version": "v0.26.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -34,7 +34,8 @@
|
||||
"docker:build:canary": "./docker/build.sh canary",
|
||||
"docker:push:canary": "./docker/push.sh canary",
|
||||
"version": "echo $(node -p \"require('./package.json').version\")",
|
||||
"test": "vitest --config __test__/vitest.config.ts"
|
||||
"test": "vitest --config __test__/vitest.config.ts",
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.5",
|
||||
@@ -98,6 +99,7 @@
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.4.2",
|
||||
"shell-quote": "^1.8.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^0.2.1",
|
||||
@@ -112,15 +114,15 @@
|
||||
"i18next": "^23.16.8",
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"yaml": "2.8.1",
|
||||
"lodash": "4.17.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "^15.3.2",
|
||||
"next": "^16.0.7",
|
||||
"next-i18next": "^15.4.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"node-os-utils": "1.3.7",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"node-os-utils": "2.0.1",
|
||||
"node-pty": "1.0.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
@@ -153,17 +155,18 @@
|
||||
"use-resize-observer": "9.1.0",
|
||||
"ws": "8.16.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"yaml": "2.8.1",
|
||||
"zod": "^3.25.32",
|
||||
"zod-form-data": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^18.19.104",
|
||||
"@types/node-os-utils": "1.3.4",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
|
||||
@@ -7,6 +7,7 @@ import Head from "next/head";
|
||||
import Script from "next/script";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
@@ -57,6 +58,7 @@ const MyApp = ({
|
||||
disableTransitionOnChange
|
||||
forcedTheme={Component.theme}
|
||||
>
|
||||
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
||||
<Toaster richColors />
|
||||
<SearchCommand />
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
|
||||
@@ -12,6 +12,17 @@ import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { myQueue } from "@/server/queues/queueSetup";
|
||||
import { deploy } from "@/server/utils/deploy";
|
||||
|
||||
/**
|
||||
* Helper function to get package_version from registry_package events
|
||||
*/
|
||||
const getPackageVersion = (headers: any, body: any) => {
|
||||
const event = headers["x-github-event"];
|
||||
if (event === "registry_package") {
|
||||
return body.registry_package?.package_version;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
@@ -46,21 +57,60 @@ export default async function handler(
|
||||
}
|
||||
|
||||
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
||||
const deploymentHash = extractHash(req.headers, req.body);
|
||||
|
||||
const deploymentHash = extractHash(req.headers, req.body);
|
||||
const sourceType = application.sourceType;
|
||||
|
||||
if (sourceType === "docker") {
|
||||
const applicationImageName = extractImageName(application.dockerImage);
|
||||
const applicationDockerTag = extractImageTag(application.dockerImage);
|
||||
|
||||
const webhookImageName = extractImageNameFromRequest(
|
||||
req.headers,
|
||||
req.body,
|
||||
);
|
||||
const webhookDockerTag = extractImageTagFromRequest(
|
||||
req.headers,
|
||||
req.body,
|
||||
);
|
||||
if (
|
||||
applicationDockerTag &&
|
||||
webhookDockerTag &&
|
||||
webhookDockerTag !== applicationDockerTag
|
||||
) {
|
||||
|
||||
if (!applicationImageName) {
|
||||
res.status(301).json({
|
||||
message: "Application Docker Image Name Not Found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!webhookImageName) {
|
||||
res.status(301).json({
|
||||
message: "Webhook Docker Image Name Not Found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate image name matches
|
||||
if (webhookImageName !== applicationImageName) {
|
||||
res.status(301).json({
|
||||
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!applicationDockerTag) {
|
||||
res.status(301).json({
|
||||
message: "Application Docker Tag Not Found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!webhookDockerTag) {
|
||||
res.status(301).json({
|
||||
message: "Webhook Docker Tag Not Found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (webhookDockerTag !== applicationDockerTag) {
|
||||
res.status(301).json({
|
||||
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
|
||||
});
|
||||
@@ -191,7 +241,7 @@ export default async function handler(
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: application.applicationId as string,
|
||||
titleLog: deploymentTitle,
|
||||
descriptionLog: `Hash: ${deploymentHash}`,
|
||||
...(deploymentHash && { descriptionLog: `Hash: ${deploymentHash}` }),
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
server: !!application.serverId,
|
||||
@@ -222,6 +272,39 @@ export default async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the image name without the tag
|
||||
* Example: "my-image" => "my-image"
|
||||
* Example: "my-image:latest" => "my-image"
|
||||
* Example: "my-image:1.0.0" => "my-image"
|
||||
* Example: "myregistryhost:5000/fedora/httpd:version1.0" => "myregistryhost:5000/fedora/httpd"
|
||||
* @link https://docs.docker.com/reference/cli/docker/image/tag/
|
||||
*/
|
||||
export function extractImageName(dockerImage: string | null): string | null {
|
||||
if (!dockerImage || typeof dockerImage !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle case where there's no tag (no colon or colon is part of port number)
|
||||
const lastColonIndex = dockerImage.lastIndexOf(":");
|
||||
if (lastColonIndex === -1) {
|
||||
return dockerImage;
|
||||
}
|
||||
|
||||
// Check if the part after the last colon looks like a tag (not a port number)
|
||||
// Port numbers are typically 1-5 digits, tags are usually longer or contain letters
|
||||
const afterColon = dockerImage.substring(lastColonIndex + 1);
|
||||
const isPortNumber = /^\d{1,5}$/.test(afterColon);
|
||||
|
||||
// If it's a port number (like registry:5000/image), don't split
|
||||
if (isPortNumber) {
|
||||
return dockerImage;
|
||||
}
|
||||
|
||||
// Otherwise, split at the last colon to get image name
|
||||
return dockerImage.substring(0, lastColonIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last part of the image name, which is the tag
|
||||
* Example: "my-image" => null
|
||||
@@ -230,7 +313,7 @@ export default async function handler(
|
||||
* Example: "myregistryhost:5000/fedora/httpd:version1.0" => "version1.0"
|
||||
* @link https://docs.docker.com/reference/cli/docker/image/tag/
|
||||
*/
|
||||
function extractImageTag(dockerImage: string | null) {
|
||||
export function extractImageTag(dockerImage: string | null) {
|
||||
if (!dockerImage || typeof dockerImage !== "string") {
|
||||
return null;
|
||||
}
|
||||
@@ -240,12 +323,78 @@ function extractImageTag(dockerImage: string | null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the image name (without tag) from webhook request
|
||||
* @link https://docs.docker.com/docker-hub/webhooks/#example-webhook-payload
|
||||
* @link https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package
|
||||
*/
|
||||
export const extractImageNameFromRequest = (
|
||||
headers: any,
|
||||
body: any,
|
||||
): string | null => {
|
||||
// GitHub Packages: registry_package events (container registry)
|
||||
const packageVersion = getPackageVersion(headers, body);
|
||||
if (packageVersion?.package_url) {
|
||||
const packageUrl = packageVersion.package_url;
|
||||
// Remove tag if present (everything after the last colon)
|
||||
if (packageUrl.includes(":")) {
|
||||
const lastColonIndex = packageUrl.lastIndexOf(":");
|
||||
// Check if it's a port number (like registry:5000/image)
|
||||
const afterColon = packageUrl.substring(lastColonIndex + 1);
|
||||
const isPortNumber = /^\d{1,5}$/.test(afterColon);
|
||||
if (isPortNumber) {
|
||||
return packageUrl;
|
||||
}
|
||||
return packageUrl.substring(0, lastColonIndex);
|
||||
}
|
||||
return packageUrl;
|
||||
}
|
||||
|
||||
// Docker Hub
|
||||
if (headers["user-agent"]?.includes("Go-http-client")) {
|
||||
if (body.repository) {
|
||||
const repoName = body.repository.repo_name;
|
||||
return `${repoName}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @link https://docs.docker.com/docker-hub/webhooks/#example-webhook-payload
|
||||
* @link https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package
|
||||
*/
|
||||
export const extractImageTagFromRequest = (
|
||||
headers: any,
|
||||
body: any,
|
||||
): string | null => {
|
||||
// GitHub Packages: registry_package events (container registry)
|
||||
const packageVersion = getPackageVersion(headers, body);
|
||||
if (packageVersion) {
|
||||
// Try to get tag from container_metadata first (most reliable)
|
||||
// Only use it if it's not empty and not the same as the version (digest)
|
||||
const tagName = packageVersion.container_metadata?.tag?.name?.trim() || "";
|
||||
if (
|
||||
tagName &&
|
||||
tagName !== packageVersion.version &&
|
||||
!tagName.startsWith("sha256:")
|
||||
) {
|
||||
return tagName;
|
||||
}
|
||||
// Fallback: extract tag from package_url (e.g., "ghcr.io/owner/repo:tag")
|
||||
if (packageVersion.package_url) {
|
||||
const packageUrl = packageVersion.package_url;
|
||||
// Handle case where package_url ends with colon (no tag)
|
||||
if (packageUrl.endsWith(":")) {
|
||||
return null;
|
||||
}
|
||||
const tagMatch = packageUrl.match(/:([^:]+)$/);
|
||||
if (tagMatch?.[1]?.trim()) {
|
||||
return tagMatch[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Docker Hub
|
||||
if (headers["user-agent"]?.includes("Go-http-client")) {
|
||||
if (body.push_data && body.repository) {
|
||||
return body.push_data.tag;
|
||||
@@ -255,6 +404,18 @@ export const extractImageTagFromRequest = (
|
||||
};
|
||||
|
||||
export const extractCommitMessage = (headers: any, body: any) => {
|
||||
// GitHub Packages: registry_package events (container tags)
|
||||
const githubEvent = headers["x-github-event"];
|
||||
if (githubEvent === "registry_package") {
|
||||
const packageVersion = getPackageVersion(headers, body);
|
||||
if (packageVersion) {
|
||||
if (packageVersion.package_url) {
|
||||
return `Docker GHCR image pushed: ${packageVersion.package_url}`;
|
||||
}
|
||||
return "Docker GHCR image pushed";
|
||||
}
|
||||
// If package_version is missing, fall through to default behavior
|
||||
}
|
||||
// GitHub
|
||||
if (headers["x-github-event"]) {
|
||||
return body.head_commit ? body.head_commit.message : "NEW COMMIT";
|
||||
@@ -283,7 +444,7 @@ export const extractCommitMessage = (headers: any, body: any) => {
|
||||
|
||||
if (headers["user-agent"]?.includes("Go-http-client")) {
|
||||
if (body.push_data && body.repository) {
|
||||
return `Docker image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
|
||||
return `DockerHub image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { findProjectById } from "@dokploy/server";
|
||||
import type { findEnvironmentById } from "@dokploy/server";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import {
|
||||
@@ -102,6 +102,7 @@ import { api } from "@/utils/api";
|
||||
export type Services = {
|
||||
appName: string;
|
||||
serverId?: string | null;
|
||||
serverName?: string | null;
|
||||
name: string;
|
||||
type:
|
||||
| "mariadb"
|
||||
@@ -118,8 +119,7 @@ export type Services = {
|
||||
lastDeployDate?: Date | null;
|
||||
};
|
||||
|
||||
type Project = Awaited<ReturnType<typeof findProjectById>>;
|
||||
type Environment = Project["environments"][0];
|
||||
type Environment = Awaited<ReturnType<typeof findEnvironmentById>>;
|
||||
|
||||
export const extractServicesFromEnvironment = (
|
||||
environment: Environment | undefined,
|
||||
@@ -154,6 +154,7 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
lastDeployDate,
|
||||
};
|
||||
}) || [];
|
||||
@@ -168,6 +169,7 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const postgres: Services[] =
|
||||
@@ -180,6 +182,7 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const mongo: Services[] =
|
||||
@@ -192,6 +195,7 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const redis: Services[] =
|
||||
@@ -204,6 +208,7 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const mysql: Services[] =
|
||||
@@ -216,6 +221,7 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const compose: Services[] =
|
||||
@@ -244,6 +250,7 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.composeStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
lastDeployDate,
|
||||
};
|
||||
}) || [];
|
||||
@@ -392,6 +399,7 @@ const EnvironmentPage = (
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [deleteVolumes, setDeleteVolumes] = useState(false);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>("all");
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedServices.length === filteredServices.length) {
|
||||
@@ -781,6 +789,27 @@ const EnvironmentPage = (
|
||||
setIsBulkActionLoading(false);
|
||||
};
|
||||
|
||||
// Get unique servers from services
|
||||
const availableServers = useMemo(() => {
|
||||
if (!applications) return [];
|
||||
const servers = new Map<string, { serverId: string; serverName: string }>();
|
||||
applications.forEach((service) => {
|
||||
if (service.serverId && service.serverName) {
|
||||
servers.set(service.serverId, {
|
||||
serverId: service.serverId,
|
||||
serverName: service.serverName,
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(servers.values());
|
||||
}, [applications]);
|
||||
|
||||
// Check if there are services without a server (Dokploy server)
|
||||
const hasServicesWithoutServer = useMemo(() => {
|
||||
if (!applications) return false;
|
||||
return applications.some((service) => !service.serverId);
|
||||
}, [applications]);
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
if (!applications) return [];
|
||||
const filtered = applications.filter(
|
||||
@@ -789,10 +818,14 @@ const EnvironmentPage = (
|
||||
service.description
|
||||
?.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())) &&
|
||||
(selectedTypes.length === 0 || selectedTypes.includes(service.type)),
|
||||
(selectedTypes.length === 0 || selectedTypes.includes(service.type)) &&
|
||||
(selectedServerId === "" ||
|
||||
selectedServerId === "all" ||
|
||||
(selectedServerId === "dokploy-server" && !service.serverId) ||
|
||||
service.serverId === selectedServerId),
|
||||
);
|
||||
return sortServices(filtered);
|
||||
}, [applications, searchQuery, selectedTypes, sortBy]);
|
||||
}, [applications, searchQuery, selectedTypes, selectedServerId, sortBy]);
|
||||
|
||||
const selectedServicesWithRunningStatus = useMemo(() => {
|
||||
return filteredServices.filter(
|
||||
@@ -1366,6 +1399,39 @@ const EnvironmentPage = (
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{(availableServers.length > 0 ||
|
||||
hasServicesWithoutServer) && (
|
||||
<Select
|
||||
value={selectedServerId || "all"}
|
||||
onValueChange={setSelectedServerId}
|
||||
>
|
||||
<SelectTrigger className="lg:w-[200px]">
|
||||
<SelectValue placeholder="Filter by server..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All servers</SelectItem>
|
||||
{hasServicesWithoutServer && (
|
||||
<SelectItem value="dokploy-server">
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerIcon className="size-4" />
|
||||
<span>Dokploy server</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
{availableServers.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerIcon className="size-4" />
|
||||
<span>{server.serverName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1471,7 +1537,15 @@ const EnvironmentPage = (
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="mt-auto">
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="space-y-1 text-sm w-full">
|
||||
{service.serverName && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||
<ServerIcon className="size-3" />
|
||||
<span className="truncate">
|
||||
{service.serverName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<DateTooltip date={service.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@ import { AddCommand } from "@/components/dashboard/application/advanced/general/
|
||||
import { ShowPorts } from "@/components/dashboard/application/advanced/ports/show-port";
|
||||
import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects";
|
||||
import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security";
|
||||
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
|
||||
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
|
||||
import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config";
|
||||
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
||||
@@ -353,7 +354,7 @@ const Service = (
|
||||
id={applicationId}
|
||||
type="application"
|
||||
/>
|
||||
|
||||
<ShowBuildServer applicationId={applicationId} />
|
||||
<ShowResources id={applicationId} type="application" />
|
||||
<ShowVolumes id={applicationId} type="application" />
|
||||
<ShowRedirects applicationId={applicationId} />
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Script to generate OpenAPI specification locally
|
||||
* This runs in CI/CD to generate the openapi.json file
|
||||
* which can then be consumed by the documentation website
|
||||
*/
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
|
||||
import { appRouter } from "../server/api/root";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
async function generateOpenAPI() {
|
||||
try {
|
||||
console.log("🔄 Generating OpenAPI specification...");
|
||||
|
||||
const openApiDocument = generateOpenApiDocument(appRouter, {
|
||||
title: "Dokploy API",
|
||||
version: "1.0.0",
|
||||
baseUrl: "https://your-dokploy-instance.com/api",
|
||||
docsUrl: "https://docs.dokploy.com/api",
|
||||
tags: [
|
||||
"admin",
|
||||
"docker",
|
||||
"compose",
|
||||
"registry",
|
||||
"cluster",
|
||||
"user",
|
||||
"domain",
|
||||
"destination",
|
||||
"backup",
|
||||
"deployment",
|
||||
"mounts",
|
||||
"certificates",
|
||||
"settings",
|
||||
"security",
|
||||
"redirects",
|
||||
"port",
|
||||
"project",
|
||||
"application",
|
||||
"mysql",
|
||||
"postgres",
|
||||
"redis",
|
||||
"mongo",
|
||||
"mariadb",
|
||||
"sshRouter",
|
||||
"gitProvider",
|
||||
"bitbucket",
|
||||
"github",
|
||||
"gitlab",
|
||||
"gitea",
|
||||
"server",
|
||||
"swarm",
|
||||
"ai",
|
||||
"organization",
|
||||
"schedule",
|
||||
"rollback",
|
||||
"volumeBackups",
|
||||
"environment",
|
||||
],
|
||||
});
|
||||
|
||||
// Enhance metadata
|
||||
openApiDocument.info = {
|
||||
title: "Dokploy API",
|
||||
description:
|
||||
"Complete API documentation for Dokploy - Deploy applications, manage databases, and orchestrate your infrastructure. This API allows you to programmatically manage all aspects of your Dokploy instance.",
|
||||
version: "1.0.0",
|
||||
contact: {
|
||||
name: "Dokploy Team",
|
||||
url: "https://dokploy.com",
|
||||
},
|
||||
license: {
|
||||
name: "Apache 2.0",
|
||||
url: "https://github.com/dokploy/dokploy/blob/canary/LICENSE",
|
||||
},
|
||||
};
|
||||
|
||||
// Add security schemes
|
||||
openApiDocument.components = {
|
||||
...openApiDocument.components,
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: "apiKey",
|
||||
in: "header",
|
||||
name: "x-api-key",
|
||||
description:
|
||||
"API key authentication. Generate an API key from your Dokploy dashboard under Settings > API Keys.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Apply global security
|
||||
openApiDocument.security = [
|
||||
{
|
||||
apiKey: [],
|
||||
},
|
||||
];
|
||||
|
||||
// Add external docs
|
||||
openApiDocument.externalDocs = {
|
||||
description: "Full documentation",
|
||||
url: "https://docs.dokploy.com",
|
||||
};
|
||||
|
||||
// Write to root of repo
|
||||
const outputPath = resolve(__dirname, "../../../openapi.json");
|
||||
writeFileSync(
|
||||
outputPath,
|
||||
JSON.stringify(openApiDocument, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
console.log("✅ OpenAPI specification generated successfully!");
|
||||
console.log(`📄 Output: ${outputPath}`);
|
||||
console.log(
|
||||
`📊 Endpoints: ${Object.keys(openApiDocument.paths || {}).length}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ Error generating OpenAPI specification:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
generateOpenAPI();
|
||||
@@ -58,7 +58,11 @@ import {
|
||||
applications,
|
||||
} from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup";
|
||||
import {
|
||||
cleanQueuesByApplication,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
import { cancelDeployment, deploy } from "@/server/utils/deploy";
|
||||
import { uploadFileSchema } from "@/utils/schema";
|
||||
|
||||
@@ -725,7 +729,21 @@ export const applicationRouter = createTRPCRouter({
|
||||
}
|
||||
await cleanQueuesByApplication(input.applicationId);
|
||||
}),
|
||||
|
||||
killBuild: protectedProcedure
|
||||
.input(apiFindOneApplication)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to kill this build",
|
||||
});
|
||||
}
|
||||
await killDockerBuild("application", application.serverId);
|
||||
}),
|
||||
readTraefikConfig: protectedProcedure
|
||||
.input(apiFindOneApplication)
|
||||
.query(async ({ input, ctx }) => {
|
||||
|
||||
@@ -59,7 +59,11 @@ import {
|
||||
compose as composeTable,
|
||||
} from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
|
||||
import {
|
||||
cleanQueuesByCompose,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
import { cancelDeployment, deploy } from "@/server/utils/deploy";
|
||||
import { generatePassword } from "@/templates/utils";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
@@ -248,6 +252,21 @@ export const composeRouter = createTRPCRouter({
|
||||
await cleanQueuesByCompose(input.composeId);
|
||||
return { success: true, message: "Queues cleaned successfully" };
|
||||
}),
|
||||
killBuild: protectedProcedure
|
||||
.input(apiFindCompose)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const compose = await findComposeById(input.composeId);
|
||||
if (
|
||||
compose.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to kill this build",
|
||||
});
|
||||
}
|
||||
await killDockerBuild("compose", compose.serverId);
|
||||
}),
|
||||
|
||||
loadServices: protectedProcedure
|
||||
.input(apiFetchServices)
|
||||
|
||||
@@ -47,15 +47,19 @@ export const destinationRouter = createTRPCRouter({
|
||||
input;
|
||||
try {
|
||||
const rcloneFlags = [
|
||||
`--s3-access-key-id=${accessKey}`,
|
||||
`--s3-secret-access-key=${secretAccessKey}`,
|
||||
`--s3-region=${region}`,
|
||||
`--s3-endpoint=${endpoint}`,
|
||||
`--s3-access-key-id="${accessKey}"`,
|
||||
`--s3-secret-access-key="${secretAccessKey}"`,
|
||||
`--s3-region="${region}"`,
|
||||
`--s3-endpoint="${endpoint}"`,
|
||||
"--s3-no-check-bucket",
|
||||
"--s3-force-path-style",
|
||||
"--retries 1",
|
||||
"--low-level-retries 1",
|
||||
"--timeout 10s",
|
||||
"--contimeout 5s",
|
||||
];
|
||||
if (provider) {
|
||||
rcloneFlags.unshift(`--s3-provider=${provider}`);
|
||||
rcloneFlags.unshift(`--s3-provider="${provider}"`);
|
||||
}
|
||||
const rcloneDestination = `:s3:${bucket}`;
|
||||
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
@@ -111,7 +111,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error testing the notification",
|
||||
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error testing the notification",
|
||||
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -285,7 +285,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error testing the notification",
|
||||
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
findEnvironmentById,
|
||||
findPostgresById,
|
||||
findProjectById,
|
||||
getMountPath,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
removePostgresById,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
postgres as postgresTable,
|
||||
} from "@/server/db/schema";
|
||||
import { cancelJobs } from "@/server/utils/backup";
|
||||
|
||||
export const postgresRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(apiCreatePostgres)
|
||||
@@ -79,11 +81,13 @@ export const postgresRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
const mountPath = getMountPath(input.dockerImage);
|
||||
|
||||
await createMount({
|
||||
serviceId: newPostgres.postgresId,
|
||||
serviceType: "postgres",
|
||||
volumeName: `${newPostgres.appName}-data`,
|
||||
mountPath: "/var/lib/postgresql/data",
|
||||
mountPath: mountPath,
|
||||
type: "volume",
|
||||
});
|
||||
|
||||
@@ -282,12 +286,16 @@ export const postgresRouter = createTRPCRouter({
|
||||
const backups = await findBackupsByDbId(input.postgresId, "postgres");
|
||||
|
||||
const cleanupOperations = [
|
||||
removeService(postgres.appName, postgres.serverId),
|
||||
cancelJobs(backups),
|
||||
removePostgresById(input.postgresId),
|
||||
async () => await removeService(postgres?.appName, postgres.serverId),
|
||||
async () => await cancelJobs(backups),
|
||||
async () => await removePostgresById(input.postgresId),
|
||||
];
|
||||
|
||||
await Promise.allSettled(cleanupOperations);
|
||||
for (const operation of cleanupOperations) {
|
||||
try {
|
||||
await operation();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return postgres;
|
||||
}),
|
||||
@@ -363,6 +371,7 @@ export const postgresRouter = createTRPCRouter({
|
||||
message: "You are not authorized to update this Postgres",
|
||||
});
|
||||
}
|
||||
|
||||
const service = await updatePostgresById(postgresId, {
|
||||
...rest,
|
||||
});
|
||||
|
||||
@@ -81,8 +81,10 @@ export const serverRouter = createTRPCRouter({
|
||||
}),
|
||||
getDefaultCommand: protectedProcedure
|
||||
.input(apiFindOneServer)
|
||||
.query(async () => {
|
||||
return defaultCommand();
|
||||
.query(async ({ input }) => {
|
||||
const server = await findServerById(input.serverId);
|
||||
const isBuildServer = server.serverType === "build";
|
||||
return defaultCommand(isBuildServer);
|
||||
}),
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
const result = await db
|
||||
@@ -124,10 +126,30 @@ export const serverRouter = createTRPCRouter({
|
||||
isNotNull(server.sshKeyId),
|
||||
eq(server.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(server.serverStatus, "active"),
|
||||
eq(server.serverType, "deploy"),
|
||||
)
|
||||
: and(
|
||||
isNotNull(server.sshKeyId),
|
||||
eq(server.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(server.serverType, "deploy"),
|
||||
),
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
buildServers: protectedProcedure.query(async ({ ctx }) => {
|
||||
const result = await db.query.server.findMany({
|
||||
orderBy: desc(server.createdAt),
|
||||
where: IS_CLOUD
|
||||
? and(
|
||||
isNotNull(server.sshKeyId),
|
||||
eq(server.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(server.serverStatus, "active"),
|
||||
eq(server.serverType, "build"),
|
||||
)
|
||||
: and(
|
||||
isNotNull(server.sshKeyId),
|
||||
eq(server.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(server.serverType, "build"),
|
||||
),
|
||||
});
|
||||
return result;
|
||||
@@ -383,6 +405,15 @@ export const serverRouter = createTRPCRouter({
|
||||
const ip = await getPublicIpWithFallback();
|
||||
return ip;
|
||||
}),
|
||||
getServerTime: protectedProcedure.query(() => {
|
||||
if (IS_CLOUD) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
time: new Date(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
}),
|
||||
getServerMetrics: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
canAccessToTraefikFiles,
|
||||
checkGPUStatus,
|
||||
cleanStoppedContainers,
|
||||
cleanUpDockerBuilder,
|
||||
cleanUpSystemPrune,
|
||||
cleanUpUnusedImages,
|
||||
cleanUpUnusedVolumes,
|
||||
cleanupContainers,
|
||||
cleanupBuilders,
|
||||
cleanupSystem,
|
||||
cleanupImages,
|
||||
cleanupVolumes,
|
||||
cleanupAll,
|
||||
checkPortInUse,
|
||||
DEFAULT_UPDATE_DATA,
|
||||
execAsync,
|
||||
findServerById,
|
||||
@@ -130,6 +132,17 @@ export const settingsRouter = createTRPCRouter({
|
||||
let newPorts = ports;
|
||||
// If receive true, add 8080 to ports
|
||||
if (input.enableDashboard) {
|
||||
// Check if port 8080 is already in use before enabling dashboard
|
||||
const portCheck = await checkPortInUse(8080, input.serverId);
|
||||
if (portCheck.isInUse) {
|
||||
const conflictingContainer = portCheck.conflictingContainer
|
||||
? ` by container "${portCheck.conflictingContainer}"`
|
||||
: "";
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
|
||||
});
|
||||
}
|
||||
newPorts.push({
|
||||
targetPort: 8080,
|
||||
publishedPort: 8080,
|
||||
@@ -149,41 +162,38 @@ export const settingsRouter = createTRPCRouter({
|
||||
cleanUnusedImages: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanUpUnusedImages(input?.serverId);
|
||||
await cleanupImages(input?.serverId);
|
||||
return true;
|
||||
}),
|
||||
cleanUnusedVolumes: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanUpUnusedVolumes(input?.serverId);
|
||||
await cleanupVolumes(input?.serverId);
|
||||
return true;
|
||||
}),
|
||||
cleanStoppedContainers: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanStoppedContainers(input?.serverId);
|
||||
await cleanupContainers(input?.serverId);
|
||||
return true;
|
||||
}),
|
||||
cleanDockerBuilder: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanUpDockerBuilder(input?.serverId);
|
||||
await cleanupBuilders(input?.serverId);
|
||||
}),
|
||||
cleanDockerPrune: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanUpSystemPrune(input?.serverId);
|
||||
await cleanUpDockerBuilder(input?.serverId);
|
||||
await cleanupSystem(input?.serverId);
|
||||
await cleanupBuilders(input?.serverId);
|
||||
|
||||
return true;
|
||||
}),
|
||||
cleanAll: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanUpUnusedImages(input?.serverId);
|
||||
await cleanStoppedContainers(input?.serverId);
|
||||
await cleanUpDockerBuilder(input?.serverId);
|
||||
await cleanUpSystemPrune(input?.serverId);
|
||||
await cleanupAll(input?.serverId);
|
||||
|
||||
return true;
|
||||
}),
|
||||
@@ -281,9 +291,9 @@ export const settingsRouter = createTRPCRouter({
|
||||
console.log(
|
||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||
);
|
||||
await cleanUpUnusedImages(server.serverId);
|
||||
await cleanUpDockerBuilder(server.serverId);
|
||||
await cleanUpSystemPrune(server.serverId);
|
||||
|
||||
await cleanupAll(server.serverId);
|
||||
|
||||
await sendDockerCleanupNotifications(server.organizationId);
|
||||
});
|
||||
}
|
||||
@@ -309,9 +319,9 @@ export const settingsRouter = createTRPCRouter({
|
||||
console.log(
|
||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||
);
|
||||
await cleanUpUnusedImages();
|
||||
await cleanUpDockerBuilder();
|
||||
await cleanUpSystemPrune();
|
||||
|
||||
await cleanupAll();
|
||||
|
||||
await sendDockerCleanupNotifications(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
@@ -587,7 +597,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
return ports.some((port) => port.targetPort === 8080);
|
||||
}),
|
||||
|
||||
readStatsLogs: adminProcedure
|
||||
readStatsLogs: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
path: "/read-stats-logs",
|
||||
@@ -650,7 +660,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
|
||||
return processedLogs || [];
|
||||
}),
|
||||
haveActivateRequests: adminProcedure.query(async () => {
|
||||
haveActivateRequests: protectedProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
@@ -665,7 +675,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
|
||||
return !!parsedConfig?.accessLog?.filePath;
|
||||
}),
|
||||
toggleRequests: adminProcedure
|
||||
toggleRequests: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
enable: z.boolean(),
|
||||
@@ -810,6 +820,19 @@ export const settingsRouter = createTRPCRouter({
|
||||
"dokploy-traefik",
|
||||
input?.serverId,
|
||||
);
|
||||
|
||||
for (const port of input.additionalPorts) {
|
||||
const portCheck = await checkPortInUse(
|
||||
port.publishedPort,
|
||||
input.serverId,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${port.targetPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const preparedEnv = prepareEnvironmentVariables(env);
|
||||
|
||||
await writeTraefikSetup({
|
||||
@@ -835,7 +858,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
const ports = await readPorts("dokploy-traefik", input?.serverId);
|
||||
return ports;
|
||||
}),
|
||||
updateLogCleanup: adminProcedure
|
||||
updateLogCleanup: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
cronExpression: z.string().nullable(),
|
||||
@@ -851,7 +874,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
return stopLogCleanup();
|
||||
}),
|
||||
|
||||
getLogCleanupStatus: adminProcedure.query(async () => {
|
||||
getLogCleanupStatus: protectedProcedure.query(async () => {
|
||||
return getLogCleanupStatus();
|
||||
}),
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ import { z } from "zod";
|
||||
|
||||
export const domain = z
|
||||
.object({
|
||||
host: z.string().min(1, { message: "Add a hostname" }),
|
||||
host: z
|
||||
.string()
|
||||
.min(1, { message: "Add a hostname" })
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
path: z.string().min(1).optional(),
|
||||
port: z
|
||||
.number()
|
||||
@@ -33,7 +39,13 @@ export const domain = z
|
||||
|
||||
export const domainCompose = z
|
||||
.object({
|
||||
host: z.string().min(1, { message: "Host is required" }),
|
||||
host: z
|
||||
.string()
|
||||
.min(1, { message: "Add a hostname" })
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
path: z.string().min(1).optional(),
|
||||
port: z
|
||||
.number()
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { Queue } from "bullmq";
|
||||
import { redisConfig } from "./redis-connection";
|
||||
|
||||
@@ -41,4 +45,31 @@ export const cleanQueuesByCompose = async (composeId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const killDockerBuild = async (
|
||||
type: "application" | "compose",
|
||||
serverId: string | null,
|
||||
) => {
|
||||
try {
|
||||
if (type === "application") {
|
||||
const command = `pkill -2 -f "docker build"`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
} else if (type === "compose") {
|
||||
const command = `pkill -2 -f "docker compose"`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export { myQueue };
|
||||
|
||||
@@ -4,11 +4,11 @@ import {
|
||||
createDefaultServerTraefikConfig,
|
||||
createDefaultTraefikConfig,
|
||||
IS_CLOUD,
|
||||
initCancelDeployments,
|
||||
initCronJobs,
|
||||
initializeNetwork,
|
||||
initSchedules,
|
||||
initVolumeBackupsCronJobs,
|
||||
initCancelDeployments,
|
||||
sendDokployRestartNotifications,
|
||||
setupDirectories,
|
||||
} from "@dokploy/server";
|
||||
@@ -26,6 +26,16 @@ config({ path: ".env" });
|
||||
const PORT = Number.parseInt(process.env.PORT || "3000", 10);
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
|
||||
// Initialize critical directories and Traefik config BEFORE Next.js starts
|
||||
// This prevents race conditions with the install script
|
||||
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
|
||||
setupDirectories();
|
||||
createDefaultTraefikConfig();
|
||||
createDefaultServerTraefikConfig();
|
||||
console.log("✅ Critical initialization complete");
|
||||
}
|
||||
|
||||
const app = next({ dev, turbopack: process.env.TURBOPACK === "1" });
|
||||
const handle = app.getRequestHandler();
|
||||
void app.prepare().then(async () => {
|
||||
@@ -45,11 +55,8 @@ void app.prepare().then(async () => {
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
|
||||
setupDirectories();
|
||||
createDefaultMiddlewares();
|
||||
await initializeNetwork();
|
||||
createDefaultTraefikConfig();
|
||||
createDefaultServerTraefikConfig();
|
||||
await migration();
|
||||
await initCronJobs();
|
||||
await initSchedules();
|
||||
|
||||
@@ -46,6 +46,14 @@ export const setupDockerContainerLogsWebSocketServer = (
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up keep-alive ping mechanism to prevent timeout
|
||||
// Send ping every 45 seconds to keep connection alive
|
||||
const pingInterval = setInterval(() => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.ping();
|
||||
}
|
||||
}, 45000); // 45 seconds
|
||||
try {
|
||||
if (serverId) {
|
||||
const server = await findServerById(serverId);
|
||||
@@ -63,7 +71,9 @@ export const setupDockerContainerLogsWebSocketServer = (
|
||||
const command = search
|
||||
? `${baseCommand} 2>&1 | grep --line-buffered -iF "${escapedSearch}"`
|
||||
: baseCommand;
|
||||
client.exec(command, (err, stream) => {
|
||||
// Use pty: true to ensure the remote process receives SIGHUP when SSH connection closes
|
||||
// This is crucial for terminating docker logs processes when the connection is closed
|
||||
client.exec(command, { pty: true }, (err, stream) => {
|
||||
if (err) {
|
||||
console.error("Execution error:", err);
|
||||
ws.close();
|
||||
@@ -86,6 +96,7 @@ export const setupDockerContainerLogsWebSocketServer = (
|
||||
.on("error", (err) => {
|
||||
console.error("SSH connection error:", err);
|
||||
ws.send(`SSH error: ${err.message}`);
|
||||
clearInterval(pingInterval);
|
||||
ws.close(); // Cierra el WebSocket si hay un error con SSH
|
||||
client.end();
|
||||
})
|
||||
@@ -96,6 +107,7 @@ export const setupDockerContainerLogsWebSocketServer = (
|
||||
privateKey: server.sshKey?.privateKey,
|
||||
});
|
||||
ws.on("close", () => {
|
||||
clearInterval(pingInterval);
|
||||
client.end();
|
||||
});
|
||||
} else {
|
||||
@@ -121,6 +133,7 @@ export const setupDockerContainerLogsWebSocketServer = (
|
||||
ws.send(data);
|
||||
});
|
||||
ws.on("close", () => {
|
||||
clearInterval(pingInterval);
|
||||
ptyProcess.kill();
|
||||
});
|
||||
ws.on("message", (message) => {
|
||||
|
||||
@@ -58,7 +58,12 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
||||
`docker exec -it -w / ${containerId} ${activeWay}`,
|
||||
{ pty: true },
|
||||
(err, stream) => {
|
||||
if (err) throw err;
|
||||
if (err) {
|
||||
console.error("SSH exec error:", err);
|
||||
ws.close();
|
||||
conn.end();
|
||||
return;
|
||||
}
|
||||
|
||||
stream
|
||||
.on("close", (code: number, _signal: string) => {
|
||||
@@ -93,10 +98,20 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
||||
|
||||
ws.on("close", () => {
|
||||
stream.end();
|
||||
// Ensure SSH connection is closed when WebSocket closes
|
||||
conn.end();
|
||||
});
|
||||
},
|
||||
);
|
||||
})
|
||||
.on("error", (err) => {
|
||||
console.error("SSH connection error:", err);
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(`SSH error: ${err.message}`);
|
||||
ws.close();
|
||||
}
|
||||
conn.end();
|
||||
})
|
||||
.connect({
|
||||
host: server.ipAddress,
|
||||
port: server.port,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type http from "node:http";
|
||||
import {
|
||||
docker,
|
||||
execAsync,
|
||||
getHostSystemStats,
|
||||
getLastAdvancedStatsFile,
|
||||
recordAdvancedStats,
|
||||
validateRequest,
|
||||
@@ -49,6 +50,21 @@ export const setupDockerStatsMonitoringSocketServer = (
|
||||
}
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
// Special case: when monitoring "dokploy", get host system stats instead of container stats
|
||||
if (appName === "dokploy") {
|
||||
const stat = await getHostSystemStats();
|
||||
|
||||
await recordAdvancedStats(stat, appName);
|
||||
const data = await getLastAdvancedStatsFile(appName);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
data,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
status: ["running"],
|
||||
...(appType === "application" && {
|
||||
|
||||
@@ -31,8 +31,11 @@ export const setupDeploymentLogsWebSocketServer = (
|
||||
const serverId = url.searchParams.get("serverId");
|
||||
const { user, session } = await validateRequest(req);
|
||||
|
||||
// Generate unique connection ID for tracking
|
||||
const connectionId = `deployment-logs-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
if (!logPath) {
|
||||
console.log("logPath no provided");
|
||||
console.log(`[${connectionId}] logPath no provided`);
|
||||
ws.close(4000, "logPath no provided");
|
||||
return;
|
||||
}
|
||||
@@ -42,40 +45,55 @@ export const setupDeploymentLogsWebSocketServer = (
|
||||
return;
|
||||
}
|
||||
|
||||
let tailProcess: ReturnType<typeof spawn> | null = null;
|
||||
let sshClient: Client | null = null;
|
||||
|
||||
try {
|
||||
if (serverId) {
|
||||
const server = await findServerById(serverId);
|
||||
|
||||
if (!server.sshKeyId) return;
|
||||
const client = new Client();
|
||||
client
|
||||
if (!server.sshKeyId) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
sshClient = new Client();
|
||||
sshClient
|
||||
.on("ready", () => {
|
||||
const command = `
|
||||
tail -n +1 -f ${logPath};
|
||||
`;
|
||||
client.exec(command, (err, stream) => {
|
||||
sshClient!.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
console.error("Execution error:", err);
|
||||
sshClient!.end();
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
stream
|
||||
.on("close", () => {
|
||||
client.end();
|
||||
sshClient!.end();
|
||||
ws.close();
|
||||
})
|
||||
.on("data", (data: string) => {
|
||||
ws.send(data.toString());
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(data.toString());
|
||||
}
|
||||
})
|
||||
.stderr.on("data", (data) => {
|
||||
ws.send(data.toString());
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(data.toString());
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.on("error", (err) => {
|
||||
console.error("SSH connection error:", err);
|
||||
ws.send(`SSH error: ${err.message}`);
|
||||
ws.close(); // Cierra el WebSocket si hay un error con SSH
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(`SSH error: ${err.message}`);
|
||||
ws.close();
|
||||
}
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
})
|
||||
.connect({
|
||||
host: server.ipAddress,
|
||||
@@ -85,26 +103,75 @@ export const setupDeploymentLogsWebSocketServer = (
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
client.end();
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const tail = spawn("tail", ["-n", "+1", "-f", logPath]);
|
||||
tailProcess = spawn("tail", ["-n", "+1", "-f", logPath]);
|
||||
|
||||
tail.stdout.on("data", (data) => {
|
||||
ws.send(data.toString());
|
||||
});
|
||||
const stdout = tailProcess.stdout;
|
||||
const stderr = tailProcess.stderr;
|
||||
|
||||
tail.stderr.on("data", (data) => {
|
||||
ws.send(new Error(`tail error: ${data.toString()}`).message);
|
||||
});
|
||||
tail.on("close", () => {
|
||||
if (stdout) {
|
||||
stdout.on("data", (data) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(data.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
stderr.on("data", (data) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(new Error(`tail error: ${data.toString()}`).message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tailProcess.on("close", () => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
tailProcess.on("error", () => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (tailProcess && !tailProcess.killed) {
|
||||
tailProcess.kill("SIGTERM");
|
||||
// Force kill after a timeout if it doesn't terminate
|
||||
setTimeout(() => {
|
||||
if (tailProcess && !tailProcess.killed) {
|
||||
tailProcess.kill("SIGKILL");
|
||||
} else {
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Clean up resources on error
|
||||
if (tailProcess && !tailProcess.killed) {
|
||||
tailProcess.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (tailProcess && !tailProcess.killed) {
|
||||
tailProcess.kill("SIGKILL");
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
// @ts-ignore
|
||||
const errorMessage = error?.message as unknown as string;
|
||||
ws.send(errorMessage || "An error occurred");
|
||||
ws.close();
|
||||
}
|
||||
} catch {
|
||||
// @ts-ignore
|
||||
// const errorMessage = error?.message as unknown as string;
|
||||
// ws.send(errorMessage);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
await initializeNetwork();
|
||||
createDefaultTraefikConfig();
|
||||
createDefaultServerTraefikConfig();
|
||||
await execAsync("docker pull traefik:v3.5.0");
|
||||
await execAsync("docker pull traefik:v3.6.1");
|
||||
await initializeStandaloneTraefik();
|
||||
await initializeRedis();
|
||||
await initializePostgres();
|
||||
|
||||
@@ -8,21 +8,22 @@
|
||||
"resolveJsonModule": true,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"checkJs": true,
|
||||
|
||||
/* Bundled projects */
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"jsx": "react-jsx",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"incremental": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
@@ -30,7 +31,6 @@
|
||||
"@dokploy/server/*": ["../../packages/server/src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
cleanUpDockerBuilder,
|
||||
cleanUpSystemPrune,
|
||||
cleanUpUnusedImages,
|
||||
cleanupAll,
|
||||
findBackupById,
|
||||
findScheduleById,
|
||||
findServerById,
|
||||
@@ -91,9 +89,7 @@ export const runJobs = async (job: QueueJob) => {
|
||||
logger.info("Server is inactive");
|
||||
return;
|
||||
}
|
||||
await cleanUpUnusedImages(serverId);
|
||||
await cleanUpDockerBuilder(serverId);
|
||||
await cleanUpSystemPrune(serverId);
|
||||
await cleanupAll(serverId);
|
||||
} else if (job.type === "schedule") {
|
||||
const { scheduleId } = job;
|
||||
const schedule = await findScheduleById(scheduleId);
|
||||
|
||||
+20004
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -20,7 +20,8 @@
|
||||
"build": "pnpm -r run build",
|
||||
"format-and-lint": "biome check .",
|
||||
"check": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true",
|
||||
"format-and-lint:fix": "biome check . --write"
|
||||
"format-and-lint:fix": "biome check . --write",
|
||||
"generate:openapi": "pnpm --filter=dokploy run generate:openapi"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.1",
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"node-os-utils": "1.3.7",
|
||||
"node-os-utils": "2.0.1",
|
||||
"node-pty": "1.0.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
@@ -75,6 +75,7 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"rotating-file-stream": "3.2.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.6",
|
||||
"ssh2": "1.15.0",
|
||||
"toml": "3.0.0",
|
||||
@@ -88,12 +89,12 @@
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^18.19.104",
|
||||
"@types/node-os-utils": "1.3.4",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
"@types/ws": "8.5.10",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
|
||||
@@ -111,6 +111,7 @@ export const applications = pgTable("application", {
|
||||
enabled: boolean("enabled"),
|
||||
subtitle: text("subtitle"),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
refreshToken: text("refreshToken").$defaultFn(() => nanoid()),
|
||||
sourceType: sourceType("sourceType").notNull().default("github"),
|
||||
cleanCache: boolean("cleanCache").default(false),
|
||||
@@ -186,6 +187,12 @@ export const applications = pgTable("application", {
|
||||
registryId: text("registryId").references(() => registry.registryId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
rollbackRegistryId: text("rollbackRegistryId").references(
|
||||
() => registry.registryId,
|
||||
{
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
environmentId: text("environmentId")
|
||||
.notNull()
|
||||
.references(() => environments.environmentId, { onDelete: "cascade" }),
|
||||
@@ -204,6 +211,15 @@ export const applications = pgTable("application", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
buildServerId: text("buildServerId").references(() => server.serverId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
buildRegistryId: text("buildRegistryId").references(
|
||||
() => registry.registryId,
|
||||
{
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export const applicationsRelations = relations(
|
||||
@@ -226,6 +242,7 @@ export const applicationsRelations = relations(
|
||||
registry: one(registry, {
|
||||
fields: [applications.registryId],
|
||||
references: [registry.registryId],
|
||||
relationName: "applicationRegistry",
|
||||
}),
|
||||
github: one(github, {
|
||||
fields: [applications.githubId],
|
||||
@@ -246,8 +263,24 @@ export const applicationsRelations = relations(
|
||||
server: one(server, {
|
||||
fields: [applications.serverId],
|
||||
references: [server.serverId],
|
||||
relationName: "applicationServer",
|
||||
}),
|
||||
buildServer: one(server, {
|
||||
fields: [applications.buildServerId],
|
||||
references: [server.serverId],
|
||||
relationName: "applicationBuildServer",
|
||||
}),
|
||||
buildRegistry: one(registry, {
|
||||
fields: [applications.buildRegistryId],
|
||||
references: [registry.registryId],
|
||||
relationName: "applicationBuildRegistry",
|
||||
}),
|
||||
previewDeployments: many(previewDeployments),
|
||||
rollbackRegistry: one(registry, {
|
||||
fields: [applications.rollbackRegistryId],
|
||||
references: [registry.registryId],
|
||||
relationName: "applicationRollbackRegistry",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -272,6 +305,7 @@ const createSchema = createInsertSchema(applications, {
|
||||
username: z.string().optional(),
|
||||
isPreviewDeploymentsActive: z.boolean().optional(),
|
||||
password: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
registryUrl: z.string().optional(),
|
||||
customGitSSHKeyId: z.string().optional(),
|
||||
repository: z.string().optional(),
|
||||
|
||||
@@ -70,6 +70,9 @@ export const deployments = pgTable("deployment", {
|
||||
(): AnyPgColumn => volumeBackups.volumeBackupId,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
buildServerId: text("buildServerId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||
@@ -84,6 +87,12 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||
server: one(server, {
|
||||
fields: [deployments.serverId],
|
||||
references: [server.serverId],
|
||||
relationName: "deploymentServer",
|
||||
}),
|
||||
buildServer: one(server, {
|
||||
fields: [deployments.buildServerId],
|
||||
references: [server.serverId],
|
||||
relationName: "deploymentBuildServer",
|
||||
}),
|
||||
previewDeployment: one(previewDeployments, {
|
||||
fields: [deployments.previewDeploymentId],
|
||||
@@ -115,6 +124,7 @@ const schema = createInsertSchema(deployments, {
|
||||
composeId: z.string(),
|
||||
description: z.string().optional(),
|
||||
previewDeploymentId: z.string(),
|
||||
buildServerId: z.string(),
|
||||
});
|
||||
|
||||
export const apiCreateDeployment = schema
|
||||
|
||||
@@ -45,6 +45,7 @@ export const mariadb = pgTable("mariadb", {
|
||||
databaseRootPassword: text("rootPassword").notNull(),
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
// RESOURCES
|
||||
memoryReservation: text("memoryReservation"),
|
||||
@@ -114,6 +115,7 @@ const createSchema = createInsertSchema(mariadb, {
|
||||
.optional(),
|
||||
dockerImage: z.string().default("mariadb:6"),
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.string().optional(),
|
||||
memoryReservation: z.string().optional(),
|
||||
memoryLimit: z.string().optional(),
|
||||
|
||||
@@ -50,6 +50,7 @@ export const mongo = pgTable("mongo", {
|
||||
databasePassword: text("databasePassword").notNull(),
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
@@ -110,6 +111,7 @@ const createSchema = createInsertSchema(mongo, {
|
||||
databaseUser: z.string().min(1),
|
||||
dockerImage: z.string().default("mongo:15"),
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.string().optional(),
|
||||
memoryReservation: z.string().optional(),
|
||||
memoryLimit: z.string().optional(),
|
||||
|
||||
@@ -45,6 +45,7 @@ export const mysql = pgTable("mysql", {
|
||||
databaseRootPassword: text("rootPassword").notNull(),
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
@@ -112,6 +113,7 @@ const createSchema = createInsertSchema(mysql, {
|
||||
.optional(),
|
||||
dockerImage: z.string().default("mysql:8"),
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.string().optional(),
|
||||
memoryReservation: z.string().optional(),
|
||||
memoryLimit: z.string().optional(),
|
||||
|
||||
@@ -24,6 +24,7 @@ export const notifications = pgTable("notification", {
|
||||
appDeploy: boolean("appDeploy").notNull().default(false),
|
||||
appBuildError: boolean("appBuildError").notNull().default(false),
|
||||
databaseBackup: boolean("databaseBackup").notNull().default(false),
|
||||
volumeBackup: boolean("volumeBackup").notNull().default(false),
|
||||
dokployRestart: boolean("dokployRestart").notNull().default(false),
|
||||
dockerCleanup: boolean("dockerCleanup").notNull().default(false),
|
||||
serverThreshold: boolean("serverThreshold").notNull().default(false),
|
||||
@@ -116,7 +117,7 @@ export const ntfy = pgTable("ntfy", {
|
||||
.$defaultFn(() => nanoid()),
|
||||
serverUrl: text("serverUrl").notNull(),
|
||||
topic: text("topic").notNull(),
|
||||
accessToken: text("accessToken").notNull(),
|
||||
accessToken: text("accessToken"),
|
||||
priority: integer("priority").notNull().default(3),
|
||||
});
|
||||
|
||||
@@ -169,6 +170,7 @@ export const apiCreateSlack = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -196,6 +198,7 @@ export const apiCreateTelegram = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -225,6 +228,7 @@ export const apiCreateDiscord = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -255,6 +259,7 @@ export const apiCreateEmail = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -290,6 +295,7 @@ export const apiCreateGotify = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -323,6 +329,7 @@ export const apiCreateNtfy = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -331,7 +338,7 @@ export const apiCreateNtfy = notificationsSchema
|
||||
.extend({
|
||||
serverUrl: z.string().min(1),
|
||||
topic: z.string().min(1),
|
||||
accessToken: z.string().min(1),
|
||||
accessToken: z.string().optional(),
|
||||
priority: z.number().min(1),
|
||||
})
|
||||
.required();
|
||||
@@ -395,7 +402,7 @@ export const apiSendTest = notificationsSchema
|
||||
serverUrl: z.string(),
|
||||
topic: z.string(),
|
||||
appToken: z.string(),
|
||||
accessToken: z.string(),
|
||||
accessToken: z.string().optional(),
|
||||
priority: z.number(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -44,6 +44,7 @@ export const postgres = pgTable("postgres", {
|
||||
description: text("description"),
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
externalPort: integer("externalPort"),
|
||||
@@ -103,6 +104,7 @@ const createSchema = createInsertSchema(postgres, {
|
||||
databaseUser: z.string().min(1),
|
||||
dockerImage: z.string().default("postgres:15"),
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.string().optional(),
|
||||
memoryReservation: z.string().optional(),
|
||||
memoryLimit: z.string().optional(),
|
||||
|
||||
@@ -41,6 +41,7 @@ export const redis = pgTable("redis", {
|
||||
databasePassword: text("password").notNull(),
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
@@ -93,6 +94,7 @@ const createSchema = createInsertSchema(redis, {
|
||||
databasePassword: z.string(),
|
||||
dockerImage: z.string().default("redis:8"),
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.string().optional(),
|
||||
memoryReservation: z.string().optional(),
|
||||
memoryLimit: z.string().optional(),
|
||||
|
||||
@@ -33,7 +33,15 @@ export const registry = pgTable("registry", {
|
||||
});
|
||||
|
||||
export const registryRelations = relations(registry, ({ many }) => ({
|
||||
applications: many(applications),
|
||||
applications: many(applications, {
|
||||
relationName: "applicationRegistry",
|
||||
}),
|
||||
buildApplications: many(applications, {
|
||||
relationName: "applicationBuildRegistry",
|
||||
}),
|
||||
rollbackApplications: many(applications, {
|
||||
relationName: "applicationRollbackRegistry",
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(registry, {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { schedules } from "./schedule";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
import { generateAppName } from "./utils";
|
||||
export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]);
|
||||
export const serverType = pgEnum("serverType", ["deploy", "build"]);
|
||||
|
||||
export const server = pgTable("server", {
|
||||
serverId: text("serverId")
|
||||
@@ -44,6 +45,7 @@ export const server = pgTable("server", {
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
serverStatus: serverStatus("serverStatus").notNull().default("active"),
|
||||
serverType: serverType("serverType").notNull().default("deploy"),
|
||||
command: text("command").notNull().default(""),
|
||||
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
|
||||
onDelete: "set null",
|
||||
@@ -97,12 +99,22 @@ export const server = pgTable("server", {
|
||||
});
|
||||
|
||||
export const serverRelations = relations(server, ({ one, many }) => ({
|
||||
deployments: many(deployments),
|
||||
deployments: many(deployments, {
|
||||
relationName: "deploymentServer",
|
||||
}),
|
||||
buildDeployments: many(deployments, {
|
||||
relationName: "deploymentBuildServer",
|
||||
}),
|
||||
sshKey: one(sshKeys, {
|
||||
fields: [server.sshKeyId],
|
||||
references: [sshKeys.sshKeyId],
|
||||
}),
|
||||
applications: many(applications),
|
||||
applications: many(applications, {
|
||||
relationName: "applicationServer",
|
||||
}),
|
||||
buildApplications: many(applications, {
|
||||
relationName: "applicationBuildServer",
|
||||
}),
|
||||
compose: many(compose),
|
||||
redis: many(redis),
|
||||
mariadb: many(mariadb),
|
||||
@@ -131,6 +143,7 @@ export const apiCreateServer = createSchema
|
||||
port: true,
|
||||
username: true,
|
||||
sshKeyId: true,
|
||||
serverType: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
@@ -155,6 +168,7 @@ export const apiUpdateServer = createSchema
|
||||
port: true,
|
||||
username: true,
|
||||
sshKeyId: true,
|
||||
serverType: true,
|
||||
})
|
||||
.required()
|
||||
.extend({
|
||||
|
||||
@@ -56,7 +56,7 @@ export const user = pgTable("user", {
|
||||
host: text("host"),
|
||||
letsEncryptEmail: text("letsEncryptEmail"),
|
||||
sshPrivateKey: text("sshPrivateKey"),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
|
||||
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
|
||||
role: text("role").notNull().default("user"),
|
||||
// Metrics
|
||||
|
||||
@@ -2,7 +2,13 @@ import { z } from "zod";
|
||||
|
||||
export const domain = z
|
||||
.object({
|
||||
host: z.string().min(1, { message: "Add a hostname" }),
|
||||
host: z
|
||||
.string()
|
||||
.min(1, { message: "Add a hostname" })
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
@@ -58,7 +64,13 @@ export const domain = z
|
||||
|
||||
export const domainCompose = z
|
||||
.object({
|
||||
host: z.string().min(1, { message: "Host is required" }),
|
||||
host: z
|
||||
.string()
|
||||
.min(1, { message: "Add a hostname" })
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user