mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-18 21:55:24 +02:00
Compare commits
1 Commits
v0.26.0
...
feat/add-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b18faa717 |
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
@@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about.
|
||||
|
||||
Before submitting this PR, please make sure that:
|
||||
|
||||
- [ ] You created a dedicated branch based on the `canary` branch.
|
||||
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||
- [ ] You have tested this PR in your local instance.
|
||||
- [] You created a dedicated branch based on the `canary` branch.
|
||||
- [] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||
- [] You have tested this PR in your local instance.
|
||||
|
||||
## Issues related (if applicable)
|
||||
|
||||
|
||||
26
.github/workflows/pull-request.yml
vendored
26
.github/workflows/pull-request.yml
vendored
@@ -20,32 +20,6 @@ jobs:
|
||||
with:
|
||||
node-version: 20.16.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Nixpacks
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export NIXPACKS_VERSION=1.39.0
|
||||
curl -sSL https://nixpacks.com/install.sh | bash
|
||||
echo "Nixpacks installed $NIXPACKS_VERSION"
|
||||
|
||||
- name: Install Railpack
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export RAILPACK_VERSION=0.15.0
|
||||
curl -sSL https://railpack.com/install.sh | bash
|
||||
echo "Railpack installed $RAILPACK_VERSION"
|
||||
|
||||
- name: Add build tools to PATH
|
||||
if: matrix.job == 'test'
|
||||
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Initialize Docker Swarm
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
docker swarm init
|
||||
docker network create --driver overlay dokploy-network || true
|
||||
echo "✅ Docker Swarm initialized"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm server:build
|
||||
- run: pnpm ${{ matrix.job }}
|
||||
|
||||
70
.github/workflows/sync-openapi-docs.yml
vendored
70
.github/workflows/sync-openapi-docs.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: Generate and Sync OpenAPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
- main
|
||||
paths:
|
||||
- 'apps/dokploy/server/api/routers/**'
|
||||
- 'packages/server/src/services/**'
|
||||
- 'packages/server/src/db/schema/**'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
generate-and-commit:
|
||||
name: Generate OpenAPI and commit to Dokploy repo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Dokploy repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.16.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate OpenAPI specification
|
||||
run: |
|
||||
pnpm generate:openapi
|
||||
|
||||
# Verifica que se generó correctamente
|
||||
if [ ! -f openapi.json ]; then
|
||||
echo "❌ openapi.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ OpenAPI specification generated successfully"
|
||||
|
||||
- name: Sync to website repository
|
||||
run: |
|
||||
# Clona el repositorio de website
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
|
||||
|
||||
cd website-repo
|
||||
|
||||
# Copia el openapi.json al website (sobrescribe)
|
||||
mkdir -p apps/docs/public
|
||||
cp -f ../openapi.json apps/docs/public/openapi.json
|
||||
|
||||
# Configura git
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
# Agrega y commitea siempre
|
||||
git add apps/docs/public/openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to website successfully"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,8 +13,6 @@ node_modules
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
openapi.json
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
|
||||
@@ -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 --version 28.5.2 && 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 && rm get-docker.sh && curl https://rclone.org/install.sh | bash
|
||||
|
||||
# Install Nixpacks and tsx
|
||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||
|
||||
@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
|
||||
|
||||
|
||||
# Deploy only the dokploy app
|
||||
# ARG NEXT_PUBLIC_UMAMI_HOST
|
||||
# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||
ARG NEXT_PUBLIC_UMAMI_HOST
|
||||
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||
|
||||
# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
|
||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
NODE_ENV=development
|
||||
@@ -1,215 +0,0 @@
|
||||
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`[^`]+`\)/);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,479 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ApplicationNested } from "@dokploy/server";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { execAsync } from "@dokploy/server/utils/process/execAsync";
|
||||
import { format } from "date-fns";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
|
||||
|
||||
// Mock ONLY database and notifications
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const createChainableMock = (): any => {
|
||||
const chain: any = {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}]),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
query: {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/application", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/services/application")
|
||||
>("@dokploy/server/services/application");
|
||||
return {
|
||||
...actual,
|
||||
findApplicationById: vi.fn(),
|
||||
updateApplicationStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/admin", () => ({
|
||||
getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||
createDeployment: vi.fn(),
|
||||
updateDeploymentStatus: vi.fn(),
|
||||
updateDeployment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
|
||||
sendBuildSuccessNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
|
||||
sendBuildErrorNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||
createRollback: vi.fn(),
|
||||
}));
|
||||
|
||||
// NOT mocked (executed for real):
|
||||
// - execAsync
|
||||
// - cloneGitRepository
|
||||
// - getBuildCommand
|
||||
// - mechanizeDockerContainer (requires Docker Swarm)
|
||||
|
||||
import { db } from "@dokploy/server/db";
|
||||
import * as adminService from "@dokploy/server/services/admin";
|
||||
import * as applicationService from "@dokploy/server/services/application";
|
||||
import { deployApplication } from "@dokploy/server/services/application";
|
||||
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||
|
||||
const createMockApplication = (
|
||||
overrides: Partial<ApplicationNested> = {},
|
||||
): ApplicationNested =>
|
||||
({
|
||||
applicationId: "test-app-id",
|
||||
name: "Real Test App",
|
||||
appName: `real-test-${Date.now()}`,
|
||||
sourceType: "git" as const,
|
||||
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||
customGitBranch: "main",
|
||||
customGitSSHKeyId: null,
|
||||
customGitBuildPath: "/astro",
|
||||
buildType: "nixpacks" as const,
|
||||
env: "NODE_ENV=production",
|
||||
serverId: null,
|
||||
rollbackActive: false,
|
||||
enableSubmodules: false,
|
||||
environmentId: "env-id",
|
||||
environment: {
|
||||
projectId: "project-id",
|
||||
env: "",
|
||||
name: "production",
|
||||
project: {
|
||||
name: "Test Project",
|
||||
organizationId: "org-id",
|
||||
env: "",
|
||||
},
|
||||
},
|
||||
domains: [],
|
||||
mounts: [],
|
||||
security: [],
|
||||
redirects: [],
|
||||
ports: [],
|
||||
registry: null,
|
||||
...overrides,
|
||||
}) as ApplicationNested;
|
||||
|
||||
const createMockDeployment = async (appName: string) => {
|
||||
const { LOGS_PATH } = paths(false); // false = local, no remote server
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${appName}-${formattedDateTime}.log`;
|
||||
const logFilePath = path.join(LOGS_PATH, appName, fileName);
|
||||
|
||||
// Actually create the log directory
|
||||
await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
|
||||
await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
|
||||
|
||||
return {
|
||||
deploymentId: "deployment-id",
|
||||
logPath: logFilePath,
|
||||
};
|
||||
};
|
||||
|
||||
async function cleanupDocker(appName: string) {
|
||||
try {
|
||||
await execAsync(`docker stop ${appName} 2>/dev/null || true`);
|
||||
await execAsync(`docker rm ${appName} 2>/dev/null || true`);
|
||||
await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
|
||||
} catch (error) {
|
||||
console.log("Docker cleanup completed");
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupFiles(appName: string) {
|
||||
try {
|
||||
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||
|
||||
// Clean cloned code directories
|
||||
const appPath = path.join(APPLICATIONS_PATH, appName);
|
||||
await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
|
||||
|
||||
// Clean logs for appName - removes entire folder
|
||||
const logPath = path.join(LOGS_PATH, appName);
|
||||
await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
|
||||
|
||||
console.log(`✅ Cleaned up files and logs for ${appName}`);
|
||||
} catch (error) {
|
||||
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
describe(
|
||||
"deployApplication - REAL Execution Tests",
|
||||
() => {
|
||||
let currentAppName: string;
|
||||
let currentDeployment: any;
|
||||
const allTestAppNames: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
currentAppName = `real-test-${Date.now()}`;
|
||||
currentDeployment = await createMockDeployment(currentAppName);
|
||||
allTestAppNames.push(currentAppName);
|
||||
|
||||
const mockApp = createMockApplication({ appName: currentAppName });
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
|
||||
"http://localhost:3000",
|
||||
);
|
||||
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
|
||||
currentDeployment as any,
|
||||
);
|
||||
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
|
||||
{} as any,
|
||||
);
|
||||
vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
|
||||
{} as any,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// ALWAYS cleanup, even if test failed or passed
|
||||
console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
|
||||
|
||||
// Clean current appName
|
||||
try {
|
||||
await cleanupDocker(currentAppName);
|
||||
await cleanupFiles(currentAppName);
|
||||
} catch (error) {
|
||||
console.error("⚠️ Error cleaning current app:", error);
|
||||
}
|
||||
|
||||
// Clean ALL test folders just in case
|
||||
try {
|
||||
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||
await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
|
||||
await execAsync(
|
||||
`rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
|
||||
);
|
||||
console.log("✅ Cleaned up all test artifacts");
|
||||
} catch (error) {
|
||||
console.error("⚠️ Error cleaning all artifacts:", error);
|
||||
}
|
||||
|
||||
console.log("✅ Cleanup completed\n");
|
||||
});
|
||||
|
||||
it(
|
||||
"should REALLY clone git repo and build with nixpacks",
|
||||
async () => {
|
||||
console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Nixpacks Test",
|
||||
descriptionLog: "Testing real execution",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify that Docker image was actually created
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
console.log("dockerImages", dockerImages);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||
|
||||
// Verify log exists and has content
|
||||
expect(existsSync(currentDeployment.logPath)).toBe(true);
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Cloning");
|
||||
expect(logContent).toContain("nixpacks");
|
||||
console.log(`✅ Build log created with ${logContent.length} chars`);
|
||||
|
||||
// Verify update functions were called
|
||||
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||
"deployment-id",
|
||||
"done",
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it.skip(
|
||||
"should REALLY build with railpack (SKIPPED: requires special permissions)",
|
||||
async () => {
|
||||
const railpackAppName = `real-railpack-${Date.now()}`;
|
||||
const railpackApp = createMockApplication({
|
||||
appName: railpackAppName,
|
||||
buildType: "railpack",
|
||||
railpackVersion: "3",
|
||||
});
|
||||
currentAppName = railpackAppName;
|
||||
allTestAppNames.push(railpackAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
railpackApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
railpackApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Railpack Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Railpack image created: ${currentAppName}`);
|
||||
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("railpack");
|
||||
console.log("✅ Railpack build completed");
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle REAL git clone errors",
|
||||
async () => {
|
||||
const errorAppName = `real-error-${Date.now()}`;
|
||||
const errorApp = createMockApplication({
|
||||
appName: errorAppName,
|
||||
customGitUrl:
|
||||
"https://github.com/invalid/nonexistent-repo-123456.git",
|
||||
});
|
||||
currentAppName = errorAppName;
|
||||
allTestAppNames.push(errorAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
errorApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
errorApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
|
||||
|
||||
await expect(
|
||||
deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Error Test",
|
||||
descriptionLog: "",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Verify error status was called
|
||||
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||
"deployment-id",
|
||||
"error",
|
||||
);
|
||||
|
||||
// Verify log contains error
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent.toLowerCase()).toContain("error");
|
||||
console.log("✅ Error handling verified");
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should REALLY clone with submodules when enabled",
|
||||
async () => {
|
||||
const submodulesAppName = `real-submodules-${Date.now()}`;
|
||||
const submodulesApp = createMockApplication({
|
||||
appName: submodulesAppName,
|
||||
enableSubmodules: true,
|
||||
});
|
||||
currentAppName = submodulesAppName;
|
||||
allTestAppNames.push(submodulesAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
submodulesApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
submodulesApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Submodules Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify deployment completed successfully
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Cloning");
|
||||
expect(logContent.length).toBeGreaterThan(100);
|
||||
console.log("✅ Submodules deployment completed");
|
||||
|
||||
// Verify image
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should verify REAL commit info extraction",
|
||||
async () => {
|
||||
console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Commit Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
// Verify updateDeployment was called with commit info
|
||||
expect(deploymentService.updateDeployment).toHaveBeenCalled();
|
||||
const updateCall = vi.mocked(deploymentService.updateDeployment).mock
|
||||
.calls[0];
|
||||
|
||||
// Real commit info should have title and hash
|
||||
expect(updateCall?.[1]).toHaveProperty("title");
|
||||
expect(updateCall?.[1]).toHaveProperty("description");
|
||||
expect(updateCall?.[1]?.description).toContain("Commit:");
|
||||
|
||||
console.log(
|
||||
`✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should REALLY build with Dockerfile",
|
||||
async () => {
|
||||
const dockerfileAppName = `real-dockerfile-${Date.now()}`;
|
||||
const dockerfileApp = createMockApplication({
|
||||
appName: dockerfileAppName,
|
||||
buildType: "dockerfile",
|
||||
customGitBuildPath: "/deno",
|
||||
dockerfile: "Dockerfile",
|
||||
});
|
||||
currentAppName = dockerfileAppName;
|
||||
allTestAppNames.push(dockerfileAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
dockerfileApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
dockerfileApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Dockerfile Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify log
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Building");
|
||||
expect(logContent).toContain(dockerfileAppName);
|
||||
console.log("✅ Dockerfile build log verified");
|
||||
|
||||
// Verify image
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
console.log("dockerImages", dockerImages);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
@@ -30,10 +30,6 @@ const baseApp: ApplicationNested = {
|
||||
previewLabels: [],
|
||||
herokuVersion: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
args: [],
|
||||
giteaBuildPath: "",
|
||||
previewRequireCollaboratorPermissions: false,
|
||||
giteaId: "",
|
||||
@@ -41,9 +37,6 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
cleanCache: false,
|
||||
watchPaths: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
enableSubmodules: false,
|
||||
applicationStatus: "done",
|
||||
triggerType: "push",
|
||||
|
||||
311
apps/dokploy/__test__/env/environment.test.ts
vendored
311
apps/dokploy/__test__/env/environment.test.ts
vendored
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForShell,
|
||||
} from "@dokploy/server/index";
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
@@ -335,310 +332,4 @@ 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é");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||
|
||||
type MockCreateServiceOptions = {
|
||||
TaskTemplate?: {
|
||||
ContainerSpec?: {
|
||||
StopGracePeriod?: number;
|
||||
};
|
||||
};
|
||||
StopGracePeriod?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -85,10 +82,8 @@ describe("mechanizeDockerContainer", () => {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
|
||||
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
|
||||
"number",
|
||||
);
|
||||
expect(settings.StopGracePeriod).toBe(0);
|
||||
expect(typeof settings.StopGracePeriod).toBe("number");
|
||||
});
|
||||
|
||||
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
||||
@@ -102,8 +97,6 @@ describe("mechanizeDockerContainer", () => {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
|
||||
"StopGracePeriod",
|
||||
);
|
||||
expect(settings).not.toHaveProperty("StopGracePeriod");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,6 @@ const baseAdmin: User = {
|
||||
enablePaidFeatures: false,
|
||||
allowImpersonation: false,
|
||||
role: "user",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
metricsConfig: {
|
||||
containers: {
|
||||
refreshRate: 20,
|
||||
@@ -63,6 +61,7 @@ const baseAdmin: User = {
|
||||
expirationDate: "",
|
||||
id: "",
|
||||
isRegistered: false,
|
||||
name: "",
|
||||
createdAt2: new Date().toISOString(),
|
||||
emailVerified: false,
|
||||
image: "",
|
||||
|
||||
@@ -11,15 +11,8 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
giteaOwner: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
giteaBuildPath: "",
|
||||
giteaId: "",
|
||||
args: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
cleanCache: false,
|
||||
applicationStatus: "done",
|
||||
endpointSpecSwarm: null,
|
||||
|
||||
@@ -13,11 +13,7 @@ export default defineConfig({
|
||||
NODE: "test",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: [path.resolve(__dirname, "../tsconfig.json")],
|
||||
}),
|
||||
],
|
||||
plugins: [tsconfigPaths()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@dokploy/server": path.resolve(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,13 +28,6 @@ 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>;
|
||||
@@ -55,30 +47,22 @@ 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) {
|
||||
if (data?.command) {
|
||||
form.reset({
|
||||
command: data?.command || "",
|
||||
args: data?.args?.map((arg) => ({ value: arg })) || [],
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
||||
|
||||
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");
|
||||
@@ -116,65 +100,13 @@ export const AddCommand = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/bin/sh" {...field} />
|
||||
<Input placeholder="Custom command" {...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">
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
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,12 +1,4 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
RocketIcon,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { 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";
|
||||
@@ -25,7 +17,6 @@ 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";
|
||||
|
||||
@@ -89,23 +80,6 @@ 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(() => {
|
||||
@@ -143,10 +117,7 @@ export const ShowDeployments = ({
|
||||
See the last 10 deployments for this {type}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-row items-center flex-wrap gap-2">
|
||||
{(type === "application" || type === "compose") && (
|
||||
<KillBuild id={id} type={type} />
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{(type === "application" || type === "compose") && (
|
||||
<CancelQueues id={id} type={type} />
|
||||
)}
|
||||
@@ -246,180 +217,122 @@ export const ShowDeployments = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{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"
|
||||
/>
|
||||
{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}
|
||||
</span>
|
||||
|
||||
<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"
|
||||
}
|
||||
>
|
||||
{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">
|
||||
<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-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>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{deployment.pid && deployment.status === "running" && (
|
||||
<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}
|
||||
>
|
||||
Kill Process
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
|
||||
{deployment?.rollback &&
|
||||
deployment.status === "done" &&
|
||||
type === "application" && (
|
||||
<DialogAction
|
||||
title="Kill Process"
|
||||
description="Are you sure you want to kill the process?"
|
||||
title="Rollback to this deployment"
|
||||
description="Are you sure you want to rollback to this deployment?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await killProcess({
|
||||
deploymentId: deployment.deploymentId,
|
||||
await rollback({
|
||||
rollbackId: deployment.rollback.rollbackId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Process killed successfully");
|
||||
toast.success(
|
||||
"Rollback initiated successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error killing process");
|
||||
toast.error("Error initiating rollback");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isLoading={isKillingProcess}
|
||||
isLoading={isRollingBack}
|
||||
>
|
||||
Kill Process
|
||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||
Rollback
|
||||
</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={activeLog?.buildServerId || serverId}
|
||||
serverId={serverId}
|
||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog?.logPath || ""}
|
||||
|
||||
@@ -46,13 +46,7 @@ export type CacheType = "fetch" | "cache";
|
||||
|
||||
export const domain = z
|
||||
.object({
|
||||
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()),
|
||||
host: z.string().min(1, { message: "Add a hostname" }),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
@@ -305,13 +299,6 @@ 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"
|
||||
|
||||
@@ -182,16 +182,7 @@ 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}`}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -21,37 +20,13 @@ 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(),
|
||||
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",
|
||||
});
|
||||
}
|
||||
});
|
||||
const formSchema = z.object({
|
||||
rollbackActive: z.boolean(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -74,33 +49,17 @@ 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");
|
||||
@@ -153,65 +112,6 @@ 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>
|
||||
|
||||
@@ -60,30 +60,6 @@ export const commonCronExpressions = [
|
||||
{ label: "Custom", value: "custom" },
|
||||
];
|
||||
|
||||
export const commonTimezones = [
|
||||
{ label: "UTC (Coordinated Universal Time)", value: "UTC" },
|
||||
{ label: "America/New_York (Eastern Time)", value: "America/New_York" },
|
||||
{ label: "America/Chicago (Central Time)", value: "America/Chicago" },
|
||||
{ label: "America/Denver (Mountain Time)", value: "America/Denver" },
|
||||
{ label: "America/Los_Angeles (Pacific Time)", value: "America/Los_Angeles" },
|
||||
{
|
||||
label: "America/Mexico_City (Central Mexico)",
|
||||
value: "America/Mexico_City",
|
||||
},
|
||||
{ label: "America/Sao_Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
|
||||
{ label: "Europe/London (Greenwich Mean Time)", value: "Europe/London" },
|
||||
{ label: "Europe/Paris (Central European Time)", value: "Europe/Paris" },
|
||||
{ label: "Europe/Berlin (Central European Time)", value: "Europe/Berlin" },
|
||||
{ label: "Asia/Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
|
||||
{ label: "Asia/Shanghai (China Standard Time)", value: "Asia/Shanghai" },
|
||||
{ label: "Asia/Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
|
||||
{ label: "Asia/Kolkata (India Standard Time)", value: "Asia/Kolkata" },
|
||||
{
|
||||
label: "Australia/Sydney (Australian Eastern Time)",
|
||||
value: "Australia/Sydney",
|
||||
},
|
||||
];
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
@@ -99,7 +75,6 @@ const formSchema = z
|
||||
"dokploy-server",
|
||||
]),
|
||||
script: z.string(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.scheduleType === "compose" && !data.serviceName) {
|
||||
@@ -238,7 +213,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
serviceName: "",
|
||||
scheduleType: scheduleType || "application",
|
||||
script: "",
|
||||
timezone: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -277,7 +251,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
serviceName: schedule.serviceName || "",
|
||||
scheduleType: schedule.scheduleType,
|
||||
script: schedule.script || "",
|
||||
timezone: schedule.timezone || undefined,
|
||||
});
|
||||
}
|
||||
}, [form, schedule, scheduleId]);
|
||||
@@ -491,54 +464,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
formControl={form.control}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timezone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Timezone
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Select a timezone for the schedule. If not
|
||||
specified, UTC will be used.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="UTC (default)" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{commonTimezones.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Optional: Choose a timezone for the schedule execution time
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(scheduleTypeForm === "application" ||
|
||||
scheduleTypeForm === "compose") && (
|
||||
<>
|
||||
|
||||
@@ -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 _ from "lodash";
|
||||
import { debounce } 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 _ from "lodash";
|
||||
import { escapeRegExp } from "lodash";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -47,7 +47,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
}
|
||||
|
||||
const htmlContent = fancyAnsi.toHtml(text);
|
||||
const searchRegex = new RegExp(`(${_.escapeRegExp(term)})`, "gi");
|
||||
const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
|
||||
|
||||
const modifiedContent = htmlContent.replace(
|
||||
searchRegex,
|
||||
|
||||
@@ -49,7 +49,7 @@ export function parseLogs(logString: string): LogLine[] {
|
||||
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
||||
// message: "The server is running on port 8080" }
|
||||
const logRegex =
|
||||
/^(?:(?<lineNumber>\d+)\s+)?(?<timestamp>(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC))?\s*(?<message>[\s\S]*)$/;
|
||||
/^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/;
|
||||
|
||||
return logString
|
||||
.split("\n")
|
||||
@@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] {
|
||||
const match = line.match(logRegex);
|
||||
if (!match) return null;
|
||||
|
||||
const { timestamp, message } = match.groups ?? {};
|
||||
const [, , timestamp, message] = match;
|
||||
|
||||
if (!message?.trim()) return null;
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ export const ImpersonationBar = () => {
|
||||
setOpen(false);
|
||||
|
||||
toast.success("Successfully impersonating user", {
|
||||
description: `You are now viewing as ${`${selectedUser.name} ${selectedUser.lastName}`.trim() || selectedUser.email}`,
|
||||
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
@@ -195,8 +195,7 @@ export const ImpersonationBar = () => {
|
||||
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate flex flex-col items-start">
|
||||
<span className="text-sm font-medium">
|
||||
{`${selectedUser.name} ${selectedUser.lastName}`.trim() ||
|
||||
""}
|
||||
{selectedUser.name || ""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedUser.email}
|
||||
@@ -243,8 +242,7 @@ export const ImpersonationBar = () => {
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">
|
||||
{`${user.name} ${user.lastName}`.trim() ||
|
||||
""}
|
||||
{user.name || ""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email} • {user.role}
|
||||
@@ -285,14 +283,10 @@ export const ImpersonationBar = () => {
|
||||
<AvatarImage
|
||||
className="object-cover"
|
||||
src={data?.user?.image || ""}
|
||||
alt={
|
||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||
""
|
||||
}
|
||||
alt={data?.user?.name || ""}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() ||
|
||||
"U"}
|
||||
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -305,8 +299,7 @@ export const ImpersonationBar = () => {
|
||||
Impersonating
|
||||
</Badge>
|
||||
<span className="font-medium">
|
||||
{`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||
""}
|
||||
{data?.user?.name || ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DockerNetworkChart } from "./docker-network-chart";
|
||||
|
||||
const defaultData = {
|
||||
cpu: {
|
||||
value: "0%",
|
||||
value: 0,
|
||||
time: "",
|
||||
},
|
||||
memory: {
|
||||
@@ -46,7 +46,7 @@ interface Props {
|
||||
}
|
||||
export interface DockerStats {
|
||||
cpu: {
|
||||
value: string;
|
||||
value: number;
|
||||
time: string;
|
||||
};
|
||||
memory: {
|
||||
@@ -220,13 +220,7 @@ export const ContainerFreeMonitoring = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Used: {currentData.cpu.value}
|
||||
</span>
|
||||
<Progress
|
||||
value={Number.parseInt(
|
||||
currentData.cpu.value.replace("%", ""),
|
||||
10,
|
||||
)}
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<Progress value={currentData.cpu.value} className="w-[100%]" />
|
||||
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -21,13 +20,6 @@ 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 {
|
||||
@@ -69,25 +61,18 @@ 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]);
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: AddDockerImage) => {
|
||||
await mutateAsync({
|
||||
@@ -98,7 +83,6 @@ 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");
|
||||
@@ -144,68 +128,13 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/bin/sh" {...field} />
|
||||
<Input placeholder="Custom command" {...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 || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
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 || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
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 || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { findEnvironmentsByProjectId } from "@dokploy/server";
|
||||
import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Terminal,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -239,6 +246,20 @@ export const AdvancedEnvironmentSelector = ({
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Action buttons for non-production environments */}
|
||||
{/* <EnvironmentVariables environmentId={environment.environmentId}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
</Button>
|
||||
</EnvironmentVariables> */}
|
||||
{environment.name !== "production" && (
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<Button
|
||||
|
||||
@@ -10,12 +10,10 @@ import {
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -46,6 +44,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -53,25 +52,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TimeBadge } from "@/components/ui/time-badge";
|
||||
import { api } from "@/utils/api";
|
||||
import { useDebounce } from "@/utils/hooks/use-debounce";
|
||||
import { HandleProject } from "./handle-project";
|
||||
import { ProjectEnvironment } from "./project-environment";
|
||||
|
||||
export const ShowProjects = () => {
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isLoading } = api.project.all.useQuery();
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { mutateAsync } = api.project.remove.useMutation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(
|
||||
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
|
||||
);
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 500);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortBy, setSortBy] = useState<string>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("projectsSort") || "createdAt-desc";
|
||||
@@ -83,41 +73,14 @@ export const ShowProjects = () => {
|
||||
localStorage.setItem("projectsSort", sortBy);
|
||||
}, [sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
||||
if (urlQuery !== searchQuery) {
|
||||
setSearchQuery(urlQuery);
|
||||
}
|
||||
}, [router.isReady, router.query.q]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
||||
if (debouncedSearchQuery === urlQuery) return;
|
||||
|
||||
const newQuery = { ...router.query };
|
||||
if (debouncedSearchQuery) {
|
||||
newQuery.q = debouncedSearchQuery;
|
||||
} else {
|
||||
delete newQuery.q;
|
||||
}
|
||||
router.replace({ pathname: router.pathname, query: newQuery }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}, [debouncedSearchQuery]);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
// First filter by search query
|
||||
const filtered = data.filter(
|
||||
(project) =>
|
||||
project.name
|
||||
.toLowerCase()
|
||||
.includes(debouncedSearchQuery.toLowerCase()) ||
|
||||
project.description
|
||||
?.toLowerCase()
|
||||
.includes(debouncedSearchQuery.toLowerCase()),
|
||||
project.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
project.description?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
// Then sort the filtered results
|
||||
@@ -165,18 +128,13 @@ export const ShowProjects = () => {
|
||||
}
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}, [data, debouncedSearchQuery, sortBy]);
|
||||
}, [data, searchQuery, sortBy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbSidebar
|
||||
list={[{ name: "Projects", href: "/dashboard/projects" }]}
|
||||
/>
|
||||
{!isCloud && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<TimeBadge />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
@@ -190,6 +148,7 @@ export const ShowProjects = () => {
|
||||
Create and manage your projects
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{(auth?.role === "owner" || auth?.canCreateProjects) && (
|
||||
<div className="">
|
||||
<HandleProject />
|
||||
@@ -339,13 +298,7 @@ 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}
|
||||
@@ -387,13 +340,7 @@ 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,65 +49,51 @@ export const RequestDistributionChart = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,38 +51,13 @@ 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;
|
||||
}>(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;
|
||||
}>({
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (logCleanupStatus) {
|
||||
@@ -104,18 +79,16 @@ export const ShowRequests = () => {
|
||||
See all the incoming requests that pass trough Traefik
|
||||
</CardDescription>
|
||||
|
||||
{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>
|
||||
)}
|
||||
<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">
|
||||
@@ -196,13 +169,17 @@ export const ShowRequests = () => {
|
||||
{isActive ? (
|
||||
<>
|
||||
<div className="flex justify-end mb-4 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDateRange(getDefaultDateRange())}
|
||||
className="px-3"
|
||||
>
|
||||
Reset to Last 3 Days
|
||||
</Button>
|
||||
{(dateRange.from || dateRange.to) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDateRange({ from: undefined, to: undefined })
|
||||
}
|
||||
className="px-3"
|
||||
>
|
||||
Clear dates
|
||||
</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&scopes=${encodeURIComponent(scope)}`;
|
||||
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
|
||||
return authUrl;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { 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,
|
||||
@@ -33,12 +26,13 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -59,8 +53,6 @@ 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 || "",
|
||||
@@ -85,17 +77,13 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
form.reset({
|
||||
name: data?.name ?? "",
|
||||
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
|
||||
apiKey: data?.apiKey ?? "",
|
||||
model: data?.model ?? "",
|
||||
isEnabled: data?.isEnabled ?? true,
|
||||
});
|
||||
}, [aiId, form, data]);
|
||||
|
||||
const apiUrl = form.watch("apiUrl");
|
||||
@@ -116,6 +104,14 @@ 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({
|
||||
@@ -135,16 +131,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setModelSearch("");
|
||||
setModelPopoverOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger className="" asChild>
|
||||
{aiId ? (
|
||||
<Button
|
||||
@@ -195,17 +182,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>API URL</FormLabel>
|
||||
<FormControl>
|
||||
<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", "");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Input placeholder="https://api.openai.com/v1" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The base URL for your AI provider's API
|
||||
@@ -228,13 +205,6 @@ 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>
|
||||
@@ -262,89 +232,30 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
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>
|
||||
);
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Mail,
|
||||
PenBoxIcon,
|
||||
PlusIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -50,7 +44,6 @@ 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),
|
||||
@@ -110,25 +103,10 @@ 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().optional(),
|
||||
accessToken: z.string().min(1, { message: "Access Token is required" }),
|
||||
priority: z.number().min(1).max(5).default(3),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("custom"),
|
||||
endpoint: z.string().min(1, { message: "Endpoint URL is required" }),
|
||||
headers: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("lark"),
|
||||
@@ -166,10 +144,6 @@ export const notificationsMap = {
|
||||
icon: <NtfyIcon />,
|
||||
label: "ntfy",
|
||||
},
|
||||
custom: {
|
||||
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
|
||||
label: "Custom",
|
||||
},
|
||||
};
|
||||
|
||||
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
||||
@@ -205,13 +179,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
api.notification.testNtfyConnection.useMutation();
|
||||
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
||||
api.notification.testLarkConnection.useMutation();
|
||||
|
||||
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
||||
api.notification.testCustomConnection.useMutation();
|
||||
|
||||
const customMutation = notificationId
|
||||
? api.notification.updateCustom.useMutation()
|
||||
: api.notification.createCustom.useMutation();
|
||||
const slackMutation = notificationId
|
||||
? api.notification.updateSlack.useMutation()
|
||||
: api.notification.createSlack.useMutation();
|
||||
@@ -250,15 +217,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
name: "toAddresses" as never,
|
||||
});
|
||||
|
||||
const {
|
||||
fields: headerFields,
|
||||
append: appendHeader,
|
||||
remove: removeHeader,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "headers" as never,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "email" && fields.length === 0) {
|
||||
append("");
|
||||
@@ -273,7 +231,6 @@ 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 || "",
|
||||
@@ -287,7 +244,6 @@ 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,
|
||||
@@ -302,7 +258,6 @@ 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,
|
||||
@@ -316,7 +271,6 @@ 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,
|
||||
@@ -334,7 +288,6 @@ 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,
|
||||
@@ -349,9 +302,8 @@ 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,
|
||||
@@ -371,26 +323,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "custom") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
type: notification.notificationType,
|
||||
endpoint: notification.custom?.endpoint || "",
|
||||
headers: notification.custom?.headers
|
||||
? Object.entries(notification.custom.headers).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}),
|
||||
)
|
||||
: [],
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
form.reset();
|
||||
@@ -405,7 +337,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
gotify: gotifyMutation,
|
||||
ntfy: ntfyMutation,
|
||||
lark: larkMutation,
|
||||
custom: customMutation,
|
||||
};
|
||||
|
||||
const onSubmit = async (data: NotificationSchema) => {
|
||||
@@ -414,7 +345,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy,
|
||||
dokployRestart,
|
||||
databaseBackup,
|
||||
volumeBackup,
|
||||
dockerCleanup,
|
||||
serverThreshold,
|
||||
} = data;
|
||||
@@ -425,7 +355,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel,
|
||||
name: data.name,
|
||||
@@ -440,7 +369,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
botToken: data.botToken,
|
||||
messageThreadId: data.messageThreadId || "",
|
||||
chatId: data.chatId,
|
||||
@@ -456,7 +384,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
decoration: data.decoration,
|
||||
name: data.name,
|
||||
@@ -471,7 +398,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
smtpServer: data.smtpServer,
|
||||
smtpPort: data.smtpPort,
|
||||
username: data.username,
|
||||
@@ -490,7 +416,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
serverUrl: data.serverUrl,
|
||||
appToken: data.appToken,
|
||||
priority: data.priority,
|
||||
@@ -506,9 +431,8 @@ 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,
|
||||
@@ -529,32 +453,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
larkId: notification?.larkId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "custom") {
|
||||
// Convert headers array to object
|
||||
const headersRecord =
|
||||
data.headers && data.headers.length > 0
|
||||
? data.headers.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key.trim()) acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
promise = customMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
endpoint: data.endpoint,
|
||||
headers: headersRecord,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
customId: notification?.customId || "",
|
||||
});
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
@@ -1103,12 +1001,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
<Input
|
||||
placeholder="AzxcvbnmKjhgfdsa..."
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. Leave blank for public topics.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -1145,92 +1039,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{type === "custom" && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.example.com/webhook"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The URL where POST requests will be sent with
|
||||
notification data.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<FormLabel>Headers</FormLabel>
|
||||
<FormDescription>
|
||||
Optional. Custom headers for your POST request (e.g.,
|
||||
Authorization, Content-Type).
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{headerFields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`headers.${index}.key` as never}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Key" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`headers.${index}.value` as never}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-[2]">
|
||||
<FormControl>
|
||||
<Input placeholder="Value" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeHeader(index)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendHeader({ key: "", value: "" })}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{type === "lark" && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -1321,27 +1130,6 @@ 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"
|
||||
@@ -1423,82 +1211,58 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingEmail ||
|
||||
isLoadingGotify ||
|
||||
isLoadingNtfy ||
|
||||
isLoadingLark ||
|
||||
isLoadingCustom
|
||||
isLoadingLark
|
||||
}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
|
||||
try {
|
||||
if (data.type === "slack") {
|
||||
if (type === "slack") {
|
||||
await testSlackConnection({
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel,
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
channel: form.getValues("channel"),
|
||||
});
|
||||
} else if (data.type === "telegram") {
|
||||
} else if (type === "telegram") {
|
||||
await testTelegramConnection({
|
||||
botToken: data.botToken,
|
||||
chatId: data.chatId,
|
||||
messageThreadId: data.messageThreadId || "",
|
||||
botToken: form.getValues("botToken"),
|
||||
chatId: form.getValues("chatId"),
|
||||
messageThreadId: form.getValues("messageThreadId") || "",
|
||||
});
|
||||
} else if (data.type === "discord") {
|
||||
} else if (type === "discord") {
|
||||
await testDiscordConnection({
|
||||
webhookUrl: data.webhookUrl,
|
||||
decoration: data.decoration,
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
decoration: form.getValues("decoration"),
|
||||
});
|
||||
} else if (data.type === "email") {
|
||||
} else if (type === "email") {
|
||||
await testEmailConnection({
|
||||
smtpServer: data.smtpServer,
|
||||
smtpPort: data.smtpPort,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
smtpServer: form.getValues("smtpServer"),
|
||||
smtpPort: form.getValues("smtpPort"),
|
||||
username: form.getValues("username"),
|
||||
password: form.getValues("password"),
|
||||
toAddresses: form.getValues("toAddresses"),
|
||||
fromAddress: form.getValues("fromAddress"),
|
||||
});
|
||||
} else if (data.type === "gotify") {
|
||||
} else if (type === "gotify") {
|
||||
await testGotifyConnection({
|
||||
serverUrl: data.serverUrl,
|
||||
appToken: data.appToken,
|
||||
priority: data.priority,
|
||||
decoration: data.decoration,
|
||||
serverUrl: form.getValues("serverUrl"),
|
||||
appToken: form.getValues("appToken"),
|
||||
priority: form.getValues("priority"),
|
||||
decoration: form.getValues("decoration"),
|
||||
});
|
||||
} else if (data.type === "ntfy") {
|
||||
} else if (type === "ntfy") {
|
||||
await testNtfyConnection({
|
||||
serverUrl: data.serverUrl,
|
||||
topic: data.topic,
|
||||
accessToken: data.accessToken || "",
|
||||
priority: data.priority,
|
||||
serverUrl: form.getValues("serverUrl"),
|
||||
topic: form.getValues("topic"),
|
||||
accessToken: form.getValues("accessToken"),
|
||||
priority: form.getValues("priority"),
|
||||
});
|
||||
} else if (data.type === "lark") {
|
||||
} else if (type === "lark") {
|
||||
await testLarkConnection({
|
||||
webhookUrl: data.webhookUrl,
|
||||
});
|
||||
} else if (data.type === "custom") {
|
||||
const headersRecord =
|
||||
data.headers && data.headers.length > 0
|
||||
? data.headers.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key.trim()) acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
await testCustomConnection({
|
||||
endpoint: data.endpoint,
|
||||
headers: headersRecord,
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Error testing the provider: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Error testing the provider");
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bell, Loader2, Mail, PenBoxIcon, Trash2 } from "lucide-react";
|
||||
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DiscordIcon,
|
||||
@@ -96,11 +96,6 @@ export const ShowNotifications = () => {
|
||||
<NtfyIcon className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "custom" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<PenBoxIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "lark" && (
|
||||
<div className="flex items-center justify-center rounded-lg">
|
||||
<LarkIcon className="size-7 text-muted-foreground" />
|
||||
|
||||
@@ -41,7 +41,6 @@ const profileSchema = z.object({
|
||||
currentPassword: z.string().nullable(),
|
||||
image: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
allowImpersonation: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
@@ -89,8 +88,7 @@ export const ProfileForm = () => {
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: "",
|
||||
allowImpersonation: data?.user?.allowImpersonation || false,
|
||||
name: data?.user?.firstName || "",
|
||||
lastName: data?.user?.lastName || "",
|
||||
name: data?.user?.name || "",
|
||||
},
|
||||
resolver: zodResolver(profileSchema),
|
||||
});
|
||||
@@ -104,8 +102,7 @@ export const ProfileForm = () => {
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: form.getValues("currentPassword") || "",
|
||||
allowImpersonation: data?.user?.allowImpersonation,
|
||||
name: data?.user?.firstName || "",
|
||||
lastName: data?.user?.lastName || "",
|
||||
name: data?.user?.name || "",
|
||||
},
|
||||
{
|
||||
keepValues: true,
|
||||
@@ -130,7 +127,6 @@ export const ProfileForm = () => {
|
||||
currentPassword: values.currentPassword || undefined,
|
||||
allowImpersonation: values.allowImpersonation,
|
||||
name: values.name || undefined,
|
||||
lastName: values.lastName || undefined,
|
||||
});
|
||||
await refetch();
|
||||
toast.success("Profile Updated");
|
||||
@@ -140,7 +136,6 @@ export const ProfileForm = () => {
|
||||
image: values.image,
|
||||
currentPassword: "",
|
||||
name: values.name || "",
|
||||
lastName: values.lastName || "",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Error updating the profile");
|
||||
@@ -185,22 +180,9 @@ export const ProfileForm = () => {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Doe" {...field} />
|
||||
<Input placeholder="Name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -298,7 +280,7 @@ export const ProfileForm = () => {
|
||||
<Avatar className="default-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform">
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{getFallbackAvatarInitials(
|
||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
|
||||
data?.user?.name,
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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,
|
||||
@@ -87,26 +85,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
</EditTraefikEnv>
|
||||
|
||||
<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>
|
||||
}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
@@ -118,26 +97,14 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
);
|
||||
refetchDashboard();
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
.catch(() => {});
|
||||
}}
|
||||
disabled={toggleDashboardIsLoading}
|
||||
type="default"
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
>
|
||||
<span>
|
||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
|
||||
Dashboard
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
<span>
|
||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<ManageTraefikPorts serverId={serverId}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
|
||||
@@ -52,7 +52,6 @@ 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>;
|
||||
@@ -90,7 +89,6 @@ export const HandleServers = ({ serverId }: Props) => {
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: "",
|
||||
serverType: "deploy",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -103,7 +101,6 @@ 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]);
|
||||
|
||||
@@ -119,7 +116,6 @@ 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) => {
|
||||
@@ -270,50 +266,6 @@ 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,7 +51,6 @@ 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);
|
||||
@@ -118,26 +117,17 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
<TabsList
|
||||
className={cn(
|
||||
"grid w-[700px]",
|
||||
isBuildServer
|
||||
? "grid-cols-3"
|
||||
: isCloud
|
||||
? "grid-cols-6"
|
||||
: "grid-cols-5",
|
||||
isCloud ? "grid-cols-6" : "grid-cols-5",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="validate">Validate</TabsTrigger>
|
||||
|
||||
{!isBuildServer && (
|
||||
<>
|
||||
<TabsTrigger value="audit">Security</TabsTrigger>
|
||||
{isCloud && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||
</>
|
||||
<TabsTrigger value="audit">Security</TabsTrigger>
|
||||
{isCloud && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="ssh-keys"
|
||||
@@ -321,36 +311,32 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
<ValidateServer 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>
|
||||
</>
|
||||
)}
|
||||
<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,9 +129,6 @@ export const ShowServers = () => {
|
||||
Status
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-center">
|
||||
Type
|
||||
</TableHead>
|
||||
<TableHead className="text-center">
|
||||
IP Address
|
||||
</TableHead>
|
||||
@@ -156,8 +153,6 @@ 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">
|
||||
@@ -176,15 +171,6 @@ 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>
|
||||
@@ -247,12 +233,11 @@ export const ShowServers = () => {
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
|
||||
{server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<ShowServerActions
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
)}
|
||||
{server.sshKeyId && (
|
||||
<ShowServerActions
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -301,43 +286,41 @@ export const ShowServers = () => {
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
|
||||
{isActive &&
|
||||
server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>
|
||||
Extra
|
||||
</DropdownMenuLabel>
|
||||
{isActive && server.sshKeyId && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>
|
||||
Extra
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<ShowTraefikFileSystemModal
|
||||
serverId={server.serverId}
|
||||
<ShowTraefikFileSystemModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
<ShowDockerContainersModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
{isCloud && (
|
||||
<ShowMonitoringModal
|
||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||
token={
|
||||
server?.metricsConfig?.server
|
||||
?.token
|
||||
}
|
||||
/>
|
||||
<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,13 +25,6 @@ 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">
|
||||
@@ -80,9 +73,7 @@ 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">
|
||||
{isBuildServer
|
||||
? "Shows the build server configuration status"
|
||||
: "Shows the server configuration status"}
|
||||
Shows the server configuration status
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
@@ -94,17 +85,15 @@ export const ValidateServer = ({ serverId }: Props) => {
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{!isBuildServer && (
|
||||
<StatusRow
|
||||
label="RClone Installed"
|
||||
isEnabled={data?.rclone?.enabled}
|
||||
description={
|
||||
data?.rclone?.enabled
|
||||
? `Installed: ${data?.rclone?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
@@ -124,36 +113,23 @@ export const ValidateServer = ({ serverId }: Props) => {
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Railpack Installed"
|
||||
isEnabled={data?.railpack?.enabled}
|
||||
label="Docker Swarm Initialized"
|
||||
isEnabled={data?.isSwarmInstalled}
|
||||
description={
|
||||
data?.railpack?.enabled
|
||||
? `Installed: ${data?.railpack?.version}`
|
||||
: undefined
|
||||
data?.isSwarmInstalled
|
||||
? "Initialized"
|
||||
: "Not Initialized"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Dokploy Network Created"
|
||||
isEnabled={data?.isDokployNetworkInstalled}
|
||||
description={
|
||||
data?.isDokployNetworkInstalled
|
||||
? "Created"
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
{!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}
|
||||
@@ -163,6 +139,15 @@ 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,7 +95,6 @@ export const CreateServer = ({ stepper }: Props) => {
|
||||
port: data.port || 22,
|
||||
username: data.username || "root",
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
serverType: "deploy",
|
||||
})
|
||||
.then(async (_data) => {
|
||||
toast.success("Server Created");
|
||||
|
||||
@@ -158,7 +158,6 @@ export const AddInvitation = () => {
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const changeRoleSchema = z.object({
|
||||
role: z.enum(["admin", "member"]),
|
||||
});
|
||||
|
||||
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
|
||||
|
||||
interface Props {
|
||||
memberId: string;
|
||||
currentRole: "admin" | "member";
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } =
|
||||
api.organization.updateMemberRole.useMutation();
|
||||
|
||||
const form = useForm<ChangeRoleSchema>({
|
||||
defaultValues: {
|
||||
role: currentRole,
|
||||
},
|
||||
resolver: zodResolver(changeRoleSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
form.reset({
|
||||
role: currentRole,
|
||||
});
|
||||
}
|
||||
}, [form, currentRole, isOpen]);
|
||||
|
||||
const onSubmit = async (data: ChangeRoleSchema) => {
|
||||
await mutateAsync({
|
||||
memberId,
|
||||
role: data.role,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Role updated successfully");
|
||||
await utils.user.all.invalidate();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error?.message || "Error updating role");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger className="" asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Change Role
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change User Role</DialogTitle>
|
||||
<DialogDescription>
|
||||
Change the role for <strong>{userEmail}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-change-role"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="w-full space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
<strong>Admin:</strong> Can manage users and settings.
|
||||
<br />
|
||||
<strong>Member:</strong> Limited permissions, can be
|
||||
customized.
|
||||
<br />
|
||||
<em className="text-muted-foreground text-xs">
|
||||
Note: Owner role is intransferible.
|
||||
</em>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-change-role"
|
||||
type="submit"
|
||||
>
|
||||
Update Role
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
@@ -29,15 +30,12 @@ import {
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { AddUserPermissions } from "./add-permissions";
|
||||
import { ChangeRole } from "./change-role";
|
||||
|
||||
export const ShowUsers = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isLoading, refetch } = api.user.all.useQuery();
|
||||
const { mutateAsync } = api.user.remove.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -70,6 +68,7 @@ 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>
|
||||
@@ -84,52 +83,6 @@ export const ShowUsers = () => {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((member) => {
|
||||
const currentUserRole = data?.find(
|
||||
(m) => m.user.id === session?.user?.id,
|
||||
)?.role;
|
||||
|
||||
// Owner never has "Edit Permissions" (they're absolute owner)
|
||||
// Other users can edit permissions if target is not themselves and target is a member
|
||||
const canEditPermissions =
|
||||
member.role !== "owner" &&
|
||||
member.role === "member" &&
|
||||
member.user.id !== session?.user?.id;
|
||||
|
||||
// Can change role based on hierarchy:
|
||||
// - Owner: Can change anyone's role (except themselves and other owners)
|
||||
// - Admin: Can only change member roles (not other admins or owners)
|
||||
// - Owner role is intransferible
|
||||
const canChangeRole =
|
||||
member.role !== "owner" &&
|
||||
member.user.id !== session?.user?.id &&
|
||||
(currentUserRole === "owner" ||
|
||||
(currentUserRole === "admin" &&
|
||||
member.role === "member"));
|
||||
|
||||
// Delete/Unlink follow same hierarchy as role changes
|
||||
// - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted)
|
||||
// - Admin: Can only delete/unlink members (not other admins or owner)
|
||||
const canDelete =
|
||||
member.role !== "owner" &&
|
||||
!isCloud &&
|
||||
member.user.id !== session?.user?.id &&
|
||||
(currentUserRole === "owner" ||
|
||||
(currentUserRole === "admin" &&
|
||||
member.role === "member"));
|
||||
|
||||
const canUnlink =
|
||||
member.role !== "owner" &&
|
||||
member.user.id !== session?.user?.id &&
|
||||
(currentUserRole === "owner" ||
|
||||
(currentUserRole === "admin" &&
|
||||
member.role === "member"));
|
||||
|
||||
const hasAnyAction =
|
||||
canEditPermissions ||
|
||||
canChangeRole ||
|
||||
canDelete ||
|
||||
canUnlink;
|
||||
|
||||
return (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell className="w-[100px]">
|
||||
@@ -158,73 +111,62 @@ export const ShowUsers = () => {
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right flex justify-end">
|
||||
{hasAnyAction ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{canChangeRole && (
|
||||
<ChangeRole
|
||||
memberId={member.id}
|
||||
currentRole={
|
||||
member.role as "admin" | "member"
|
||||
}
|
||||
userEmail={member.user.email}
|
||||
/>
|
||||
)}
|
||||
{member.role !== "owner" && (
|
||||
<AddUserPermissions
|
||||
userId={member.user.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canEditPermissions && (
|
||||
<AddUserPermissions
|
||||
userId={member.user.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete User"
|
||||
description="Are you sure you want to delete this user?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"User deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
{member.role !== "owner" && (
|
||||
<>
|
||||
{!isCloud && (
|
||||
<DialogAction
|
||||
title="Delete User"
|
||||
description="Are you sure you want to delete this user?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
err?.message ||
|
||||
"Error deleting user",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"User deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting destination",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
Delete User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
{canUnlink && (
|
||||
<DialogAction
|
||||
title="Unlink User"
|
||||
description="Are you sure you want to unlink this user?"
|
||||
@@ -238,6 +180,8 @@ export const ShowUsers = () => {
|
||||
},
|
||||
);
|
||||
|
||||
console.log(orgCount);
|
||||
|
||||
if (orgCount === 1) {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
@@ -283,21 +227,10 @@ export const ShowUsers = () => {
|
||||
Unlink User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled
|
||||
>
|
||||
<span className="sr-only">
|
||||
No actions available
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ import { api } from "@/utils/api";
|
||||
|
||||
const addServerDomain = z
|
||||
.object({
|
||||
domain: z.string().trim().toLowerCase(),
|
||||
domain: z.string(),
|
||||
letsEncryptEmail: z.string(),
|
||||
https: z.boolean().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||
@@ -49,11 +49,7 @@ const addServerDomain = z
|
||||
message: "Required",
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.https &&
|
||||
data.certificateType === "letsencrypt" &&
|
||||
!data.letsEncryptEmail
|
||||
) {
|
||||
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
|
||||
@@ -105,9 +105,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
});
|
||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "Error updating Traefik ports");
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -158,11 +156,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="pr-4">
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="grid gap-4">
|
||||
{fields.map((field, index) => (
|
||||
<Card key={field.id} className="bg-transparent">
|
||||
<CardContent className="grid grid-cols-4 gap-4 p-4 transparent">
|
||||
<CardContent className="grid grid-cols-[1fr_1fr_1fr_auto] gap-4 p-4 transparent">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ports.${index}.targetPort`}
|
||||
@@ -305,12 +303,6 @@ 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,7 +83,6 @@ 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";
|
||||
|
||||
@@ -158,8 +157,7 @@ const MENU: Menu = {
|
||||
url: "/dashboard/schedules",
|
||||
icon: Clock,
|
||||
// Only enabled in non-cloud environments
|
||||
isEnabled: ({ isCloud, auth }) =>
|
||||
!isCloud && (auth?.role === "owner" || auth?.role === "admin"),
|
||||
isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner",
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -169,9 +167,7 @@ const MENU: Menu = {
|
||||
// Only enabled for admins and users with access to Traefik files in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!(
|
||||
(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canAccessToTraefikFiles) &&
|
||||
(auth?.role === "owner" || auth?.canAccessToTraefikFiles) &&
|
||||
!isCloud
|
||||
),
|
||||
},
|
||||
@@ -182,12 +178,7 @@ const MENU: Menu = {
|
||||
icon: BlocksIcon,
|
||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!(
|
||||
(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canAccessToDocker) &&
|
||||
!isCloud
|
||||
),
|
||||
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -196,12 +187,7 @@ const MENU: Menu = {
|
||||
icon: PieChart,
|
||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!(
|
||||
(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canAccessToDocker) &&
|
||||
!isCloud
|
||||
),
|
||||
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -210,12 +196,7 @@ const MENU: Menu = {
|
||||
icon: Forward,
|
||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!(
|
||||
(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canAccessToDocker) &&
|
||||
!isCloud
|
||||
),
|
||||
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
||||
},
|
||||
|
||||
// Legacy unused menu, adjusted to the new structure
|
||||
@@ -282,8 +263,7 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/server",
|
||||
icon: Activity,
|
||||
// Only enabled for admins in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
||||
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -297,8 +277,7 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/servers",
|
||||
icon: Server,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -306,8 +285,7 @@ const MENU: Menu = {
|
||||
icon: Users,
|
||||
url: "/dashboard/settings/users",
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -316,19 +294,14 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/ssh-keys",
|
||||
// Only enabled for admins and users with access to SSH keys
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(
|
||||
auth?.role === "owner" ||
|
||||
auth?.canAccessToSSHKeys ||
|
||||
auth?.role === "admin"
|
||||
),
|
||||
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
|
||||
},
|
||||
{
|
||||
title: "AI",
|
||||
icon: BotIcon,
|
||||
url: "/dashboard/settings/ai",
|
||||
isSingle: true,
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -337,11 +310,7 @@ const MENU: Menu = {
|
||||
icon: GitBranch,
|
||||
// Only enabled for admins and users with access to Git providers
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(
|
||||
auth?.role === "owner" ||
|
||||
auth?.canAccessToGitProviders ||
|
||||
auth?.role === "admin"
|
||||
),
|
||||
!!(auth?.role === "owner" || auth?.canAccessToGitProviders),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -349,8 +318,7 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/registry",
|
||||
icon: Package,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -358,8 +326,7 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/destinations",
|
||||
icon: Database,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
},
|
||||
|
||||
{
|
||||
@@ -368,8 +335,7 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/certificates",
|
||||
icon: ShieldCheck,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -377,8 +343,7 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/cluster",
|
||||
icon: Boxes,
|
||||
// Only enabled for admins in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
||||
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -386,8 +351,7 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/notifications",
|
||||
icon: Bell,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -753,9 +717,7 @@ function SidebarLogo() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(user?.role === "owner" ||
|
||||
user?.role === "admin" ||
|
||||
isCloud) && (
|
||||
{(user?.role === "owner" || isCloud) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<AddOrganization />
|
||||
@@ -1119,7 +1081,7 @@ export default function Page({ children }: Props) {
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu className="flex flex-col gap-2">
|
||||
{!isCloud && (auth?.role === "owner" || auth?.role === "admin") && (
|
||||
{!isCloud && auth?.role === "owner" && (
|
||||
<SidebarMenuItem>
|
||||
<UpdateServerButton />
|
||||
</SidebarMenuItem>
|
||||
@@ -1163,7 +1125,6 @@ export default function Page({ children }: Props) {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{!isCloud && <TimeBadge />}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
@@ -49,9 +49,7 @@ export const UserNav = () => {
|
||||
alt={data?.user?.image || ""}
|
||||
/>
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{getFallbackAvatarInitials(
|
||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
|
||||
)}
|
||||
{getFallbackAvatarInitials(data?.user?.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
@@ -104,9 +102,7 @@ export const UserNav = () => {
|
||||
>
|
||||
Monitoring
|
||||
</DropdownMenuItem>
|
||||
{(data?.role === "owner" ||
|
||||
data?.role === "admin" ||
|
||||
data?.canAccessToTraefikFiles) && (
|
||||
{(data?.role === "owner" || data?.canAccessToTraefikFiles) && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
@@ -116,9 +112,7 @@ export const UserNav = () => {
|
||||
Traefik
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(data?.role === "owner" ||
|
||||
data?.role === "admin" ||
|
||||
data?.canAccessToDocker) && (
|
||||
{(data?.role === "owner" || data?.canAccessToDocker) && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
@@ -132,7 +126,7 @@ export const UserNav = () => {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
(data?.role === "owner" || data?.role === "admin") && (
|
||||
data?.role === "owner" && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
-- 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%';
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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;
|
||||
@@ -1,6 +0,0 @@
|
||||
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[];
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "ntfy" ALTER COLUMN "accessToken" DROP NOT NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
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;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "user" ALTER COLUMN "enableDockerCleanup" SET DEFAULT true;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "notification" ADD COLUMN "volumeBackup" boolean DEFAULT false NOT NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "user" RENAME COLUMN "name" TO "firstName";--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "lastName" text DEFAULT '' NOT NULL;
|
||||
@@ -1,9 +0,0 @@
|
||||
ALTER TYPE "public"."notificationType" ADD VALUE 'custom' BEFORE 'lark';--> statement-breakpoint
|
||||
CREATE TABLE "custom" (
|
||||
"customId" text PRIMARY KEY NOT NULL,
|
||||
"endpoint" text NOT NULL,
|
||||
"headers" jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD COLUMN "customId" text;--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_customId_custom_customId_fk" FOREIGN KEY ("customId") REFERENCES "public"."custom"("customId") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "schedule" ADD COLUMN "timezone" text;
|
||||
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
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,76 +848,6 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 128,
|
||||
"version": "7",
|
||||
"when": 1765101709413,
|
||||
"tag": "0128_hard_falcon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 129,
|
||||
"version": "7",
|
||||
"when": 1765136384035,
|
||||
"tag": "0129_pale_roughhouse",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 130,
|
||||
"version": "7",
|
||||
"when": 1765167657813,
|
||||
"tag": "0130_perpetual_screwball",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
adminClient,
|
||||
apiKeyClient,
|
||||
inferAdditionalFields,
|
||||
organizationClient,
|
||||
twoFactorClient,
|
||||
} from "better-auth/client/plugins";
|
||||
@@ -14,12 +13,5 @@ export const authClient = createAuthClient({
|
||||
twoFactorClient(),
|
||||
apiKeyClient(),
|
||||
adminClient(),
|
||||
inferAdditionalFields({
|
||||
user: {
|
||||
lastName: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.26.0",
|
||||
"version": "v0.25.6",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -34,8 +34,7 @@
|
||||
"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",
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||
"test": "vitest --config __test__/vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.5",
|
||||
@@ -99,7 +98,6 @@
|
||||
"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",
|
||||
@@ -114,15 +112,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": "^16.0.7",
|
||||
"next": "^15.3.2",
|
||||
"next-i18next": "^15.4.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"node-os-utils": "2.0.1",
|
||||
"node-os-utils": "1.3.7",
|
||||
"node-pty": "1.0.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
@@ -155,18 +153,17 @@
|
||||
"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",
|
||||
|
||||
@@ -4,9 +4,9 @@ import type { NextPage } from "next";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Inter } from "next/font/google";
|
||||
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";
|
||||
@@ -42,6 +42,14 @@ const MyApp = ({
|
||||
<Head>
|
||||
<title>Dokploy</title>
|
||||
</Head>
|
||||
{process.env.NEXT_PUBLIC_UMAMI_HOST &&
|
||||
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
|
||||
<Script
|
||||
src={process.env.NEXT_PUBLIC_UMAMI_HOST}
|
||||
data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
@@ -49,7 +57,6 @@ const MyApp = ({
|
||||
disableTransitionOnChange
|
||||
forcedTheme={Component.theme}
|
||||
>
|
||||
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
||||
<Toaster richColors />
|
||||
<SearchCommand />
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { findEnvironmentById } from "@dokploy/server";
|
||||
import type { findProjectById } from "@dokploy/server";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import {
|
||||
@@ -102,7 +102,6 @@ import { api } from "@/utils/api";
|
||||
export type Services = {
|
||||
appName: string;
|
||||
serverId?: string | null;
|
||||
serverName?: string | null;
|
||||
name: string;
|
||||
type:
|
||||
| "mariadb"
|
||||
@@ -119,7 +118,8 @@ export type Services = {
|
||||
lastDeployDate?: Date | null;
|
||||
};
|
||||
|
||||
type Environment = Awaited<ReturnType<typeof findEnvironmentById>>;
|
||||
type Project = Awaited<ReturnType<typeof findProjectById>>;
|
||||
type Environment = Project["environments"][0];
|
||||
|
||||
export const extractServicesFromEnvironment = (
|
||||
environment: Environment | undefined,
|
||||
@@ -154,7 +154,6 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
lastDeployDate,
|
||||
};
|
||||
}) || [];
|
||||
@@ -169,7 +168,6 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const postgres: Services[] =
|
||||
@@ -182,7 +180,6 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const mongo: Services[] =
|
||||
@@ -195,7 +192,6 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const redis: Services[] =
|
||||
@@ -208,7 +204,6 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const mysql: Services[] =
|
||||
@@ -221,7 +216,6 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const compose: Services[] =
|
||||
@@ -250,7 +244,6 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.composeStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
lastDeployDate,
|
||||
};
|
||||
}) || [];
|
||||
@@ -399,7 +392,6 @@ 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) {
|
||||
@@ -789,27 +781,6 @@ 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(
|
||||
@@ -818,14 +789,10 @@ const EnvironmentPage = (
|
||||
service.description
|
||||
?.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())) &&
|
||||
(selectedTypes.length === 0 || selectedTypes.includes(service.type)) &&
|
||||
(selectedServerId === "" ||
|
||||
selectedServerId === "all" ||
|
||||
(selectedServerId === "dokploy-server" && !service.serverId) ||
|
||||
service.serverId === selectedServerId),
|
||||
(selectedTypes.length === 0 || selectedTypes.includes(service.type)),
|
||||
);
|
||||
return sortServices(filtered);
|
||||
}, [applications, searchQuery, selectedTypes, selectedServerId, sortBy]);
|
||||
}, [applications, searchQuery, selectedTypes, sortBy]);
|
||||
|
||||
const selectedServicesWithRunningStatus = useMemo(() => {
|
||||
return filteredServices.filter(
|
||||
@@ -1399,39 +1366,6 @@ 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>
|
||||
|
||||
@@ -1537,15 +1471,7 @@ const EnvironmentPage = (
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="mt-auto">
|
||||
<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>
|
||||
)}
|
||||
<div className="space-y-1 text-sm">
|
||||
<DateTooltip date={service.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
|
||||
@@ -17,7 +17,6 @@ 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";
|
||||
@@ -354,7 +353,7 @@ const Service = (
|
||||
id={applicationId}
|
||||
type="application"
|
||||
/>
|
||||
<ShowBuildServer applicationId={applicationId} />
|
||||
|
||||
<ShowResources id={applicationId} type="application" />
|
||||
<ShowVolumes id={applicationId} type="application" />
|
||||
<ShowRedirects applicationId={applicationId} />
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function getServerSideProps(
|
||||
};
|
||||
}
|
||||
const { user } = await validateRequest(ctx.req);
|
||||
if (!user || (user.role !== "owner" && user.role !== "admin")) {
|
||||
if (!user || user.role !== "owner") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function getServerSideProps(
|
||||
}
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user || user.role !== "owner") {
|
||||
if (!user || user.role === "member") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
|
||||
@@ -18,9 +18,7 @@ const Page = () => {
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<ProfileForm />
|
||||
{(data?.canAccessToAPI ||
|
||||
data?.role === "owner" ||
|
||||
data?.role === "admin") && <ShowApiKeys />}
|
||||
{(data?.canAccessToAPI || data?.role === "owner") && <ShowApiKeys />}
|
||||
|
||||
{/* {isCloud && <RemoveSelfAccount />} */}
|
||||
</div>
|
||||
|
||||
@@ -27,10 +27,7 @@ import { api } from "@/utils/api";
|
||||
const registerSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, {
|
||||
message: "First name is required",
|
||||
}),
|
||||
lastName: z.string().min(1, {
|
||||
message: "Last name is required",
|
||||
message: "Name is required",
|
||||
}),
|
||||
email: z
|
||||
.string()
|
||||
@@ -95,7 +92,6 @@ const Invitation = ({
|
||||
const form = useForm<Register>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
@@ -119,7 +115,6 @@ const Invitation = ({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
name: values.name,
|
||||
lastName: values.lastName,
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
"x-dokploy-token": token,
|
||||
@@ -202,22 +197,12 @@ const Invitation = ({
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Doe" {...field} />
|
||||
<Input
|
||||
placeholder="Enter your name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -27,10 +27,7 @@ import { authClient } from "@/lib/auth-client";
|
||||
const registerSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, {
|
||||
message: "First name is required",
|
||||
}),
|
||||
lastName: z.string().min(1, {
|
||||
message: "Last name is required",
|
||||
message: "Name is required",
|
||||
}),
|
||||
email: z
|
||||
.string()
|
||||
@@ -82,7 +79,6 @@ const Register = ({ isCloud }: Props) => {
|
||||
const form = useForm<Register>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
@@ -99,7 +95,6 @@ const Register = ({ isCloud }: Props) => {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
name: values.name,
|
||||
lastName: values.lastName,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
@@ -163,22 +158,9 @@ const Register = ({ isCloud }: Props) => {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Doe" {...field} />
|
||||
<Input placeholder="name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { findOwner } from "@dokploy/server";
|
||||
import { findAdmin } from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { user } from "@dokploy/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await findOwner();
|
||||
const result = await findAdmin();
|
||||
|
||||
const update = await db
|
||||
.update(user)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { findOwner, generateRandomPassword } from "@dokploy/server";
|
||||
import { findAdmin, generateRandomPassword } from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { account } from "@dokploy/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -7,7 +7,7 @@ import { eq } from "drizzle-orm";
|
||||
try {
|
||||
const randomPassword = await generateRandomPassword();
|
||||
|
||||
const result = await findOwner();
|
||||
const result = await findAdmin();
|
||||
|
||||
const update = await db
|
||||
.update(account)
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/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,11 +58,7 @@ import {
|
||||
applications,
|
||||
} from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import {
|
||||
cleanQueuesByApplication,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup";
|
||||
import { cancelDeployment, deploy } from "@/server/utils/deploy";
|
||||
import { uploadFileSchema } from "@/utils/schema";
|
||||
|
||||
@@ -729,21 +725,7 @@ 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,11 +59,7 @@ import {
|
||||
compose as composeTable,
|
||||
} from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import {
|
||||
cleanQueuesByCompose,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
|
||||
import { cancelDeployment, deploy } from "@/server/utils/deploy";
|
||||
import { generatePassword } from "@/templates/utils";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
@@ -252,21 +248,6 @@ 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,19 +47,15 @@ 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}"`;
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
createMount,
|
||||
deployMariadb,
|
||||
findBackupsByDbId,
|
||||
findEnvironmentById,
|
||||
findMariadbById,
|
||||
findEnvironmentById,
|
||||
findProjectById,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
createMount,
|
||||
deployMongo,
|
||||
findBackupsByDbId,
|
||||
findEnvironmentById,
|
||||
findMongoById,
|
||||
findEnvironmentById,
|
||||
findProjectById,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
createCustomNotification,
|
||||
createDiscordNotification,
|
||||
createEmailNotification,
|
||||
createGotifyNotification,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
findNotificationById,
|
||||
IS_CLOUD,
|
||||
removeNotificationById,
|
||||
sendCustomNotification,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
sendGotifyNotification,
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
sendServerThresholdNotifications,
|
||||
sendSlackNotification,
|
||||
sendTelegramNotification,
|
||||
updateCustomNotification,
|
||||
updateDiscordNotification,
|
||||
updateEmailNotification,
|
||||
updateGotifyNotification,
|
||||
@@ -39,7 +36,6 @@ import {
|
||||
} from "@/server/api/trpc";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
apiCreateCustom,
|
||||
apiCreateDiscord,
|
||||
apiCreateEmail,
|
||||
apiCreateGotify,
|
||||
@@ -48,7 +44,6 @@ import {
|
||||
apiCreateSlack,
|
||||
apiCreateTelegram,
|
||||
apiFindOneNotification,
|
||||
apiTestCustomConnection,
|
||||
apiTestDiscordConnection,
|
||||
apiTestEmailConnection,
|
||||
apiTestGotifyConnection,
|
||||
@@ -56,7 +51,6 @@ import {
|
||||
apiTestNtfyConnection,
|
||||
apiTestSlackConnection,
|
||||
apiTestTelegramConnection,
|
||||
apiUpdateCustom,
|
||||
apiUpdateDiscord,
|
||||
apiUpdateEmail,
|
||||
apiUpdateGotify,
|
||||
@@ -117,7 +111,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
message: "Error testing the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -234,7 +228,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
message: "Error testing the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -291,7 +285,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
message: "Error testing the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -340,7 +334,6 @@ export const notificationRouter = createTRPCRouter({
|
||||
email: true,
|
||||
gotify: true,
|
||||
ntfy: true,
|
||||
custom: true,
|
||||
lark: true,
|
||||
},
|
||||
orderBy: desc(notifications.createdAt),
|
||||
@@ -525,59 +518,6 @@ export const notificationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
createCustom: adminProcedure
|
||||
.input(apiCreateCustom)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await createCustomNotification(
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
updateCustom: adminProcedure
|
||||
.input(apiUpdateCustom)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const notification = await findNotificationById(input.notificationId);
|
||||
if (notification.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to update this notification",
|
||||
});
|
||||
}
|
||||
return await updateCustomNotification({
|
||||
...input,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
testCustomConnection: adminProcedure
|
||||
.input(apiTestCustomConnection)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await sendCustomNotification(input, {
|
||||
title: "Test Notification",
|
||||
message: "Hi, From Dokploy 👋",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
createLark: adminProcedure
|
||||
.input(apiCreateLark)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const organizationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) {
|
||||
if (ctx.user.role !== "owner" && !IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the organization owner can create an organization",
|
||||
@@ -32,6 +32,8 @@ export const organizationRouter = createTRPCRouter({
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
console.log("result", result);
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
@@ -80,22 +82,7 @@ export const organizationRouter = createTRPCRouter({
|
||||
organizationId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Verify user is a member of this organization
|
||||
const userMember = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.organizationId, input.organizationId),
|
||||
eq(member.userId, ctx.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!userMember) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not a member of this organization",
|
||||
});
|
||||
}
|
||||
|
||||
.query(async ({ input }) => {
|
||||
return await db.query.organization.findFirst({
|
||||
where: eq(organization.id, input.organizationId),
|
||||
});
|
||||
@@ -109,45 +96,12 @@ export const organizationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// First, verify the organization exists
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, input.organizationId),
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user is a member of this organization
|
||||
const userMember = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.organizationId, input.organizationId),
|
||||
eq(member.userId, ctx.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!userMember) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not a member of this organization",
|
||||
});
|
||||
}
|
||||
|
||||
// Only owners can update the organization
|
||||
// Verify the user is either the organization owner or has the owner role
|
||||
const isOwner =
|
||||
org.ownerId === ctx.user.id || userMember.role === "owner";
|
||||
|
||||
if (!isOwner) {
|
||||
if (ctx.user.role !== "owner" && !IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the organization owner can update it",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.update(organization)
|
||||
.set({
|
||||
@@ -165,7 +119,12 @@ export const organizationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// First, verify the organization exists
|
||||
if (ctx.user.role !== "owner" && !IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the organization owner can delete it",
|
||||
});
|
||||
}
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, input.organizationId),
|
||||
});
|
||||
@@ -177,27 +136,7 @@ export const organizationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user is a member of this organization
|
||||
const userMember = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.organizationId, input.organizationId),
|
||||
eq(member.userId, ctx.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!userMember) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not a member of this organization",
|
||||
});
|
||||
}
|
||||
|
||||
// Only owners can delete the organization
|
||||
// Verify the user is either the organization owner or has the owner role
|
||||
const isOwner =
|
||||
org.ownerId === ctx.user.id || userMember.role === "owner";
|
||||
|
||||
if (!isOwner) {
|
||||
if (org.ownerId !== ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the organization owner can delete it",
|
||||
@@ -255,65 +194,6 @@ export const organizationRouter = createTRPCRouter({
|
||||
.delete(invitation)
|
||||
.where(eq(invitation.id, input.invitationId));
|
||||
}),
|
||||
updateMemberRole: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
memberId: z.string(),
|
||||
role: z.enum(["admin", "member"]),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Fetch the target member
|
||||
const target = await db.query.member.findFirst({
|
||||
where: eq(member.id, input.memberId),
|
||||
with: { user: true },
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
|
||||
}
|
||||
|
||||
if (target.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to update this member's role",
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent users from changing their own role
|
||||
if (target.userId === ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You cannot change your own role",
|
||||
});
|
||||
}
|
||||
|
||||
// Owner role is intransferible - cannot change to or from owner
|
||||
if (target.role === "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "The owner role is intransferible",
|
||||
});
|
||||
}
|
||||
|
||||
// Only owners can change admin roles
|
||||
// Admins can only change member roles
|
||||
if (ctx.user.role === "admin" && target.role === "admin") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"Only the organization owner can change admin roles. Admins can only modify member roles.",
|
||||
});
|
||||
}
|
||||
|
||||
// Update the target member's role
|
||||
await db
|
||||
.update(member)
|
||||
.set({ role: input.role })
|
||||
.where(eq(member.id, input.memberId));
|
||||
|
||||
return true;
|
||||
}),
|
||||
setDefault: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user