Compare commits

..

1 Commits

Author SHA1 Message Date
dosubot[bot]
10c6b2bd73 docs: reorganize README with improved structure and clarity 2026-03-29 17:44:02 +00:00
347 changed files with 2158 additions and 105052 deletions

View File

@@ -138,8 +138,6 @@ jobs:
needs: [combine-manifests] needs: [combine-manifests]
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -162,80 +160,3 @@ jobs:
prerelease: false prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sync-version:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"

View File

@@ -68,66 +68,3 @@ jobs:
echo "✅ OpenAPI synced to website successfully" echo "✅ OpenAPI synced to website successfully"
- name: Sync to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
cd mcp-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add 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 MCP repository successfully"
- name: Sync to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
cd cli-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add 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 CLI repository successfully"
- name: Sync to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
cd sdk-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add 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 SDK repository successfully"

View File

@@ -4,8 +4,5 @@
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit", "source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit" "source.organizeImports.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
} }
} }

View File

@@ -66,7 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \ HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1 CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"] CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]

View File

@@ -20,7 +20,7 @@ Dokploy includes multiple features to make your life easier.
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.). - **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis. - **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.
- **Backups**: Automate backups for databases to an external storage destination. - **Backups**: Automate backups for databases to external storage destinations (S3, SFTP, FTP, Google Drive).
- **Docker Compose**: Native support for Docker Compose to manage complex applications. - **Docker Compose**: Native support for Docker Compose to manage complex applications.
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster. - **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click. - **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
@@ -39,7 +39,7 @@ To get started, run the following command on a VPS:
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com). Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
```bash ```bash
curl -sSL https://dokploy.com/install.sh | bash curl -sSL https://dokploy.com/install.sh | sh
``` ```
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).

View File

@@ -32,8 +32,6 @@ describe("Host rule format regression tests", () => {
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/", internalPath: "/",
stripPath: false, stripPath: false,
customEntrypoint: null,
middlewares: null,
}; };
describe("Host rule format validation", () => { describe("Host rule format validation", () => {

View File

@@ -7,7 +7,6 @@ describe("createDomainLabels", () => {
const baseDomain: Domain = { const baseDomain: Domain = {
host: "example.com", host: "example.com",
port: 8080, port: 8080,
customEntrypoint: null,
https: false, https: false,
uniqueConfigKey: 1, uniqueConfigKey: 1,
customCertResolver: null, customCertResolver: null,
@@ -22,7 +21,6 @@ describe("createDomainLabels", () => {
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/", internalPath: "/",
stripPath: false, stripPath: false,
middlewares: null,
}; };
it("should create basic labels for web entrypoint", async () => { it("should create basic labels for web entrypoint", async () => {
@@ -173,12 +171,12 @@ describe("createDomainLabels", () => {
"websecure", "websecure",
); );
// Web entrypoint with HTTPS should only have redirect // Web entrypoint should have both middlewares with redirect first
expect(webLabels).toContain( expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
); );
// Websecure should have the addprefix middleware // Websecure should only have the addprefix middleware
expect(websecureLabels).toContain( expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1", "traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
); );
@@ -210,9 +208,9 @@ describe("createDomainLabels", () => {
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
); );
// Web router with HTTPS should only have redirect // Should have middlewares in correct order: redirect, stripprefix, addprefix
expect(webLabels).toContain( expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
); );
}); });
@@ -242,259 +240,4 @@ describe("createDomainLabels", () => {
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1", "traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
); );
}); });
it("should add single custom middleware to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
);
});
it("should add multiple custom middlewares to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file", "rate-limit@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
);
});
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
const combinedDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// Web router with HTTPS should only redirect, custom middlewares go on websecure
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(labels).not.toContain("auth@file");
});
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
const combinedDomain = {
...baseDomain,
path: "/api",
stripPath: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// stripprefix should come before custom middleware
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
);
});
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
const fullDomain = {
...baseDomain,
https: true,
path: "/api",
stripPath: true,
internalPath: "/hello",
middlewares: ["auth@file", "rate-limit@file"],
};
const webLabels = await createDomainLabels(appName, fullDomain, "web");
// Web router with HTTPS should only redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
// Middleware definitions should still be present (Traefik needs them registered)
expect(webLabels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(webLabels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// But they should NOT be attached to the router
expect(webLabels).not.toContain("stripprefix-test-app-1,");
expect(webLabels).not.toContain("auth@file");
expect(webLabels).not.toContain("rate-limit@file");
});
it("should include custom middlewares on websecure entrypoint", async () => {
const customMiddlewareDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const websecureLabels = await createDomainLabels(
appName,
customMiddlewareDomain,
"websecure",
);
// Websecure should have custom middleware but not redirect-to-https
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
);
expect(websecureLabels).not.toContain("redirect-to-https");
});
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
const domain = {
...baseDomain,
https: true,
middlewares: ["rate-limit@file", "auth@file"],
};
const webLabels = await createDomainLabels(appName, domain, "web");
// Web router with HTTPS should ONLY have redirect, not custom middlewares
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(webLabels).not.toContain("rate-limit@file");
expect(webLabels).not.toContain("auth@file");
});
it("should create basic labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{ ...baseDomain, customEntrypoint: "custom" },
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
]);
});
it("should create https labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
]);
});
it("should add stripPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
);
});
it("should add internalPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
);
});
it("should add path prefix in rule for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
},
"custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
);
});
it("should combine all middlewares for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
https: true,
certificateType: "letsencrypt",
},
"custom",
);
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
// Should not contain redirect-to-https since there's only one router
expect(middlewareLabel).toBeUndefined();
});
}); });

View File

@@ -292,7 +292,7 @@ networks:
dokploy-network: dokploy-network:
`; `;
test("It shouldn't add suffix to dokploy-network", () => { test("It shoudn't add suffix to dokploy-network", () => {
const composeData = parse(composeFile7) as ComposeSpecification; const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash(); const suffix = generateRandomHash();

View File

@@ -195,7 +195,7 @@ services:
- dokploy-network - dokploy-network
`; `;
test("It shouldn't add suffix to dokploy-network in services", () => { test("It shoudn't add suffix to dokploy-network in services", () => {
const composeData = parse(composeFile7) as ComposeSpecification; const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash(); const suffix = generateRandomHash();
@@ -241,10 +241,10 @@ services:
dokploy-network: dokploy-network:
aliases: aliases:
- apid - apid
`; `;
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => { test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = parse(composeFile8) as ComposeSpecification; const composeData = parse(composeFile8) as ComposeSpecification;
const suffix = generateRandomHash(); const suffix = generateRandomHash();

View File

@@ -415,24 +415,5 @@ describe("Docker Image Name and Tag Extraction", () => {
expect(extractImageTag("my-image:123")).toBe("123"); expect(extractImageTag("my-image:123")).toBe("123");
expect(extractImageTag("my-image:1")).toBe("1"); expect(extractImageTag("my-image:1")).toBe("1");
}); });
it("should return 'latest' for registry with port but no tag", () => {
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
"latest",
);
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
});
it("should extract tag from registry with port and tag", () => {
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
"v2.0",
);
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
"sha-abc123",
);
});
}); });
}); });

View File

@@ -1,41 +0,0 @@
import { shouldDeploy } from "@dokploy/server";
import { describe, expect, it } from "vitest";
describe("shouldDeploy", () => {
it("should deploy when no watch paths are configured", () => {
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
});
it("should deploy when watch paths match modified files", () => {
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
});
it("should not deploy when watch paths do not match", () => {
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
});
it("should not throw when modified files contain non-string values", () => {
expect(() =>
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
).not.toThrow();
expect(
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
).toBe(true);
});
it("should not throw when modified files are undefined or null", () => {
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
expect(shouldDeploy(["src/**"], null)).toBe(false);
});
it("should not throw when every modified file is non-string", () => {
expect(() =>
shouldDeploy(["src/**"], [undefined, undefined] as any),
).not.toThrow();
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
});
});

View File

@@ -120,7 +120,6 @@ const baseApp: ApplicationNested = {
environmentId: "", environmentId: "",
enabled: null, enabled: null,
env: null, env: null,
icon: null,
healthCheckSwarm: null, healthCheckSwarm: null,
labelsSwarm: null, labelsSwarm: null,
memoryLimit: null, memoryLimit: null,

View File

@@ -1,4 +1,4 @@
import { getEnvironmentVariablesObject } from "@dokploy/server/index"; import { getEnviromentVariablesObject } from "@dokploy/server/index";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const projectEnv = ` const projectEnv = `
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
SECRET_KEY=env-secret-123 SECRET_KEY=env-secret-123
`; `;
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => { describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
it("resolves environment variables correctly for Stack compose", () => { it("resolves environment variables correctly for Stack compose", () => {
const serviceEnv = ` const serviceEnv = `
FOO=\${{environment.NODE_ENV}} FOO=\${{environment.NODE_ENV}}
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
BAZ=test BAZ=test
`; `;
const result = getEnvironmentVariablesObject( const result = getEnviromentVariablesObject(
serviceEnv, serviceEnv,
projectEnv, projectEnv,
environmentEnv, environmentEnv,
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
SERVICE_PORT=4000 SERVICE_PORT=4000
`; `;
const result = getEnvironmentVariablesObject( const result = getEnviromentVariablesObject(
serviceEnv, serviceEnv,
projectEnv, projectEnv,
environmentEnv, environmentEnv,
@@ -72,7 +72,7 @@ PASSWORD=secret123
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
`; `;
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv); const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
expect(result).toEqual({ expect(result).toEqual({
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb", DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
`; `;
expect(() => expect(() =>
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv), getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR"); ).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
}); });
@@ -95,7 +95,7 @@ NODE_ENV=production
API_URL=\${{environment.API_URL}} API_URL=\${{environment.API_URL}}
`; `;
const result = getEnvironmentVariablesObject( const result = getEnviromentVariablesObject(
serviceOverrideEnv, serviceOverrideEnv,
"", "",
environmentEnv, environmentEnv,
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}} COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
`; `;
const result = getEnvironmentVariablesObject( const result = getEnviromentVariablesObject(
complexServiceEnv, complexServiceEnv,
projectEnv, projectEnv,
environmentEnv, environmentEnv,
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
DB_NAME=\${{environment.DATABASE_NAME}} DB_NAME=\${{environment.DATABASE_NAME}}
`; `;
const result = getEnvironmentVariablesObject( const result = getEnviromentVariablesObject(
serviceWithConflicts, serviceWithConflicts,
conflictingProjectEnv, conflictingProjectEnv,
conflictingEnvironmentEnv, conflictingEnvironmentEnv,
@@ -170,7 +170,7 @@ SERVICE_VAR=test
PROJECT_VAR=\${{project.ENVIRONMENT}} PROJECT_VAR=\${{project.ENVIRONMENT}}
`; `;
const result = getEnvironmentVariablesObject( const result = getEnviromentVariablesObject(
serviceWithEmpty, serviceWithEmpty,
projectEnv, projectEnv,
"", "",

View File

@@ -1,8 +1,8 @@
import { describe, it, expect } from "vitest";
import { import {
enterpriseOnlyResources, enterpriseOnlyResources,
statements, statements,
} from "@dokploy/server/lib/access-control"; } from "@dokploy/server/lib/access-control";
import { describe, expect, it } from "vitest";
const FREE_TIER_RESOURCES = [ const FREE_TIER_RESOURCES = [
"organization", "organization",

View File

@@ -57,7 +57,7 @@ const createApplication = (
env: null, env: null,
}, },
replicas: 1, replicas: 1,
stopGracePeriodSwarm: 0, stopGracePeriodSwarm: 0n,
ulimitsSwarm: null, ulimitsSwarm: null,
serverId: "server-id", serverId: "server-id",
...overrides, ...overrides,
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
}); });
}); });
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => { it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0 }); const application = createApplication({ stopGracePeriodSwarm: 0n });
await mechanizeDockerContainer(application); await mechanizeDockerContainer(application);

View File

@@ -494,49 +494,4 @@ describe("processTemplate", () => {
expect(result.mounts).toHaveLength(1); expect(result.mounts).toHaveLength(1);
}); });
}); });
describe("isolated deployment config", () => {
it("should default to isolated=true when not specified", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {},
},
};
expect(template.config.isolated).toBeUndefined();
// undefined !== false => isolatedDeployment = true
expect(template.config.isolated !== false).toBe(true);
});
it("should be isolated when isolated=true is explicitly set", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: true,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(true);
});
it("should disable isolated deployment when isolated=false", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: false,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(false);
});
});
}); });

View File

@@ -30,7 +30,9 @@ describe("helpers functions", () => {
const domain = processValue("${domain}", {}, mockSchema); const domain = processValue("${domain}", {}, mockSchema);
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy(); expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
expect( expect(
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`), domain.endsWith(
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
),
).toBeTruthy(); ).toBeTruthy();
}); });
}); });

View File

@@ -95,7 +95,6 @@ const baseApp: ApplicationNested = {
dropBuildPath: null, dropBuildPath: null,
enabled: null, enabled: null,
env: null, env: null,
icon: null,
healthCheckSwarm: null, healthCheckSwarm: null,
labelsSwarm: null, labelsSwarm: null,
memoryLimit: null, memoryLimit: null,
@@ -138,7 +137,6 @@ const baseDomain: Domain = {
https: false, https: false,
path: null, path: null,
port: null, port: null,
customEntrypoint: null,
serviceName: "", serviceName: "",
composeId: "", composeId: "",
customCertResolver: null, customCertResolver: null,
@@ -147,7 +145,6 @@ const baseDomain: Domain = {
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/", internalPath: "/",
stripPath: false, stripPath: false,
middlewares: null,
}; };
const baseRedirect: Redirect = { const baseRedirect: Redirect = {
@@ -267,80 +264,6 @@ test("Websecure entrypoint on https domain with redirect", async () => {
expect(router.middlewares).toContain("redirect-test-1"); expect(router.middlewares).toContain("redirect-test-1");
}); });
/** Custom Middlewares */
test("Web entrypoint with single custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with multiple custom middlewares", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
expect(router.middlewares).toContain("rate-limit@file");
});
test("Web entrypoint on https domain with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"web",
);
// Should only have HTTPS redirect - custom middleware applies on websecure
expect(router.middlewares).toContain("redirect-to-https");
expect(router.middlewares).not.toContain("auth@file");
});
test("Websecure entrypoint with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"websecure",
);
// Should have custom middleware but not HTTPS redirect
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with redirect and custom middleware", async () => {
const router = await createRouterConfig(
{
...baseApp,
appName: "test",
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
},
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
// Should have both redirect middleware and custom middleware
expect(router.middlewares).toContain("redirect-test-1");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with empty middlewares array", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, middlewares: [] },
"web",
);
// Should behave same as no middlewares - no redirect for http
expect(router.middlewares).not.toContain("redirect-to-https");
});
/** Certificates */ /** Certificates */
test("CertificateType on websecure entrypoint", async () => { test("CertificateType on websecure entrypoint", async () => {
@@ -353,130 +276,6 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt"); expect(router.tls?.certResolver).toBe("letsencrypt");
}); });
test("Custom entrypoint on http domain", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, customEntrypoint: "custom" },
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls).toBeUndefined();
});
test("Custom entrypoint on https domain", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls?.certResolver).toBe("letsencrypt");
});
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
"custom",
);
expect(router.rule).toContain("PathPrefix(`/api`)");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(router.middlewares).toContain("stripprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(router.middlewares).toContain("addprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
path: "/public",
stripPath: true,
internalPath: "/app/v2",
},
"web",
);
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
expect(stripIndex).toBeGreaterThanOrEqual(0);
expect(addIndex).toBeGreaterThanOrEqual(0);
expect(stripIndex).toBeLessThan(addIndex);
});
test("Custom entrypoint with https and custom cert resolver", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "custom",
customCertResolver: "myresolver",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls?.certResolver).toBe("myresolver");
});
test("Custom entrypoint without https should not have tls", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: false,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls).toBeUndefined();
});
/** IDN/Punycode */ /** IDN/Punycode */
test("Internationalized domain name is converted to punycode", async () => { test("Internationalized domain name is converted to punycode", async () => {

View File

@@ -78,20 +78,4 @@ describe("readValidDirectory (path traversal)", () => {
it("returns false for empty string (resolves to cwd)", () => { it("returns false for empty string (resolves to cwd)", () => {
expect(readValidDirectory("")).toBe(false); expect(readValidDirectory("")).toBe(false);
}); });
it("returns true for Next.js dynamic route paths with square brackets", () => {
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/app/api/[id]/route.ts`,
),
).toBe(true);
expect(
readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/app/[...catch]/page.tsx`,
),
).toBe(true);
});
}); });

View File

@@ -110,13 +110,6 @@ const menuItems: MenuItem[] = [
}, },
]; ];
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface Props { interface Props {
id: string; id: string;
type: type:

View File

@@ -40,12 +40,12 @@ interface Props {
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis"; type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
} }
const AddRedirectSchema = z.object({ const AddRedirectchema = z.object({
replicas: z.number().min(1, "Replicas must be at least 1"), replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string().optional(), registryId: z.string().optional(),
}); });
type AddCommand = z.infer<typeof AddRedirectSchema>; type AddCommand = z.infer<typeof AddRedirectchema>;
export const ShowClusterSettings = ({ id, type }: Props) => { export const ShowClusterSettings = ({ id, type }: Props) => {
const queryMap = { const queryMap = {
@@ -87,7 +87,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
: {}), : {}),
replicas: data?.replicas || 1, replicas: data?.replicas || 1,
}, },
resolver: zodResolver(AddRedirectSchema), resolver: zodResolver(AddRedirectchema),
}); });
useEffect(() => { useEffect(() => {

View File

@@ -16,7 +16,7 @@ import { api } from "@/utils/api";
const hasStopGracePeriodSwarm = ( const hasStopGracePeriodSwarm = (
value: unknown, value: unknown,
): value is { stopGracePeriodSwarm: number | string | null } => ): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
typeof value === "object" && typeof value === "object" &&
value !== null && value !== null &&
"stopGracePeriodSwarm" in value; "stopGracePeriodSwarm" in value;
@@ -68,7 +68,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
const form = useForm<any>({ const form = useForm<any>({
defaultValues: { defaultValues: {
value: null as number | null, value: null as bigint | null,
}, },
}); });
@@ -76,7 +76,11 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
if (hasStopGracePeriodSwarm(data)) { if (hasStopGracePeriodSwarm(data)) {
const value = data.stopGracePeriodSwarm; const value = data.stopGracePeriodSwarm;
const normalizedValue = const normalizedValue =
value === null || value === undefined ? null : Number(value); value === null || value === undefined
? null
: typeof value === "bigint"
? value
: BigInt(value);
form.reset({ form.reset({
value: normalizedValue, value: normalizedValue,
}); });
@@ -132,7 +136,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
} }
onChange={(e) => onChange={(e) =>
field.onChange( field.onChange(
e.target.value ? Number(e.target.value) : null, e.target.value ? BigInt(e.target.value) : null,
) )
} }
/> />

View File

@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
const AddRedirectSchema = z.object({ const AddRedirectchema = z.object({
regex: z.string().min(1, "Regex required"), regex: z.string().min(1, "Regex required"),
permanent: z.boolean().default(false), permanent: z.boolean().default(false),
replacement: z.string().min(1, "Replacement required"), replacement: z.string().min(1, "Replacement required"),
}); });
type AddRedirect = z.infer<typeof AddRedirectSchema>; type AddRedirect = z.infer<typeof AddRedirectchema>;
// Default presets // Default presets
const redirectPresets = [ const redirectPresets = [
@@ -110,7 +110,7 @@ export const HandleRedirect = ({
regex: "", regex: "",
replacement: "", replacement: "",
}, },
resolver: zodResolver(AddRedirectSchema), resolver: zodResolver(AddRedirectchema),
}); });
useEffect(() => { useEffect(() => {
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
const onDialogToggle = (open: boolean) => { const onDialogToggle = (open: boolean) => {
setIsOpen(open); setIsOpen(open);
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug // commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// setPresetSelected(""); // setPresetSelected("");
// form.reset(); // form.reset();
}; };

View File

@@ -1,7 +1,6 @@
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { Check, Copy, Loader2 } from "lucide-react"; import { Check, Copy, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -166,7 +165,6 @@ export const ShowDeployment = ({
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
)} )}
</Button> </Button>
<AnalyzeLogs logs={filteredLogs} context="build" />
{serverId && ( {serverId && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">

View File

@@ -1,4 +1,3 @@
import copy from "copy-to-clipboard";
import { import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
@@ -12,6 +11,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import copy from "copy-to-clipboard";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { DateTooltip } from "@/components/shared/date-tooltip"; import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";

View File

@@ -1,303 +0,0 @@
import type { ColumnDef } from "@tanstack/react-table";
import {
ArrowUpDown,
CheckCircle2,
ExternalLink,
Loader2,
PenBoxIcon,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { RouterOutputs } from "@/utils/api";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import type { ValidationStates } from "./show-domains";
export type Domain =
| RouterOutputs["domain"]["byApplicationId"][0]
| RouterOutputs["domain"]["byComposeId"][0];
interface ColumnsProps {
id: string;
type: "application" | "compose";
validationStates: ValidationStates;
handleValidateDomain: (host: string) => Promise<void>;
handleDeleteDomain: (domainId: string) => Promise<void>;
isDeleting: boolean;
serverIp?: string;
canCreateDomain: boolean;
canDeleteDomain: boolean;
}
export const createColumns = ({
id,
type,
validationStates,
handleValidateDomain,
handleDeleteDomain,
isDeleting,
serverIp,
canCreateDomain,
canDeleteDomain,
}: ColumnsProps): ColumnDef<Domain>[] => [
...(type === "compose"
? [
{
accessorKey: "serviceName",
header: "Service",
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
const serviceName = row.getValue("serviceName") as string | null;
if (!serviceName) return null;
return (
<Badge variant="outline">
<Server className="size-3 mr-1" />
{serviceName}
</Badge>
);
},
} satisfies ColumnDef<Domain>,
]
: []),
{
accessorKey: "host",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Host
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const domain = row.original;
return (
<Link
className="flex items-center gap-2 font-medium hover:underline"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
{domain.host}
<ExternalLink className="size-3" />
</Link>
);
},
},
{
accessorKey: "path",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Path
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const path = row.getValue("path") as string;
return <div className="font-mono text-sm">{path || "/"}</div>;
},
},
{
accessorKey: "port",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Port
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const port = row.getValue("port") as number;
return <Badge variant="secondary">{port}</Badge>;
},
},
{
accessorKey: "customEntrypoint",
header: "Entrypoint",
cell: ({ row }) => {
const entrypoint = row.getValue("customEntrypoint") as string | null;
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
return <div className="font-mono text-sm">{entrypoint}</div>;
},
},
{
accessorKey: "https",
header: "Protocol",
cell: ({ row }) => {
const https = row.getValue("https") as boolean;
return (
<Badge variant={https ? "outline" : "secondary"}>
{https ? "HTTPS" : "HTTP"}
</Badge>
);
},
},
{
id: "certificate",
header: "Certificate",
cell: ({ row }) => {
const domain = row.original;
const validationState = validationStates[domain.host];
return (
<div className="flex items-center gap-2">
{domain.certificateType && (
<Badge variant="outline" className="capitalize">
{domain.certificateType}
</Badge>
)}
{!domain.host.includes("sslip.io") && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={
validationState?.isValid
? "bg-green-500/10 text-green-500 cursor-pointer"
: validationState?.error
? "bg-red-500/10 text-red-500 cursor-pointer"
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
}
onClick={() => handleValidateDomain(domain.host)}
>
{validationState?.isLoading ? (
<>
<Loader2 className="size-3 mr-1 animate-spin" />
Checking...
</>
) : validationState?.isValid ? (
<>
<CheckCircle2 className="size-3 mr-1" />
{validationState.message && validationState.cdnProvider
? `${validationState.cdnProvider}`
: "Valid"}
</>
) : validationState?.error ? (
<>
<XCircle className="size-3 mr-1" />
Invalid
</>
) : (
<>
<RefreshCw className="size-3 mr-1" />
Validate
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">Error:</p>
<p>{validationState.error}</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as string;
return (
<div className="text-sm text-muted-foreground">
{new Date(createdAt).toLocaleDateString()}
</div>
);
},
},
{
id: "actions",
header: "Actions",
enableHiding: false,
cell: ({ row }) => {
const domain = row.original;
return (
<div className="flex items-center gap-2">
{!domain.host.includes("sslip.io") && (
<DnsHelperModal
domain={{
host: domain.host,
https: domain.https,
path: domain.path || undefined,
}}
serverIp={serverIp}
/>
)}
{canCreateDomain && (
<AddDomain id={id} type={type} domainId={domain.domainId}>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 h-8 w-8"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await handleDeleteDomain(domain.domainId);
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 h-8 w-8"
isLoading={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
},
},
];

View File

@@ -1,12 +1,11 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react"; import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import z from "zod"; import z from "zod";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -62,14 +61,11 @@ export const domain = z
.min(1, { message: "Port must be at least 1" }) .min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
useCustomEntrypoint: z.boolean(),
customEntrypoint: z.string().optional(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(), customCertResolver: z.string().optional(),
serviceName: z.string().optional(), serviceName: z.string().optional(),
domainType: z.enum(["application", "compose", "preview"]).optional(), domainType: z.enum(["application", "compose", "preview"]).optional(),
middlewares: z.array(z.string()).optional(),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
if (input.https && !input.certificateType) { if (input.https && !input.certificateType) {
@@ -118,14 +114,6 @@ export const domain = z
message: "Internal path must start with '/'", message: "Internal path must start with '/'",
}); });
} }
if (input.useCustomEntrypoint && !input.customEntrypoint) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customEntrypoint"],
message: "Custom entry point must be specified",
});
}
}); });
type Domain = z.infer<typeof domain>; type Domain = z.infer<typeof domain>;
@@ -208,24 +196,20 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined, internalPath: undefined,
stripPath: false, stripPath: false,
port: undefined, port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false, https: false,
certificateType: undefined, certificateType: undefined,
customCertResolver: undefined, customCertResolver: undefined,
serviceName: undefined, serviceName: undefined,
domainType: type, domainType: type,
middlewares: [],
}, },
mode: "onChange", mode: "onChange",
}); });
const certificateType = form.watch("certificateType"); const certificateType = form.watch("certificateType");
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
const https = form.watch("https"); const https = form.watch("https");
const domainType = form.watch("domainType"); const domainType = form.watch("domainType");
const host = form.watch("host"); const host = form.watch("host");
const isTraefikMeDomain = host?.includes("sslip.io") || false; const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => { useEffect(() => {
if (data) { if (data) {
@@ -236,13 +220,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: data?.internalPath || undefined, internalPath: data?.internalPath || undefined,
stripPath: data?.stripPath || false, stripPath: data?.stripPath || false,
port: data?.port || undefined, port: data?.port || undefined,
useCustomEntrypoint: !!data.customEntrypoint,
customEntrypoint: data.customEntrypoint || undefined,
certificateType: data?.certificateType || undefined, certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined, customCertResolver: data?.customCertResolver || undefined,
serviceName: data?.serviceName || undefined, serviceName: data?.serviceName || undefined,
domainType: data?.domainType || type, domainType: data?.domainType || type,
middlewares: data?.middlewares || [],
}); });
} }
@@ -253,13 +234,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined, internalPath: undefined,
stripPath: false, stripPath: false,
port: undefined, port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false, https: false,
certificateType: undefined, certificateType: undefined,
customCertResolver: undefined, customCertResolver: undefined,
domainType: type, domainType: type,
middlewares: [],
}); });
} }
}, [form, data, isPending, domainId]); }, [form, data, isPending, domainId]);
@@ -290,7 +268,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
composeId: id, composeId: id,
}), }),
...data, ...data,
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
}) })
.then(async () => { .then(async () => {
toast.success(dictionary.success); toast.success(dictionary.success);
@@ -513,7 +490,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
{!canGenerateTraefikMeDomains && {!canGenerateTraefikMeDomains &&
field.value.includes("sslip.io") && ( field.value.includes("traefik.me") && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You need to set an IP address in your{" "} You need to set an IP address in your{" "}
<Link <Link
@@ -524,12 +501,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
? "Remote Servers -> Server -> Edit Server -> Update IP Address" ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"} : "Web Server -> Server -> Update Server IP"}
</Link>{" "} </Link>{" "}
to make your sslip.io domain work. to make your traefik.me domain work.
</AlertBlock> </AlertBlock>
)} )}
{isTraefikMeDomain && ( {isTraefikMeDomain && (
<AlertBlock type="info"> <AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP <strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect. certificate options will not have any effect.
</AlertBlock> </AlertBlock>
@@ -567,7 +544,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
sideOffset={5} sideOffset={5}
className="max-w-[10rem]" className="max-w-[10rem]"
> >
<p>Generate sslip.io domain</p> <p>Generate traefik.me domain</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -658,55 +635,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}} }}
/> />
<FormField
control={form.control}
name="useCustomEntrypoint"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Custom Entrypoint</FormLabel>
<FormDescription>
Use custom entrypoint for domain
<br />
"web" and/or "websecure" is used by default.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue("customEntrypoint", undefined);
}
}}
/>
</FormControl>
</FormItem>
)}
/>
{useCustomEntrypoint && (
<FormField
control={form.control}
name="customEntrypoint"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Entrypoint Name</FormLabel>
<FormControl>
<Input
placeholder="Enter entrypoint name manually"
{...field}
className="w-full"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField <FormField
control={form.control} control={form.control}
name="https" name="https"
@@ -797,88 +725,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
)} )}
</> </>
)} )}
<FormField
control={form.control}
name="middlewares"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Middlewares</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Add Traefik middleware references. Middlewares
must be defined in your Traefik configuration.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((name, index) => (
<Badge key={index} variant="secondary">
{name}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newMiddlewares = [...(field.value || [])];
newMiddlewares.splice(index, 1);
form.setValue("middlewares", newMiddlewares);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="e.g., rate-limit@file, auth@file"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="e.g., rate-limit@file, auth@file"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -1,22 +1,8 @@
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { import {
CheckCircle2, CheckCircle2,
ChevronDown,
ExternalLink, ExternalLink,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
LayoutGrid,
LayoutList,
Loader2, Loader2,
PenBoxIcon, PenBoxIcon,
RefreshCw, RefreshCw,
@@ -37,21 +23,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -59,7 +30,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal"; import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain"; import { AddDomain } from "./handle-domain";
@@ -104,19 +74,6 @@ export const ShowDomains = ({ id, type }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>( const [validationStates, setValidationStates] = useState<ValidationStates>(
{}, {},
); );
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
if (typeof window !== "undefined") {
return (
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
"grid"
);
}
return "grid";
});
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { const {
@@ -146,16 +103,6 @@ export const ShowDomains = ({ id, type }: Props) => {
const { mutateAsync: deleteDomain, isPending: isRemoving } = const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation(); api.domain.delete.useMutation();
const handleDeleteDomain = async (domainId: string) => {
try {
await deleteDomain({ domainId });
refetch();
toast.success("Domain deleted successfully");
} catch {
toast.error("Error deleting domain");
}
};
const handleValidateDomain = async (host: string) => { const handleValidateDomain = async (host: string) => {
setValidationStates((prev) => ({ setValidationStates((prev) => ({
...prev, ...prev,
@@ -193,37 +140,6 @@ export const ShowDomains = ({ id, type }: Props) => {
} }
}; };
const columns = createColumns({
id,
type,
validationStates,
handleValidateDomain,
handleDeleteDomain,
isDeleting: isRemoving,
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
canCreateDomain,
canDeleteDomain,
});
const table = useReactTable({
data: data ?? [],
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return ( return (
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
@@ -235,32 +151,13 @@ export const ShowDomains = ({ id, type }: Props) => {
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-row gap-2 flex-wrap"> <div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && ( {canCreateDomain && data && data?.length > 0 && (
<> <AddDomain id={id} type={type}>
<Button <Button>
variant="outline" <GlobeIcon className="size-4" /> Add Domain
size="icon"
onClick={() => {
const next = viewMode === "grid" ? "table" : "grid";
localStorage.setItem("domains-view-mode", next);
setViewMode(next);
}}
>
{viewMode === "grid" ? (
<LayoutList className="size-4" />
) : (
<LayoutGrid className="size-4" />
)}
</Button> </Button>
{canCreateDomain && ( </AddDomain>
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
)}
</>
)} )}
</div> </div>
</CardHeader> </CardHeader>
@@ -289,122 +186,6 @@ export const ShowDomains = ({ id, type }: Props) => {
</div> </div>
)} )}
</div> </div>
) : viewMode === "table" ? (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by host..."
value={
(table.getColumn("host")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("host")?.setFilterValue(event.target.value)
}
className="md:max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table?.getRowModel()?.rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{data && data?.length > 0 && (
<div className="flex items-center justify-end space-x-2 py-4">
<div className="space-x-2 flex flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] "> <div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
{data?.map((item) => { {data?.map((item) => {
@@ -425,7 +206,7 @@ export const ShowDomains = ({ id, type }: Props) => {
</Badge> </Badge>
)} )}
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{!item.host.includes("sslip.io") && ( {!item.host.includes("traefik.me") && (
<DnsHelperModal <DnsHelperModal
domain={{ domain={{
host: item.host, host: item.host,
@@ -560,22 +341,6 @@ export const ShowDomains = ({ id, type }: Props) => {
</TooltipProvider> </TooltipProvider>
)} )}
{item.middlewares?.map((middleware, index) => (
<TooltipProvider key={`${middleware}-${index}`}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Middleware: {middleware}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Traefik middleware reference</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -56,17 +56,17 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true); const [isEnvVisible, setIsEnvVisible] = useState(true);
const mutationMap = { const mutationMap = {
compose: () => api.compose.saveEnvironment.useMutation(), compose: () => api.compose.update.useMutation(),
libsql: () => api.libsql.saveEnvironment.useMutation(), libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.saveEnvironment.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.saveEnvironment.useMutation(), mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.saveEnvironment.useMutation(), mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.saveEnvironment.useMutation(), postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.saveEnvironment.useMutation(), redis: () => api.redis.update.useMutation(),
}; };
const { mutateAsync, isPending } = mutationMap[type] const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]() ? mutationMap[type]()
: api.mongo.saveEnvironment.useMutation(); : api.mongo.update.useMutation();
const form = useForm<EnvironmentSchema>({ const form = useForm<EnvironmentSchema>({
defaultValues: { defaultValues: {
@@ -116,7 +116,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) { if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }

View File

@@ -106,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) { if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }

View File

@@ -5,7 +5,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { BitbucketIcon } from "@/components/icons/data-tools-icons"; import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(), slug: z.string().optional(),
}) })
.required(), .required(),
branch: z branch: z.string().min(1, "Branch is required"),
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().optional(), enableSubmodules: z.boolean().optional(),

View File

@@ -6,7 +6,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitIcon } from "@/components/icons/data-tools-icons"; import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, { repositoryURL: z.string().min(1, {
message: "Repository URL is required", message: "Repository URL is required",
}), }),
branch: z branch: z.string().min(1, "Branch required"),
.string()
.min(1, "Branch required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
sshKey: z.string().optional(), sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false), enableSubmodules: z.boolean().default(false),
@@ -59,7 +55,7 @@ interface Props {
export const SaveGitProvider = ({ applicationId }: Props) => { export const SaveGitProvider = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { data: sshKeys } = api.sshKey.allForApps.useQuery(); const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter(); const router = useRouter();
const { mutateAsync, isPending } = const { mutateAsync, isPending } =
@@ -111,103 +107,110 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start"> onSubmit={form.handleSubmit(onSubmit)}
<FormField className="flex flex-col gap-4"
control={form.control} >
name="repositoryURL" <div className="grid md:grid-cols-2 gap-4">
render={({ field }) => ( <div className="flex items-end col-span-2 gap-4">
<FormItem className="col-span-2 lg:col-span-3"> <div className="grow">
<div className="flex items-center justify-between h-5"> <FormField
<FormLabel>Repository URL</FormLabel> control={form.control}
{field.value?.startsWith("https://") && ( name="repositoryURL"
<Link render={({ field }) => (
href={field.value} <FormItem>
target="_blank" <div className="flex items-center justify-between">
rel="noopener noreferrer" <FormLabel>Repository URL</FormLabel>
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary" {field.value?.startsWith("https://") && (
> <Link
<GitIcon className="h-4 w-4" /> href={field.value}
<span>View Repository</span> target="_blank"
</Link> rel="noopener noreferrer"
)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
</div> >
<FormControl> <GitIcon className="h-4 w-4" />
<Input placeholder="Repository URL" {...field} /> <span>View Repository</span>
</FormControl> </Link>
<FormMessage /> )}
</FormItem> </div>
<FormControl>
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="sshKey"
render={({ field }) => (
<FormItem className="basis-40">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)} )}
/> </div>
{sshKeys && sshKeys.length > 0 ? ( <div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="sshKey" name="branch"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-1"> <FormItem>
<FormLabel className="w-full inline-flex justify-between"> <FormLabel>Branch</FormLabel>
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl> <FormControl>
<Select <Input placeholder="Branch" {...field} />
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl> </FormControl>
<FormMessage />
</FormItem> </FormItem>
)} )}
/> />
) : ( </div>
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
className="col-span-2 lg:col-span-1 lg:mt-7"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="buildPath" name="buildPath"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-2"> <FormItem>
<FormLabel>Build Path</FormLabel> <FormLabel>Build Path</FormLabel>
<FormControl> <FormControl>
<Input placeholder="/" {...field} /> <Input placeholder="/" {...field} />
@@ -220,7 +223,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
control={form.control} control={form.control}
name="watchPaths" name="watchPaths"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-4"> <FormItem className="md:col-span-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>

View File

@@ -5,7 +5,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GiteaIcon } from "@/components/icons/data-tools-icons"; import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -73,10 +72,7 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"), owner: z.string().min(1, "Owner is required"),
}) })
.required(), .required(),
branch: z branch: z.string().min(1, "Branch is required"),
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
giteaId: z.string().min(1, "Gitea Provider is required"), giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).default([]), watchPaths: z.array(z.string()).default([]),
enableSubmodules: z.boolean().optional(), enableSubmodules: z.boolean().optional(),

View File

@@ -5,7 +5,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GithubIcon } from "@/components/icons/data-tools-icons"; import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"), owner: z.string().min(1, "Owner is required"),
}) })
.required(), .required(),
branch: z branch: z.string().min(1, "Branch is required"),
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
githubId: z.string().min(1, "Github Provider is required"), githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"), triggerType: z.enum(["push", "tag"]).default("push"),

View File

@@ -5,7 +5,6 @@ import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitlabIcon } from "@/components/icons/data-tools-icons"; import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
id: z.number().nullable(), id: z.number().nullable(),
}) })
.required(), .required(),
branch: z branch: z.string().min(1, "Branch is required"),
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
gitlabId: z.string().min(1, "Gitlab Provider is required"), gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false), enableSubmodules: z.boolean().default(false),

View File

@@ -58,7 +58,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-2 lg:flex lg:flex-row lg:flex-wrap gap-4"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0} disableHoverableContent={false}> <TooltipProvider delayDuration={0} disableHoverableContent={false}>
{canDeploy && ( {canDeploy && (
<DialogAction <DialogAction
@@ -274,14 +274,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
> >
<Button <Button
variant="outline" variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2 col-span-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Terminal className="size-4 mr-1" /> <Terminal className="size-4 mr-1" />
Open Terminal Open Terminal
</Button> </Button>
</DockerTerminalModal> </DockerTerminalModal>
{canUpdateService && ( {canUpdateService && (
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1"> <div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span> <span className="text-sm font-medium">Autodeploy</span>
<Switch <Switch
aria-label="Toggle autodeploy" aria-label="Toggle autodeploy"
@@ -305,7 +305,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
)} )}
{canUpdateService && ( {canUpdateService && (
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1"> <div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Clean Cache</span> <span className="text-sm font-medium">Clean Cache</span>
<Switch <Switch
aria-label="Toggle clean cache" aria-label="Toggle clean cache"

View File

@@ -1,277 +0,0 @@
import DOMPurify from "dompurify";
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Dropzone } from "@/components/ui/dropzone";
import { Input } from "@/components/ui/input";
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
interface ShowIconSettingsProps {
applicationId: string;
icon?: string | null;
}
const svgToDataUrl = (icon: BundledIcon): string => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#${icon.hex}"><path d="${icon.path}"/></svg>`;
return `data:image/svg+xml;base64,${btoa(svg)}`;
};
export const ShowIconSettings = ({
applicationId,
icon,
}: ShowIconSettingsProps) => {
const [open, setOpen] = useState(false);
const [iconSearchQuery, setIconSearchQuery] = useState("");
const [iconsToShow, setIconsToShow] = useState(24);
const filteredIcons = useMemo(() => {
if (!iconSearchQuery) return bundledIcons;
const q = iconSearchQuery.toLowerCase();
return bundledIcons.filter(
(i) =>
i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q),
);
}, [iconSearchQuery]);
const displayedIcons = filteredIcons.slice(0, iconsToShow);
const hasMoreIcons = filteredIcons.length > iconsToShow;
const utils = api.useUtils();
const { mutateAsync: updateApplication } =
api.application.update.useMutation();
useEffect(() => {
if (open) {
setIconSearchQuery("");
setIconsToShow(24);
}
}, [open]);
const handleIconSelect = async (selectedIcon: BundledIcon) => {
try {
const dataUrl = svgToDataUrl(selectedIcon);
await updateApplication({
applicationId,
icon: dataUrl,
});
toast.success("Icon saved successfully");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
};
const handleRemoveIcon = async () => {
try {
await updateApplication({
applicationId,
icon: null,
});
toast.success("Icon removed");
await utils.application.one.invalidate({ applicationId });
} catch (_error) {
toast.error("Error removing icon");
}
};
const sanitizeSvg = (svgContent: string): string | null => {
const clean = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ["use"],
});
if (!clean) return null;
return `data:image/svg+xml;base64,${btoa(clean)}`;
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
if (!file) return;
const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = ["jpg", "jpeg", "png", "svg"];
if (
!allowedTypes.includes(file.type) &&
!allowedExtensions.includes(fileExtension || "")
) {
toast.error("Only JPG, JPEG, PNG, and SVG files are allowed");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error("Image size must be less than 2MB");
return;
}
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
if (isSvg) {
const text = await file.text();
const sanitizedDataUrl = sanitizeSvg(text);
if (!sanitizedDataUrl) {
toast.error("Invalid SVG file");
return;
}
try {
await updateApplication({
applicationId,
icon: sanitizedDataUrl,
});
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
const result = event.target?.result as string;
try {
await updateApplication({
applicationId,
icon: result,
});
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
};
reader.readAsDataURL(file);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
type="button"
className="relative group flex items-center justify-center"
>
{icon ? (
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
<img
src={icon}
alt="Application icon"
className="h-8 w-8 object-contain"
/>
) : (
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded opacity-0 group-hover:opacity-100 transition-opacity">
<Pencil className="h-3 w-3 text-white" />
</div>
</button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
Change Icon
{icon && (
<Button
variant="ghost"
size="sm"
onClick={handleRemoveIcon}
className="text-muted-foreground"
>
<X className="size-4 mr-1" />
Remove icon
</Button>
)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search icons (e.g. react, vue, docker)..."
value={iconSearchQuery}
onChange={(e) => setIconSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="max-h-[300px] overflow-y-auto border rounded-lg p-4">
{displayedIcons.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No icons found
</div>
) : (
<>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
{displayedIcons.map((i) => (
<button
type="button"
key={i.slug}
onClick={() => handleIconSelect(i)}
className="flex flex-col items-center gap-1.5 p-2 rounded-lg border hover:border-primary hover:bg-muted transition-colors group"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="size-7 group-hover:scale-110 transition-transform"
fill={`#${i.hex}`}
>
<path d={i.path} />
</svg>
<span className="text-[10px] text-muted-foreground capitalize truncate w-full text-center">
{i.title}
</span>
</button>
))}
</div>
{hasMoreIcons && (
<div className="flex justify-center mt-3">
<Button
variant="outline"
size="sm"
onClick={() => setIconsToShow((prev) => prev + 24)}
>
Load More ({filteredIcons.length - iconsToShow} remaining)
</Button>
</div>
)}
</>
)}
</div>
<div className="relative pt-3 border-t">
<p className="text-sm text-muted-foreground text-center mb-3">
or upload a custom icon
</p>
<Dropzone
dropMessage="Drag & drop an icon or click to upload"
accept=".jpg,.jpeg,.png,.svg,image/jpeg,image/png,image/svg+xml"
onChange={handleFileUpload}
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
/>
<div className="mt-2 text-center text-xs text-muted-foreground">
Supported formats: JPG, JPEG, PNG, SVG (max 2MB)
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -91,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
}, [option, services, containers]); }, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading; const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength = const containersLenght =
option === "native" ? containers?.length : services?.length; option === "native" ? containers?.length : services?.length;
return ( return (
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</> </>
)} )}
<SelectLabel>Containers ({containersLength})</SelectLabel> <SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -1,2 +1,2 @@
export * from "./patch-editor";
export * from "./show-patches"; export * from "./show-patches";
export * from "./patch-editor";

View File

@@ -87,7 +87,7 @@ export const AddPreviewDomain = ({
}); });
const host = form.watch("host"); const host = form.watch("host");
const isTraefikMeDomain = host?.includes("sslip.io") || false; const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => { useEffect(() => {
if (data) { if (data) {
@@ -162,7 +162,7 @@ export const AddPreviewDomain = ({
<FormItem> <FormItem>
{isTraefikMeDomain && ( {isTraefikMeDomain && (
<AlertBlock type="info"> <AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP <strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect. certificate options will not have any effect.
</AlertBlock> </AlertBlock>
@@ -202,7 +202,7 @@ export const AddPreviewDomain = ({
sideOffset={5} sideOffset={5}
className="max-w-[10rem]" className="max-w-[10rem]"
> >
<p>Generate sslip.io domain</p> <p>Generate traefik.me domain</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>

View File

@@ -88,7 +88,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const form = useForm<Schema>({ const form = useForm<Schema>({
defaultValues: { defaultValues: {
env: "", env: "",
wildcardDomain: "*.sslip.io", wildcardDomain: "*.traefik.me",
port: 3000, port: 3000,
previewLimit: 3, previewLimit: 3,
previewLabels: [], previewLabels: [],
@@ -102,7 +102,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const previewHttps = form.watch("previewHttps"); const previewHttps = form.watch("previewHttps");
const wildcardDomain = form.watch("wildcardDomain"); const wildcardDomain = form.watch("wildcardDomain");
const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false; const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
useEffect(() => { useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false); setIsEnabled(data?.isPreviewDeploymentsActive || false);
@@ -114,7 +114,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
env: data.previewEnv || "", env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "", buildArgs: data.previewBuildArgs || "",
buildSecrets: data.previewBuildSecrets || "", buildSecrets: data.previewBuildSecrets || "",
wildcardDomain: data.previewWildcard || "*.sslip.io", wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000, port: data.previewPort || 3000,
previewLabels: data.previewLabels || [], previewLabels: data.previewLabels || [],
previewLimit: data.previewLimit || 3, previewLimit: data.previewLimit || 3,
@@ -173,7 +173,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<div className="grid gap-4"> <div className="grid gap-4">
{isTraefikMeDomain && ( {isTraefikMeDomain && (
<AlertBlock type="info"> <AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP service and <strong>Note:</strong> traefik.me is a public HTTP service and
does not support SSL/HTTPS. HTTPS and certificate options will does not support SSL/HTTPS. HTTPS and certificate options will
not have any effect. not have any effect.
</AlertBlock> </AlertBlock>
@@ -192,7 +192,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<FormItem> <FormItem>
<FormLabel>Wildcard Domain</FormLabel> <FormLabel>Wildcard Domain</FormLabel>
<FormControl> <FormControl>
<Input placeholder="*.sslip.io" {...field} /> <Input placeholder="*.traefik.me" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -80,7 +80,6 @@ export const commonCronExpressions = [
const formSchema = z const formSchema = z
.object({ .object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
description: z.string().optional(),
cronExpression: z.string().min(1, "Cron expression is required"), cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"), shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string(), command: z.string(),
@@ -225,7 +224,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
resolver: standardSchemaResolver(formSchema), resolver: standardSchemaResolver(formSchema),
defaultValues: { defaultValues: {
name: "", name: "",
description: "",
cronExpression: "", cronExpression: "",
shellType: "bash", shellType: "bash",
command: "", command: "",
@@ -265,7 +263,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
if (scheduleId && schedule) { if (scheduleId && schedule) {
form.reset({ form.reset({
name: schedule.name, name: schedule.name,
description: schedule.description || "",
cronExpression: schedule.cronExpression, cronExpression: schedule.cronExpression,
shellType: schedule.shellType, shellType: schedule.shellType,
command: schedule.command, command: schedule.command,
@@ -482,26 +479,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
)} )}
/> />
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="Backs up the database every day at midnight"
{...field}
/>
</FormControl>
<FormDescription>
Optional description of what this schedule does
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ScheduleFormField <ScheduleFormField
name="cronExpression" name="cronExpression"
formControl={form.control} formControl={form.control}

View File

@@ -125,11 +125,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
{schedule.enabled ? "Enabled" : "Disabled"} {schedule.enabled ? "Enabled" : "Disabled"}
</Badge> </Badge>
</div> </div>
{schedule.description && (
<p className="text-xs text-muted-foreground/70 [overflow-wrap:anywhere] line-clamp-2">
{schedule.description}
</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap"> <div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<Badge <Badge
variant="outline" variant="outline"

View File

@@ -483,7 +483,7 @@ export const HandleVolumeBackups = ({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <FormDescription>
Choose the volume to backup. If you do not see the Choose the volume to backup, if you dont see the
volume here, you can type the volume name manually volume here, you can type the volume name manually
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@@ -518,7 +518,7 @@ export const HandleVolumeBackups = ({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <FormDescription>
Choose the volume to backup. If you do not see the volume Choose the volume to backup, if you dont see the volume
here, you can type the volume name manually here, you can type the volume name manually
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />

View File

@@ -1,290 +0,0 @@
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { toast } from "sonner";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const DockerLogsId = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
serverId,
},
{
enabled: !!appName,
},
);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-xl">Containers</CardTitle>
<CardDescription>
Inspect each container in this compose and run basic lifecycle
actions.
</CardDescription>
</div>
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isPending}
>
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
</Button>
</CardHeader>
<CardContent>
{isPending ? (
<div className="flex items-center justify-center h-[20vh]">
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
</div>
) : !data || data.length === 0 ? (
<div className="flex items-center justify-center h-[20vh]">
<span className="text-muted-foreground">
No containers found. Deploy the compose to see containers here.
</span>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
container={container}
serverId={serverId}
onActionComplete={() => refetch()}
/>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
};
interface ContainerRowProps {
container: {
containerId: string;
name: string;
state: string;
status: string;
};
serverId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
serverId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const restartMutation = api.docker.restartContainer.useMutation();
const startMutation = api.docker.startContainer.useMutation();
const stopMutation = api.docker.stopContainer.useMutation();
const killMutation = api.docker.killContainer.useMutation();
const handleAction = async (
action: string,
mutationFn: typeof restartMutation,
) => {
setActionLoading(action);
try {
await mutationFn.mutateAsync({
containerId: container.containerId,
serverId,
});
toast.success(`Container ${action} successfully`);
onActionComplete();
} catch (error) {
toast.error(
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setActionLoading(null);
}
};
return (
<TableRow>
<TableCell className="font-medium">{container.name}</TableCell>
<TableCell>
<Badge
variant={
container.state === "running"
? "default"
: container.state === "exited"
? "secondary"
: "destructive"
}
>
{container.state}
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
</TableCell>
<TableCell className="text-right">
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Logs
</DropdownMenuItem>
</DialogTrigger>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
Restart
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("start", startMutation)}
>
Start
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("stop", stopMutation)}
>
Stop
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-600"
disabled={actionLoading !== null}
onClick={() => handleAction("kill", killMutation)}
>
Kill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>Logs for {container.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={container.containerId}
serverId={serverId}
runType="native"
/>
</div>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
);
};

View File

@@ -49,12 +49,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
const composeFile = form.watch("composeFile"); const composeFile = form.watch("composeFile");
useEffect(() => { useEffect(() => {
if (data) { if (data && !composeFile) {
form.reset({ form.reset({
composeFile: data.composeFile || "", composeFile: data.composeFile || "",
}); });
} }
}, [form, data]); }, [form, form.reset, data]);
useEffect(() => { useEffect(() => {
if (data?.composeFile !== undefined) { if (data?.composeFile !== undefined) {
@@ -95,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) { if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }

View File

@@ -5,7 +5,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { BitbucketIcon } from "@/components/icons/data-tools-icons"; import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(), slug: z.string().optional(),
}) })
.required(), .required(),
branch: z branch: z.string().min(1, "Branch is required"),
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false), enableSubmodules: z.boolean().default(false),

View File

@@ -6,7 +6,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitIcon } from "@/components/icons/data-tools-icons"; import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, { repositoryURL: z.string().min(1, {
message: "Repository URL is required", message: "Repository URL is required",
}), }),
branch: z branch: z.string().min(1, "Branch required"),
.string()
.min(1, "Branch required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
sshKey: z.string().optional(), sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false), enableSubmodules: z.boolean().default(false),
@@ -59,7 +55,7 @@ interface Props {
export const SaveGitProviderCompose = ({ composeId }: Props) => { export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId }); const { data, refetch } = api.compose.one.useQuery({ composeId });
const { data: sshKeys } = api.sshKey.allForApps.useQuery(); const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter(); const router = useRouter();
const { mutateAsync, isPending } = api.compose.update.useMutation(); const { mutateAsync, isPending } = api.compose.update.useMutation();

View File

@@ -1,11 +1,10 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GiteaIcon } from "@/components/icons/data-tools-icons"; import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -58,10 +57,7 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"), owner: z.string().min(1, "Owner is required"),
}) })
.required(), .required(),
branch: z branch: z.string().min(1, "Branch is required"),
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
giteaId: z.string().min(1, "Gitea Provider is required"), giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false), enableSubmodules: z.boolean().default(false),
@@ -413,8 +409,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" /> <div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>

View File

@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"), owner: z.string().min(1, "Owner is required"),
}) })
.required(), .required(),
branch: z branch: z.string().min(1, "Branch is required"),
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
githubId: z.string().min(1, "Github Provider is required"), githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"), triggerType: z.enum(["push", "tag"]).default("push"),

View File

@@ -5,7 +5,6 @@ import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitlabIcon } from "@/components/icons/data-tools-icons"; import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
gitlabPathNamespace: z.string().min(1), gitlabPathNamespace: z.string().min(1),
}) })
.required(), .required(),
branch: z branch: z.string().min(1, "Branch is required"),
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
gitlabId: z.string().min(1, "Gitlab Provider is required"), gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false), enableSubmodules: z.boolean().default(false),

View File

@@ -77,7 +77,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
}, [option, services, containers]); }, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading; const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength = const containersLenght =
option === "native" ? containers?.length : services?.length; option === "native" ? containers?.length : services?.length;
return ( return (
@@ -152,7 +152,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
</> </>
)} )}
<SelectLabel>Containers ({containersLength})</SelectLabel> <SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -225,7 +225,7 @@ export const RestoreBackup = ({
resolver: zodResolver(RestoreBackupSchema), resolver: zodResolver(RestoreBackupSchema),
}); });
const destinationId = form.watch("destinationId"); const destionationId = form.watch("destinationId");
const currentDatabaseType = form.watch("databaseType"); const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata"); const metadata = form.watch("metadata");
@@ -240,12 +240,12 @@ export const RestoreBackup = ({
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery( const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{ {
destinationId: destinationId, destinationId: destionationId,
search: debouncedSearchTerm, search: debouncedSearchTerm,
serverId: serverId ?? "", serverId: serverId ?? "",
}, },
{ {
enabled: isOpen && !!destinationId, enabled: isOpen && !!destionationId,
}, },
); );
@@ -288,6 +288,7 @@ export const RestoreBackup = ({
toast.error("Please select a database type"); toast.error("Please select a database type");
return; return;
} }
console.log({ data });
setIsDeploying(true); setIsDeploying(true);
}; };

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import type { inferRouterOutputs } from "@trpc/server"; import type { inferRouterOutputs } from "@trpc/server";
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {

View File

@@ -1,220 +0,0 @@
"use client";
import copy from "copy-to-clipboard";
import {
Bot,
Check,
Copy,
Loader2,
RotateCcw,
Settings,
X,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import type { LogLine } from "./utils";
interface Props {
logs: LogLine[];
context: "build" | "runtime";
}
const MAX_LOG_LINES = 200;
export function AnalyzeLogs({ logs, context }: Props) {
const [open, setOpen] = useState(false);
const [aiId, setAiId] = useState<string>("");
const [copied, setCopied] = useState(false);
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
enabled: open,
});
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
onError: (error) => {
toast.error("Analysis failed", {
description: error.message,
});
},
});
const handleAnalyze = () => {
if (!aiId || logs.length === 0) return;
const logsText = logs
.slice(-MAX_LOG_LINES)
.map((l) => l.message)
.join("\n");
mutate({ aiId, logs: logsText, context });
};
const handleCopy = () => {
if (!data?.analysis) return;
const success = copy(data.analysis);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
reset();
setAiId("");
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9"
disabled={logs.length === 0}
title="Analyze logs with AI"
>
<Bot className="mr-2 size-4" />
AI
</Button>
</PopoverTrigger>
<PopoverContent className="w-[550px] p-0" align="end">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4" />
<span className="text-sm font-medium">Log Analysis</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setOpen(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="p-4 space-y-3">
{!data?.analysis ? (
providers && providers.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-2 text-center">
<p className="text-sm text-muted-foreground">
No AI providers configured. Set up a provider to start
analyzing logs.
</p>
<Button size="sm" variant="outline" asChild>
<Link href="/dashboard/settings/ai">
<Settings className="mr-2 h-3.5 w-3.5" />
Configure AI Provider
</Link>
</Button>
</div>
) : (
<>
<Select value={aiId} onValueChange={setAiId}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select AI provider..." />
</SelectTrigger>
<SelectContent>
{providers?.map((p) => (
<SelectItem key={p.aiId} value={p.aiId}>
{p.name} ({p.model})
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
className="w-full"
disabled={!aiId || isPending || logs.length === 0}
onClick={handleAnalyze}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Analyzing...
</>
) : (
<>
<Bot className="mr-2 h-3.5 w-3.5" />
Analyze{" "}
{logs.length > MAX_LOG_LINES
? `last ${MAX_LOG_LINES}`
: logs.length}{" "}
lines
</>
)}
</Button>
</>
)
) : (
<>
<div className="max-h-[400px] overflow-y-auto">
<div className="prose prose-sm dark:prose-invert max-w-none text-sm break-words">
<ReactMarkdown>{data.analysis}</ReactMarkdown>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => {
reset();
handleAnalyze();
}}
disabled={isPending}
>
{isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="mr-2 h-3.5 w-3.5" />
)}
Re-analyze
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCopy}
title="Copy analysis to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
reset();
setAiId("");
}}
title="Change provider"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -12,7 +12,6 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { AnalyzeLogs } from "./analyze-logs";
import { LineCountFilter } from "./line-count-filter"; import { LineCountFilter } from "./line-count-filter";
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter"; import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
import { StatusLogsFilter } from "./status-logs-filter"; import { StatusLogsFilter } from "./status-logs-filter";
@@ -347,13 +346,11 @@ export const DockerLogsId: React.FC<Props> = ({
title={isPaused ? "Resume logs" : "Pause logs"} title={isPaused ? "Resume logs" : "Pause logs"}
> >
{isPaused ? ( {isPaused ? (
<Play className="size-4" /> <Play className="mr-2 h-4 w-4" />
) : ( ) : (
<Pause className="size-4" /> <Pause className="mr-2 h-4 w-4" />
)} )}
<span className="hidden lg:ml-2 lg:inline"> {isPaused ? "Resume" : "Pause"}
{isPaused ? "Resume" : "Pause"}
</span>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -364,13 +361,11 @@ export const DockerLogsId: React.FC<Props> = ({
title="Copy logs to clipboard" title="Copy logs to clipboard"
> >
{copied ? ( {copied ? (
<Check className="size-4" /> <Check className="mr-2 h-4 w-4" />
) : ( ) : (
<Copy className="size-4" /> <Copy className="mr-2 h-4 w-4" />
)} )}
<span className="hidden lg:ml-2 lg:inline"> Copy
{copied ? "Copied" : "Copy"}
</span>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -378,18 +373,16 @@ export const DockerLogsId: React.FC<Props> = ({
className="h-9 sm:w-auto w-full" className="h-9 sm:w-auto w-full"
onClick={handleDownload} onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name} disabled={filteredLogs.length === 0 || !data?.Name}
title="Download logs as text file"
> >
<DownloadIcon className="size-4" /> <DownloadIcon className="mr-2 h-4 w-4" />
<span className="hidden lg:ml-2 lg:inline">Download logs</span> Download logs
</Button> </Button>
<AnalyzeLogs logs={filteredLogs} context="runtime" />
</div> </div>
</div> </div>
{isPaused && ( {isPaused && (
<AlertBlock type="warning" className="items-center"> <AlertBlock type="warning">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pause className="size-4" /> <Pause className="h-4 w-4" />
<span> <span>
Logs paused Logs paused
{messageBuffer.length > 0 && ( {messageBuffer.length > 0 && (

View File

@@ -103,7 +103,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
> >
{" "} {" "}
<div className="flex items-start gap-x-2"> <div className="flex items-start gap-x-2">
{/* Icon to expand the log item maybe implement a collapsible later */} {/* Icon to expand the log item maybe implement a colapsible later */}
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */} {/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)} {tooltip(color, rawTimestamp)}
{!noTimestamp && ( {!noTimestamp && (

View File

@@ -74,18 +74,6 @@ export function parseLogs(logString: string): LogLine[] {
// Detect log type based on message content // Detect log type based on message content
export const getLogType = (message: string): LogStyle => { export const getLogType = (message: string): LogStyle => {
// Detect HTTP statusCode
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
if (statusMatch) {
const statusCode = Number(statusMatch[1]);
if (statusCode >= 500) return LOG_STYLES.error;
if (statusCode >= 400) return LOG_STYLES.warning;
if (statusCode >= 200 && statusCode < 300) return LOG_STYLES.success;
return LOG_STYLES.info;
}
const lowerMessage = message.toLowerCase(); const lowerMessage = message.toLowerCase();
if ( if (

View File

@@ -1,112 +0,0 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Mount {
Type: string;
Source: string;
Destination: string;
Mode: string;
RW: boolean;
Propagation: string;
Name?: string;
Driver?: string;
}
export const ShowContainerMounts = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const mounts: Mount[] = data?.Mounts ?? [];
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Mounts
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Mounts</DialogTitle>
<DialogDescription>
Volume and bind mounts for this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{mounts.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No mounts found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Mode</TableHead>
<TableHead>Read/Write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mounts.map((mount, index) => (
<TableRow key={index}>
<TableCell>
<Badge variant="outline">{mount.Type}</Badge>
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Name || mount.Source}
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Destination}
</TableCell>
<TableCell className="text-xs">
{mount.Mode || "-"}
</TableCell>
<TableCell>
<Badge variant={mount.RW ? "default" : "secondary"}>
{mount.RW ? "RW" : "RO"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,119 +0,0 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Network {
IPAMConfig: unknown;
Links: unknown;
Aliases: string[] | null;
MacAddress: string;
NetworkID: string;
EndpointID: string;
Gateway: string;
IPAddress: string;
IPPrefixLen: number;
IPv6Gateway: string;
GlobalIPv6Address: string;
GlobalIPv6PrefixLen: number;
DriverOpts: unknown;
}
export const ShowContainerNetworks = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const networks: Record<string, Network> =
data?.NetworkSettings?.Networks ?? {};
const entries = Object.entries(networks);
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Networks
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Networks</DialogTitle>
<DialogDescription>
Networks attached to this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{entries.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No networks found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Network</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Gateway</TableHead>
<TableHead>MAC Address</TableHead>
<TableHead>Aliases</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map(([name, network]) => (
<TableRow key={name}>
<TableCell>
<Badge variant="outline">{name}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{network.IPAddress
? `${network.IPAddress}/${network.IPPrefixLen}`
: "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.Gateway || "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.MacAddress || "-"}
</TableCell>
<TableCell className="text-xs">
{network.Aliases?.join(", ") || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -10,11 +10,8 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config"; import { ShowContainerConfig } from "../config/show-container-config";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
import { RemoveContainerDialog } from "../remove/remove-container"; import { RemoveContainerDialog } from "../remove/remove-container";
import { DockerTerminalModal } from "../terminal/docker-terminal-modal"; import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
import { UploadFileModal } from "../upload/upload-file-modal";
import type { Container } from "./show-containers"; import type { Container } from "./show-containers";
export const columns: ColumnDef<Container>[] = [ export const columns: ColumnDef<Container>[] = [
@@ -125,26 +122,12 @@ export const columns: ColumnDef<Container>[] = [
containerId={container.containerId} containerId={container.containerId}
serverId={container.serverId || ""} serverId={container.serverId || ""}
/> />
<ShowContainerMounts
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<DockerTerminalModal <DockerTerminalModal
containerId={container.containerId} containerId={container.containerId}
serverId={container.serverId || ""} serverId={container.serverId || ""}
> >
Terminal Terminal
</DockerTerminalModal> </DockerTerminalModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Upload File
</UploadFileModal>
<RemoveContainerDialog <RemoveContainerDialog
containerId={container.containerId} containerId={container.containerId}
serverId={container.serverId ?? undefined} serverId={container.serverId ?? undefined}

View File

@@ -35,7 +35,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { api, type RouterOutputs } from "@/utils/api"; import { api, type RouterOutputs } from "@/utils/api";
import { columns } from "./columns"; import { columns } from "./colums";
export type Container = NonNullable< export type Container = NonNullable<
RouterOutputs["docker"]["getContainers"] RouterOutputs["docker"]["getContainers"]
>[0]; >[0];

View File

@@ -1,187 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Upload } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
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 { Dropzone } from "@/components/ui/dropzone";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
type UploadFileToContainer,
uploadFileToContainerSchema,
} from "@/utils/schema";
interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
}
export const UploadFileModal = ({ children, containerId, serverId }: Props) => {
const [open, setOpen] = useState(false);
const { mutateAsync: uploadFile, isPending: isLoading } =
api.docker.uploadFileToContainer.useMutation({
onSuccess: () => {
toast.success("File uploaded successfully");
setOpen(false);
form.reset();
},
onError: (error) => {
toast.error(error.message || "Failed to upload file to container");
},
});
const form = useForm({
resolver: zodResolver(uploadFileToContainerSchema),
defaultValues: {
containerId,
destinationPath: "/",
serverId: serverId || undefined,
},
});
const file = form.watch("file");
const onSubmit = async (values: UploadFileToContainer) => {
if (!values.file) {
toast.error("Please select a file to upload");
return;
}
const formData = new FormData();
formData.append("containerId", values.containerId);
formData.append("file", values.file);
formData.append("destinationPath", values.destinationPath);
if (values.serverId) {
formData.append("serverId", values.serverId);
}
await uploadFile(formData);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload File to Container
</DialogTitle>
<DialogDescription>
Upload a file directly into the container's filesystem
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="destinationPath"
render={({ field }) => (
<FormItem>
<FormLabel>Destination Path</FormLabel>
<FormControl>
<Input
{...field}
placeholder="/path/to/file"
className="font-mono"
/>
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
Enter the full path where the file should be uploaded in the
container (e.g., /app/config.json)
</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="file"
render={({ field }) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Dropzone
{...field}
dropMessage="Drop file here or click to browse"
onChange={(files) => {
if (files && files.length > 0) {
field.onChange(files[0]);
} else {
field.onChange(null);
}
}}
/>
</FormControl>
<FormMessage />
{file instanceof File && (
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
<span className="text-sm text-muted-foreground flex-1">
{file.name} ({(file.size / 1024).toFixed(2)} KB)
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange(null)}
>
Remove
</Button>
</div>
)}
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
isLoading={isLoading}
disabled={!file || isLoading}
>
Upload File
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,291 +0,0 @@
import { formatDistanceToNow } from "date-fns";
import { ArrowRight, Rocket, Server } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { api } from "@/utils/api";
type DeploymentStatus = "idle" | "running" | "done" | "error";
const statusDotClass: Record<string, string> = {
done: "bg-emerald-500",
running: "bg-amber-500",
error: "bg-red-500",
idle: "bg-muted-foreground/40",
};
function getServiceInfo(d: any) {
const app = d.application;
const comp = d.compose;
const serverName: string =
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
if (app?.environment?.project && app.environment) {
return {
name: app.name as string,
environment: app.environment.name as string,
projectName: app.environment.project.name as string,
serverName,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
name: comp.name as string,
environment: comp.environment.name as string,
projectName: comp.environment.project.name as string,
serverName,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
}
function StatCard({
label,
value,
delta,
}: {
label: string;
value: string;
delta?: string;
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex flex-col gap-1">
<span className="text-3xl font-semibold tracking-tight">{value}</span>
{delta && (
<span className="text-xs text-muted-foreground">{delta}</span>
)}
</div>
</div>
);
}
function StatusListCard({
label,
items,
}: {
label: string;
items: { dotClass: string; label: string; count: number }[];
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<ul className="flex flex-col gap-1.5">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-2.5 text-sm">
<span
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
aria-hidden
/>
<span className="font-semibold tabular-nums w-8">{item.count}</span>
<span className="text-muted-foreground">{item.label}</span>
</li>
))}
</ul>
</div>
);
}
export const ShowHome = () => {
const { data: auth } = api.user.get.useQuery();
const { data: homeStats } = api.project.homeStats.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const canReadDeployments = !!permissions?.deployment.read;
const { data: deployments } = api.deployment.allCentralized.useQuery(
undefined,
{
enabled: canReadDeployments,
refetchInterval: 10000,
},
);
const firstName = auth?.user?.firstName?.trim();
const totals = homeStats ?? {
projects: 0,
environments: 0,
applications: 0,
compose: 0,
databases: 0,
services: 0,
};
const statusBreakdown = homeStats?.status ?? {
running: 0,
error: 0,
idle: 0,
};
const recentDeployments = useMemo(() => {
if (!deployments) return [];
return [...deployments]
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
.slice(0, 10);
}, [deployments]);
const deployStats = useMemo(() => {
const now = Date.now();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const lastStart = now - weekMs;
const prevStart = now - 2 * weekMs;
const last: NonNullable<typeof deployments> = [];
const prev: NonNullable<typeof deployments> = [];
for (const d of deployments ?? []) {
const t = new Date(d.createdAt).getTime();
if (t >= lastStart) last.push(d);
else if (t >= prevStart) prev.push(d);
}
const lastCount = last.length;
const prevCount = prev.length;
let delta: string | undefined;
if (prevCount > 0) {
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
} else if (lastCount > 0) {
delta = "no prior data";
} else {
delta = "no activity yet";
}
return { value: String(lastCount), delta };
}, [deployments]);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[85vh]">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6 h-full">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-3xl font-semibold tracking-tight">
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
</h1>
<Button asChild variant="secondary" className="w-fit">
<Link href="/dashboard/projects">
Go to projects
<ArrowRight className="size-4" />
</Link>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Projects"
value={String(totals.projects)}
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
/>
<StatCard
label="Services"
value={String(totals.services)}
delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
/>
<StatCard
label="Deploys / 7d"
value={deployStats.value}
delta={deployStats.delta}
/>
<StatusListCard
label="Status"
items={[
{
dotClass: "bg-emerald-500",
label: "running",
count: statusBreakdown.running,
},
{
dotClass: "bg-red-500",
label: "errored",
count: statusBreakdown.error,
},
{
dotClass: "bg-muted-foreground/40",
label: "idle",
count: statusBreakdown.idle,
},
]}
/>
</div>
<div className="rounded-xl border bg-background">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex items-center gap-2">
<Rocket className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Recent deployments</h2>
</div>
{canReadDeployments && (
<Link
href="/dashboard/deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all
</Link>
)}
</div>
{!canReadDeployments ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>You do not have permission to view deployments.</span>
</div>
) : recentDeployments.length === 0 ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>No deployments yet.</span>
</div>
) : (
<ul className="divide-y">
{recentDeployments.map((d) => {
const info = getServiceInfo(d);
if (!info) return null;
const status = (d.status ?? "idle") as DeploymentStatus;
return (
<li key={d.deploymentId}>
<Link
href={info.href}
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
>
<span
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
aria-hidden
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm truncate">{info.name}</span>
<span className="text-xs text-muted-foreground truncate">
{info.projectName} · {info.environment}
</span>
</div>
<span className="text-xs text-muted-foreground w-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
<Server className="size-3 shrink-0" />
<span className="truncate">{info.serverName}</span>
</span>
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
{status}
</span>
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
{formatDistanceToNow(new Date(d.createdAt), {
addSuffix: true,
})}
</span>
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
logs
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
</Card>
</div>
);
};

View File

@@ -1,6 +1,4 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -11,9 +9,6 @@ interface Props {
} }
export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => { export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
const { data } = api.mariadb.one.useQuery({ mariadbId }); const { data } = api.mariadb.one.useQuery({ mariadbId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mariadb.changePassword.useMutation();
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
@@ -33,43 +28,20 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={data?.databasePassword} value={data?.databasePassword}
/> />
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mariadbId,
password: newPassword,
type: "user",
});
toast.success("Password updated successfully");
utils.mariadb.one.invalidate({ mariadbId });
}}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Root Password</Label> <Label>Root Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={data?.databaseRootPassword} value={data?.databaseRootPassword}
/> />
<UpdateDatabasePassword
label="Root Password"
onUpdatePassword={async (newPassword) => {
await changePassword({
mariadbId,
password: newPassword,
type: "root",
});
toast.success("Root password updated successfully");
utils.mariadb.one.invalidate({ mariadbId });
}}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View File

@@ -82,8 +82,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
const params = `authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`; return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/?${params}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());

View File

@@ -1,6 +1,4 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -11,9 +9,6 @@ interface Props {
} }
export const ShowInternalMongoCredentials = ({ mongoId }: Props) => { export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
const { data } = api.mongo.one.useQuery({ mongoId }); const { data } = api.mongo.one.useQuery({ mongoId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mongo.changePassword.useMutation();
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
@@ -30,21 +25,11 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={data?.databasePassword} value={data?.databasePassword}
/> />
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mongoId,
password: newPassword,
});
toast.success("Password updated successfully");
utils.mongo.one.invalidate({ mongoId });
}}
/>
</div> </div>
</div> </div>
@@ -62,7 +47,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<Label>Internal Connection URL </Label> <Label>Internal Connection URL </Label>
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017/?authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`} value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
/> />
</div> </div>
</div> </div>

View File

@@ -1,103 +1,103 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
type ChartConfig, Area,
ChartContainer, AreaChart,
ChartLegend, CartesianGrid,
ChartLegendContent, Legend,
ChartTooltip, ResponsiveContainer,
ChartTooltipContent, Tooltip,
} from "@/components/ui/chart"; YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["block"]; acummulativeData: DockerStatsJSON["block"];
} }
const chartConfig = { export const DockerBlockChart = ({ acummulativeData }: Props) => {
readMb: { const transformedData = acummulativeData.map((item, index) => {
label: "Read (MB)", return {
color: "hsl(var(--chart-1))", time: item.time,
}, name: `Point ${index + 1}`,
writeMb: { readMb: item.value.readMb,
label: "Write (MB)", writeMb: item.value.writeMb,
color: "hsl(var(--chart-2))", };
}, });
} satisfies ChartConfig;
export const DockerBlockChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
readMb: item.value.readMb,
writeMb: item.value.writeMb,
}));
return ( return (
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full"> <div className="mt-6 w-full h-[10rem]">
<AreaChart <ResponsiveContainer>
data={transformedData} <AreaChart
margin={{ top: 10, right: 10, left: 0, bottom: 0 }} data={transformedData}
> margin={{
<defs> top: 10,
<linearGradient id="fillBlockRead" x1="0" y1="0" x2="0" y2="1"> right: 30,
<stop left: 0,
offset="5%" bottom: 0,
stopColor="var(--color-readMb)" }}
stopOpacity={0.8} >
/> <defs>
<stop <linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
offset="95%" <stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
stopColor="var(--color-readMb)" <stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
stopOpacity={0.1} </linearGradient>
/> <linearGradient id="colorWrite" x1="0" y1="0" x2="0" y2="1">
</linearGradient> <stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
<linearGradient id="fillBlockWrite" x1="0" y1="0" x2="0" y2="1"> <stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
<stop </linearGradient>
offset="5%" </defs>
stopColor="var(--color-writeMb)" <YAxis stroke="#A1A1AA" />
stopOpacity={0.8} <CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
/> {/* @ts-ignore */}
<stop <Tooltip content={<CustomTooltip />} />
offset="95%" <Legend />
stopColor="var(--color-writeMb)" <Area
stopOpacity={0.1} type="monotone"
/> dataKey="readMb"
</linearGradient> stroke="#27272A"
</defs> fillOpacity={1}
<CartesianGrid vertical={false} /> fill="url(#colorUv)"
<YAxis tickLine={false} axisLine={false} /> name="Read Mb"
<ChartTooltip />
cursor={false} <Area
content={ type="monotone"
<ChartTooltipContent dataKey="writeMb"
labelFormatter={(_, payload) => { stroke="#82ca9d"
const time = payload?.[0]?.payload?.time; fillOpacity={1}
return time ? format(new Date(time), "PPpp") : ""; fill="url(#colorWrite)"
}} name="Write Mb"
formatter={(value, name) => { />
const label = name === "readMb" ? "Read" : "Write"; </AreaChart>
return [`${value} MB`, label]; </ResponsiveContainer>
}} </div>
/>
}
/>
<Area
type="monotone"
dataKey="readMb"
stroke="var(--color-readMb)"
fill="url(#fillBlockRead)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="writeMb"
stroke="var(--color-writeMb)"
fill="url(#fillBlockWrite)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
readMb: number;
writeMb: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Read ${payload[0].payload.readMb} `}</p>
<p>{`Write: ${payload[0].payload.writeMb} `}</p>
</div>
);
}
return null;
};

View File

@@ -1,81 +1,87 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
type ChartConfig, Area,
ChartContainer, AreaChart,
ChartLegend, CartesianGrid,
ChartLegendContent, Legend,
ChartTooltip, ResponsiveContainer,
ChartTooltipContent, Tooltip,
} from "@/components/ui/chart"; YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["cpu"]; acummulativeData: DockerStatsJSON["cpu"];
} }
const chartConfig = { export const DockerCpuChart = ({ acummulativeData }: Props) => {
usage: { const transformedData = acummulativeData.map((item, index) => {
label: "CPU Usage", return {
color: "hsl(var(--chart-1))", name: `Point ${index + 1}`,
}, time: item.time,
} satisfies ChartConfig; usage: item.value.toString().split("%")[0],
};
export const DockerCpuChart = ({ accumulativeData }: Props) => { });
const transformedData = accumulativeData.map((item, index) => ({
name: `Point ${index + 1}`,
time: item.time,
usage: item.value.toString().split("%")[0],
}));
return ( return (
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full"> <div className="mt-6 w-full h-[10rem]">
<AreaChart <ResponsiveContainer>
data={transformedData} <AreaChart
margin={{ top: 10, right: 10, left: 0, bottom: 0 }} data={transformedData}
> margin={{
<defs> top: 10,
<linearGradient id="fillCpu" x1="0" y1="0" x2="0" y2="1"> right: 30,
<stop left: 0,
offset="5%" bottom: 0,
stopColor="var(--color-usage)" }}
stopOpacity={0.8} >
/> <defs>
<stop <linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
offset="95%" <stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
stopColor="var(--color-usage)" <stop offset="95%" stopColor="white" stopOpacity={0} />
stopOpacity={0.1} </linearGradient>
/> </defs>
</linearGradient> <YAxis stroke="#A1A1AA" domain={[0, 100]} />
</defs> <CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
<CartesianGrid vertical={false} /> {/* @ts-ignore */}
<YAxis <Tooltip content={<CustomTooltip />} />
tickFormatter={(value) => `${value}%`} <Legend />
domain={[0, 100]} <Area
tickLine={false} type="monotone"
axisLine={false} dataKey="usage"
/> stroke="#27272A"
<ChartTooltip fillOpacity={1}
cursor={false} fill="url(#colorUv)"
content={ />
<ChartTooltipContent </AreaChart>
labelFormatter={(_, payload) => { </ResponsiveContainer>
const time = payload?.[0]?.payload?.time; </div>
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => [`${value}%`, "CPU Usage"]}
/>
}
/>
<Area
type="monotone"
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillCpu)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usage: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`CPU Usage: ${payload[0].payload.usage}%`}</p>
</div>
);
}
return null;
};

View File

@@ -1,82 +1,105 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
type ChartConfig, Area,
ChartContainer, AreaChart,
ChartTooltip, CartesianGrid,
ChartTooltipContent, Legend,
} from "@/components/ui/chart"; ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["disk"]; acummulativeData: DockerStatsJSON["disk"];
diskTotal: number; diskTotal: number;
} }
const chartConfig = { export const DockerDiskChart = ({ acummulativeData, diskTotal }: Props) => {
usedGb: { const transformedData = acummulativeData.map((item, index) => {
label: "Used (GB)", return {
color: "hsl(var(--chart-3))", time: item.time,
}, name: `Point ${index + 1}`,
} satisfies ChartConfig; usedGb: +item.value.diskUsage,
totalGb: +item.value.diskTotal,
export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => { freeGb: item.value.diskFree,
const transformedData = accumulativeData.map((item, index) => ({ };
time: item.time, });
name: `Point ${index + 1}`,
usedGb: +item.value.diskUsage,
totalGb: +item.value.diskTotal,
}));
return ( return (
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full"> <div className="mt-6 w-full h-[10rem]">
<AreaChart <ResponsiveContainer>
data={transformedData} <AreaChart
margin={{ top: 10, right: 10, left: 0, bottom: 0 }} data={transformedData}
> margin={{
<defs> top: 10,
<linearGradient id="fillDiskUsed" x1="0" y1="0" x2="0" y2="1"> right: 30,
<stop left: 0,
offset="5%" bottom: 0,
stopColor="var(--color-usedGb)" }}
stopOpacity={0.8} >
/> <defs>
<stop <linearGradient id="colorUsed" x1="0" y1="0" x2="0" y2="1">
offset="95%" <stop offset="5%" stopColor="#6C28D9" stopOpacity={0.8} />
stopColor="var(--color-usedGb)" <stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
stopOpacity={0.1} </linearGradient>
/> <linearGradient id="colorFree" x1="0" y1="0" x2="0" y2="1">
</linearGradient> <stop offset="5%" stopColor="#6C28D9" stopOpacity={0.2} />
</defs> <stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
<CartesianGrid vertical={false} /> </linearGradient>
<YAxis </defs>
domain={[0, diskTotal]} <YAxis stroke="#A1A1AA" domain={[0, diskTotal]} />
tickLine={false} <CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
axisLine={false} {/* @ts-ignore */}
tickFormatter={(value) => `${value} GB`} <Tooltip content={<CustomTooltip />} />
/> <Legend />
<ChartTooltip <Area
cursor={false} type="monotone"
content={ dataKey="usedGb"
<ChartTooltipContent stroke="#6C28D9"
labelFormatter={(_, payload) => { fillOpacity={1}
const time = payload?.[0]?.payload?.time; fill="url(#colorUsed)"
return time ? format(new Date(time), "PPpp") : ""; name="Used GB"
}} />
formatter={(value) => { <Area
return [`${value} GB`, "Used"]; type="monotone"
}} dataKey="freeGb"
/> stroke="#8884d8"
} fillOpacity={1}
/> fill="url(#colorFree)"
<Area name="Free GB"
type="monotone" />
dataKey="usedGb" </AreaChart>
stroke="var(--color-usedGb)" </ResponsiveContainer>
fill="url(#fillDiskUsed)" </div>
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usedGb: number;
freeGb: number;
totalGb: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
<p>{`Disk usage: ${payload[0].payload.usedGb} GB`}</p>
<p>{`Disk free: ${payload[0].payload.freeGb} GB`}</p>
<p>{`Total disk: ${payload[0].payload.totalGb} GB`}</p>
</div>
);
}
return null;
};

View File

@@ -1,182 +0,0 @@
import { Loader2, RefreshCw } from "lucide-react";
import { useMemo } from "react";
import { Cell, Label, Pie, PieChart } from "recharts";
import { Button } from "@/components/ui/button";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { api } from "@/utils/api";
const TYPE_TO_KEY: Record<string, string> = {
Images: "images",
Containers: "containers",
"Local Volumes": "volumes",
"Build Cache": "buildCache",
};
const chartConfig = {
value: {
label: "Size",
},
images: {
label: "Images",
color: "hsl(var(--chart-1))",
},
containers: {
label: "Containers",
color: "hsl(var(--chart-2))",
},
volumes: {
label: "Volumes",
color: "hsl(var(--chart-3))",
},
buildCache: {
label: "Build Cache",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig;
const formatSize = (bytes: number): string => {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
export const DockerDiskUsageChart = () => {
const { data, isLoading, refetch, isRefetching } =
api.settings.getDockerDiskUsage.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const { chartData, totalBytes } = useMemo(() => {
const items =
data
?.filter((item) => item.sizeBytes > 0)
.map((item) => {
const key = TYPE_TO_KEY[item.type] ?? item.type;
return {
name: key,
value: item.sizeBytes,
size: item.size,
active: item.active,
totalCount: item.totalCount,
reclaimable: item.reclaimable,
fill: `var(--color-${key})`,
};
}) ?? [];
return {
chartData: items,
totalBytes: items.reduce((sum, item) => sum + item.value, 0),
};
}, [data]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-[16rem]">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
if (chartData.length === 0) {
return (
<p className="text-xs text-muted-foreground mt-4">
No Docker disk usage data available.
</p>
);
}
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Total: {formatSize(totalBytes)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`size-3.5 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<ChartContainer
config={chartConfig}
className="mx-auto w-full max-h-[250px] [&_.recharts-pie-label-text]:fill-foreground"
>
<PieChart>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => {
const item = chartData.find((d) => d.name === name);
if (!item) return [formatSize(value as number), name];
return [
`${item.size}${item.active} active / ${item.totalCount} total — Reclaimable: ${item.reclaimable}`,
chartConfig[name as keyof typeof chartConfig]?.label ??
name,
];
}}
/>
}
/>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
innerRadius={60}
outerRadius={85}
strokeWidth={3}
stroke="hsl(var(--background))"
minAngle={15}
>
{chartData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) - 8}
className="fill-foreground text-2xl font-bold"
>
{formatSize(totalBytes)}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 14}
className="fill-muted-foreground text-xs"
>
Docker Usage
</tspan>
</text>
);
}
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
</PieChart>
</ChartContainer>
</div>
);
};

View File

@@ -1,87 +1,93 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
type ChartConfig, Area,
ChartContainer, AreaChart,
ChartLegend, CartesianGrid,
ChartLegendContent, Legend,
ChartTooltip, ResponsiveContainer,
ChartTooltipContent, Tooltip,
} from "@/components/ui/chart"; YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
import { convertMemoryToBytes } from "./show-free-container-monitoring"; import { convertMemoryToBytes } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["memory"]; acummulativeData: DockerStatsJSON["memory"];
memoryLimitGB: number; memoryLimitGB: number;
} }
const chartConfig = {
usage: {
label: "Memory (GB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerMemoryChart = ({ export const DockerMemoryChart = ({
accumulativeData, acummulativeData,
memoryLimitGB, memoryLimitGB,
}: Props) => { }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({ const transformedData = acummulativeData.map((item, index) => {
time: item.time, return {
name: `Point ${index + 1}`, time: item.time,
// @ts-ignore name: `Point ${index + 1}`,
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2), // @ts-ignore
})); usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
};
});
return ( return (
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full"> <div className="mt-6 w-full h-[10rem]">
<AreaChart <ResponsiveContainer>
data={transformedData} <AreaChart
margin={{ top: 10, right: 10, left: 0, bottom: 0 }} data={transformedData}
> margin={{
<defs> top: 10,
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1"> right: 30,
<stop left: 0,
offset="5%" bottom: 0,
stopColor="var(--color-usage)" }}
stopOpacity={0.8} >
/> <defs>
<stop <linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
offset="95%" <stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
stopColor="var(--color-usage)" <stop offset="95%" stopColor="white" stopOpacity={0} />
stopOpacity={0.1} </linearGradient>
/> </defs>
</linearGradient> <YAxis stroke="#A1A1AA" domain={[0, +memoryLimitGB.toFixed(2)]} />
</defs> <CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
<CartesianGrid vertical={false} /> {/* @ts-ignore */}
<YAxis <Tooltip content={<CustomTooltip />} />
tickFormatter={(value) => `${value} GB`} <Legend />
domain={[0, +memoryLimitGB.toFixed(2)]} <Area
tickLine={false} type="monotone"
axisLine={false} dataKey="usage"
/> stroke="#27272A"
<ChartTooltip fillOpacity={1}
cursor={false} fill="url(#colorUv)"
content={ />
<ChartTooltipContent </AreaChart>
labelFormatter={(_, payload) => { </ResponsiveContainer>
const time = payload?.[0]?.payload?.time; </div>
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => [`${value} GB`, "Memory"]}
/>
}
/>
<Area
type="monotone"
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillMemory)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usage: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0] && payload[0].payload) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Memory usage: ${payload[0].payload.usage} GB`}</p>
</div>
);
}
return null;
};

View File

@@ -1,99 +1,99 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
type ChartConfig, Area,
ChartContainer, AreaChart,
ChartLegend, CartesianGrid,
ChartLegendContent, Legend,
ChartTooltip, ResponsiveContainer,
ChartTooltipContent, Tooltip,
} from "@/components/ui/chart"; YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["network"]; acummulativeData: DockerStatsJSON["network"];
} }
const chartConfig = { export const DockerNetworkChart = ({ acummulativeData }: Props) => {
inMB: { const transformedData = acummulativeData.map((item, index) => {
label: "In (MB)", return {
color: "hsl(var(--chart-1))", time: item.time,
}, name: `Point ${index + 1}`,
outMB: { inMB: item.value.inputMb,
label: "Out (MB)", outMB: item.value.outputMb,
color: "hsl(var(--chart-2))", };
}, });
} satisfies ChartConfig;
export const DockerNetworkChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb,
outMB: item.value.outputMb,
}));
return ( return (
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full"> <div className="mt-6 w-full h-[10rem]">
<AreaChart <ResponsiveContainer>
data={transformedData} <AreaChart
margin={{ top: 10, right: 10, left: 0, bottom: 0 }} data={transformedData}
> margin={{
<defs> top: 10,
<linearGradient id="fillNetIn" x1="0" y1="0" x2="0" y2="1"> right: 30,
<stop offset="5%" stopColor="var(--color-inMB)" stopOpacity={0.8} /> left: 0,
<stop bottom: 0,
offset="95%" }}
stopColor="var(--color-inMB)" >
stopOpacity={0.1} <defs>
/> <linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
</linearGradient> <stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<linearGradient id="fillNetOut" x1="0" y1="0" x2="0" y2="1"> <stop offset="95%" stopColor="white" stopOpacity={0} />
<stop </linearGradient>
offset="5%" </defs>
stopColor="var(--color-outMB)" <YAxis stroke="#A1A1AA" />
stopOpacity={0.8} <CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
/> {/* @ts-ignore */}
<stop <Tooltip content={<CustomTooltip />} />
offset="95%" <Legend />
stopColor="var(--color-outMB)" <Area
stopOpacity={0.1} type="monotone"
/> dataKey="inMB"
</linearGradient> stroke="#8884d8"
</defs> fillOpacity={1}
<CartesianGrid vertical={false} /> fill="url(#colorUv)"
<YAxis tickLine={false} axisLine={false} /> name="In MB"
<ChartTooltip />
cursor={false} <Area
content={ type="monotone"
<ChartTooltipContent dataKey="outMB"
labelFormatter={(_, payload) => { stroke="#82ca9d"
const time = payload?.[0]?.payload?.time; fillOpacity={1}
return time ? format(new Date(time), "PPpp") : ""; fill="url(#colorUv)"
}} name="Out MB"
formatter={(value, name) => { />
const label = name === "inMB" ? "In" : "Out"; </AreaChart>
return [`${value} MB`, label]; </ResponsiveContainer>
}} </div>
/>
}
/>
<Area
type="monotone"
dataKey="inMB"
stroke="var(--color-inMB)"
fill="url(#fillNetIn)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="outMB"
stroke="var(--color-outMB)"
fill="url(#fillNetOut)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
inMB: number;
outMB: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`In Usage: ${payload[0].payload.inMB} `}</p>
<p>{`Out Usage: ${payload[0].payload.outMB} `}</p>
</div>
);
}
return null;
};

View File

@@ -5,7 +5,6 @@ import { api } from "@/utils/api";
import { DockerBlockChart } from "./docker-block-chart"; import { DockerBlockChart } from "./docker-block-chart";
import { DockerCpuChart } from "./docker-cpu-chart"; import { DockerCpuChart } from "./docker-cpu-chart";
import { DockerDiskChart } from "./docker-disk-chart"; import { DockerDiskChart } from "./docker-disk-chart";
import { DockerDiskUsageChart } from "./docker-disk-usage-chart";
import { DockerMemoryChart } from "./docker-memory-chart"; import { DockerMemoryChart } from "./docker-memory-chart";
import { DockerNetworkChart } from "./docker-network-chart"; import { DockerNetworkChart } from "./docker-network-chart";
@@ -125,7 +124,7 @@ export const ContainerFreeMonitoring = ({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, },
); );
const [accumulativeData, setAccumulativeData] = useState<DockerStatsJSON>({ const [acummulativeData, setAcummulativeData] = useState<DockerStatsJSON>({
cpu: [], cpu: [],
memory: [], memory: [],
block: [], block: [],
@@ -137,7 +136,7 @@ export const ContainerFreeMonitoring = ({
useEffect(() => { useEffect(() => {
setCurrentData(defaultData); setCurrentData(defaultData);
setAccumulativeData({ setAcummulativeData({
cpu: [], cpu: [],
memory: [], memory: [],
block: [], block: [],
@@ -156,7 +155,7 @@ export const ContainerFreeMonitoring = ({
network: data.network[data.network.length - 1] ?? currentData.network, network: data.network[data.network.length - 1] ?? currentData.network,
disk: data.disk[data.disk.length - 1] ?? currentData.disk, disk: data.disk[data.disk.length - 1] ?? currentData.disk,
}); });
setAccumulativeData({ setAcummulativeData({
block: data?.block || [], block: data?.block || [],
cpu: data?.cpu || [], cpu: data?.cpu || [],
disk: data?.disk || [], disk: data?.disk || [],
@@ -185,7 +184,7 @@ export const ContainerFreeMonitoring = ({
setCurrentData(data); setCurrentData(data);
const MAX_DATA_POINTS = 300; const MAX_DATA_POINTS = 300;
setAccumulativeData((prevData) => ({ setAcummulativeData((prevData) => ({
cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS), cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS),
memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS), memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS),
block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS), block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS),
@@ -220,16 +219,16 @@ export const ContainerFreeMonitoring = ({
<CardContent> <CardContent>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Used: {String(currentData.cpu.value ?? "0%")} Used: {currentData.cpu.value}
</span> </span>
<Progress <Progress
value={Number.parseInt( value={Number.parseInt(
String(currentData.cpu.value ?? "0%").replace("%", ""), currentData.cpu.value.replace("%", ""),
10, 10,
)} )}
className="w-[100%]" className="w-[100%]"
/> />
<DockerCpuChart accumulativeData={accumulativeData.cpu} /> <DockerCpuChart acummulativeData={acummulativeData.cpu} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -253,7 +252,7 @@ export const ContainerFreeMonitoring = ({
className="w-[100%]" className="w-[100%]"
/> />
<DockerMemoryChart <DockerMemoryChart
accumulativeData={accumulativeData.memory} acummulativeData={acummulativeData.memory}
memoryLimitGB={ memoryLimitGB={
// @ts-ignore // @ts-ignore
convertMemoryToBytes(currentData.memory.value.total) / convertMemoryToBytes(currentData.memory.value.total) /
@@ -278,25 +277,13 @@ export const ContainerFreeMonitoring = ({
className="w-[100%]" className="w-[100%]"
/> />
<DockerDiskChart <DockerDiskChart
accumulativeData={accumulativeData.disk} acummulativeData={acummulativeData.disk}
diskTotal={currentData.disk.value.diskTotal} diskTotal={currentData.disk.value.diskTotal}
/> />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{appName === "dokploy" && (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Docker Disk Usage
</CardTitle>
</CardHeader>
<CardContent>
<DockerDiskUsageChart />
</CardContent>
</Card>
)}
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -307,7 +294,7 @@ export const ContainerFreeMonitoring = ({
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `} {`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
</span> </span>
<DockerBlockChart accumulativeData={accumulativeData.block} /> <DockerBlockChart acummulativeData={acummulativeData.block} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -320,7 +307,7 @@ export const ContainerFreeMonitoring = ({
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `} {`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
</span> </span>
<DockerNetworkChart accumulativeData={accumulativeData.network} /> <DockerNetworkChart acummulativeData={acummulativeData.network} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,6 +1,4 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -11,9 +9,6 @@ interface Props {
} }
export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => { export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
const { data } = api.mysql.one.useQuery({ mysqlId }); const { data } = api.mysql.one.useQuery({ mysqlId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mysql.changePassword.useMutation();
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
@@ -33,43 +28,20 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={data?.databasePassword} value={data?.databasePassword}
/> />
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mysqlId,
password: newPassword,
type: "user",
});
toast.success("Password updated successfully");
utils.mysql.one.invalidate({ mysqlId });
}}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Root Password</Label> <Label>Root Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={data?.databaseRootPassword} value={data?.databaseRootPassword}
/> />
<UpdateDatabasePassword
label="Root Password"
onUpdatePassword={async (newPassword) => {
await changePassword({
mysqlId,
password: newPassword,
type: "root",
});
toast.success("Root password updated successfully");
utils.mysql.one.invalidate({ mysqlId });
}}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View File

@@ -1,6 +1,4 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -11,9 +9,6 @@ interface Props {
} }
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => { export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
const { data } = api.postgres.one.useQuery({ postgresId }); const { data } = api.postgres.one.useQuery({ postgresId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.postgres.changePassword.useMutation();
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
@@ -33,21 +28,11 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
value={data?.databasePassword} value={data?.databasePassword}
disabled disabled
/> />
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
postgresId,
password: newPassword,
});
toast.success("Password updated successfully");
utils.postgres.one.invalidate({ postgresId });
}}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View File

@@ -43,7 +43,6 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug"; import { slugify } from "@/lib/slug";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddTemplateSchema = z.object({ const AddTemplateSchema = z.object({
name: z.string().min(1, { name: z.string().min(1, {
@@ -54,8 +53,9 @@ const AddTemplateSchema = z.object({
.min(1, { .min(1, {
message: "App name is required", message: "App name is required",
}) })
.regex(APP_NAME_REGEX, { .regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message: APP_NAME_MESSAGE, message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}), }),
description: z.string().optional(), description: z.string().optional(),
serverId: z.string().optional(), serverId: z.string().optional(),

View File

@@ -43,7 +43,6 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug"; import { slugify } from "@/lib/slug";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddComposeSchema = z.object({ const AddComposeSchema = z.object({
composeType: z.enum(["docker-compose", "stack"]).optional(), composeType: z.enum(["docker-compose", "stack"]).optional(),
@@ -55,8 +54,9 @@ const AddComposeSchema = z.object({
.min(1, { .min(1, {
message: "App name is required", message: "App name is required",
}) })
.regex(APP_NAME_REGEX, { .regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message: APP_NAME_MESSAGE, message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}), }),
description: z.string().optional(), description: z.string().optional(),
serverId: z.string().optional(), serverId: z.string().optional(),
@@ -78,6 +78,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
const { mutateAsync, isPending, error, isError } = const { mutateAsync, isPending, error, isError } =
api.compose.create.useMutation(); api.compose.create.useMutation();
// Get environment data to extract projectId
// const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0; const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment // Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option) // Cloud: show only if there are remote servers (no Dokploy option)

View File

@@ -52,13 +52,12 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug"; import { slugify } from "@/lib/slug";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
type DbType = z.infer<typeof mySchema>["type"]; type DbType = z.infer<typeof mySchema>["type"];
const dockerImageDefaultPlaceholder: Record<DbType, string> = { const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mongo: "mongo:8",
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32", libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
mongo: "mongo:7",
mariadb: "mariadb:11", mariadb: "mariadb:11",
mysql: "mysql:8", mysql: "mysql:8",
postgres: "postgres:18", postgres: "postgres:18",
@@ -83,8 +82,9 @@ const baseDatabaseSchema = z.object({
.min(1, { .min(1, {
message: "App name is required", message: "App name is required",
}) })
.regex(APP_NAME_REGEX, { .regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message: APP_NAME_MESSAGE, message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}), }),
databasePassword: z databasePassword: z
.string() .string()
@@ -632,6 +632,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
control={form.control} control={form.control}
name="enableNamespaces" name="enableNamespaces"
render={({ field }) => { render={({ field }) => {
console.log(field.value);
return ( return (
<FormItem> <FormItem>
<FormLabel>Enable Namespaces</FormLabel> <FormLabel>Enable Namespaces</FormLabel>

View File

@@ -1,494 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Code2, FileInput, Globe2, HardDrive, HelpCircle } from "lucide-react";
import { 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 { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddImportSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
appName: z
.string()
.min(1, { message: "App name is required" })
.regex(APP_NAME_REGEX, { message: APP_NAME_MESSAGE }),
base64: z.string().min(1, { message: "Base64 content is required" }),
serverId: z.string().optional(),
});
type AddImport = z.infer<typeof AddImportSchema>;
type TemplateInfo = {
compose: string;
template: {
domains: Array<{
serviceName: string;
port: number;
path?: string;
host?: string;
}>;
envs: string[];
mounts: Array<{ filePath: string; content: string }>;
};
};
interface Props {
environmentId: string;
projectName?: string;
}
export const AddImport = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [mountOpen, setMountOpen] = useState(false);
const [selectedMount, setSelectedMount] = useState<{
filePath: string;
content: string;
} | null>(null);
const [templateInfo, setTemplateInfo] = useState<TemplateInfo | null>(null);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const shouldShowServerDropdown = !!(servers && servers.length > 0);
const { mutateAsync: previewTemplate, isPending: isProcessing } =
api.compose.previewTemplate.useMutation();
const { mutateAsync: createCompose, isPending: isCreating } =
api.compose.create.useMutation();
const { mutateAsync: importCompose, isPending: isImporting } =
api.compose.import.useMutation();
const form = useForm<AddImport>({
defaultValues: { name: "", appName: `${slug}-`, base64: "" },
resolver: zodResolver(AddImportSchema),
});
const resetAll = () => {
form.reset({ name: "", appName: `${slug}-`, base64: "" });
setTemplateInfo(null);
setPreviewOpen(false);
setMountOpen(false);
setSelectedMount(null);
};
const handleOpenChange = (open: boolean) => {
if (!open) resetAll();
setVisible(open);
};
const handleLoad = async (data: AddImport) => {
try {
const result = await previewTemplate({
appName: data.appName,
base64: data.base64.trim(),
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
});
setTemplateInfo(result);
setPreviewOpen(true);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error processing template",
);
}
};
const handleImport = async () => {
const data = form.getValues();
try {
const compose = await createCompose({
name: data.name,
appName: data.appName,
environmentId,
composeType: "docker-compose",
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
});
await importCompose({
composeId: compose.composeId,
base64: data.base64.trim(),
});
toast.success("Compose imported successfully");
await utils.environment.one.invalidate({ environmentId });
resetAll();
setVisible(false);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error importing compose",
);
}
};
const handleCancelPreview = () => {
setPreviewOpen(false);
setTemplateInfo(null);
};
return (
<>
<Dialog open={visible} onOpenChange={handleOpenChange}>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<FileInput className="size-4 text-muted-foreground" />
<span>Import</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Import Compose</DialogTitle>
<DialogDescription>
Paste a base64-encoded compose export to preview and import it
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-import"
onSubmit={form.handleSubmit(handleLoad)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="My App"
{...field}
onChange={(e) => {
const val = e.target.value || "";
form.setValue(
"appName",
`${slug}-${slugify(val.trim())}`,
);
field.onChange(val);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
<TooltipContent
className="z-[999] w-[300px]"
align="start"
side="top"
>
<span>
If no server is selected, the compose will be
deployed on the server where the user is logged
in.
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.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 self-center">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Servers (
{(servers?.length ?? 0) + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>App Name</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base64"
render={({ field }) => (
<FormItem>
<FormLabel>Configuration (Base64)</FormLabel>
<FormControl>
<Textarea
placeholder="Paste your base64-encoded compose export here..."
className="font-mono resize-none h-32"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="outline"
isLoading={isCreating || isProcessing}
>
Load
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Preview modal */}
<Dialog
open={previewOpen}
onOpenChange={(open) => !open && handleCancelPreview()}
>
<DialogContent className="max-w-[60vw]">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
</DialogTitle>
<DialogDescription className="space-y-2">
<p>Review the template information before importing</p>
<AlertBlock type="warning">
Warning: This will remove all existing environment variables,
mounts, and domains from this service.
</AlertBlock>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Docker Compose</h3>
</div>
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
{templateInfo?.template.domains &&
templateInfo.template.domains.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Domains</h3>
</div>
<div className="grid grid-cols-1 gap-3">
{templateInfo.template.domains.map((domain, index) => (
<div
key={index}
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
>
<div className="font-medium">
{domain.serviceName}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div>Port: {domain.port}</div>
{domain.host && <div>Host: {domain.host}</div>}
{domain.path && <div>Path: {domain.path}</div>}
</div>
</div>
))}
</div>
</div>
</>
)}
{templateInfo?.template.envs &&
templateInfo.template.envs.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">
Environment Variables
</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.envs.map((env, index) => (
<div
key={index}
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
>
{env}
</div>
))}
</div>
</div>
</>
)}
{templateInfo?.template.mounts &&
templateInfo.template.mounts.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Mounts</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.mounts.map((mount, index) => (
<div
key={index}
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
onClick={() => {
setSelectedMount(mount);
setMountOpen(true);
}}
>
{mount.filePath}
</div>
))}
</div>
</div>
</>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={handleCancelPreview}>
Cancel
</Button>
<Button isLoading={isImporting} onClick={handleImport}>
Import
</Button>
</div>
</DialogContent>
</Dialog>
{/* Mount content modal */}
<Dialog open={mountOpen} onOpenChange={setMountOpen}>
<DialogContent className="max-w-[50vw]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{selectedMount?.filePath}
</DialogTitle>
<DialogDescription>Mount File Content</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[45vh] pr-4">
<CodeEditor
language="yaml"
value={selectedMount?.content || ""}
className="font-mono"
readOnly
/>
</ScrollArea>
<div className="flex justify-end gap-2 pt-4">
<Button onClick={() => setMountOpen(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -1,5 +1,4 @@
import { import {
Bookmark,
BookText, BookText,
CheckIcon, CheckIcon,
ChevronsUpDown, ChevronsUpDown,
@@ -83,7 +82,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed"); const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [showBookmarksOnly, setShowBookmarksOnly] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => { const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
// Try to get from props first, then localStorage // Try to get from props first, then localStorage
if (baseUrl) return baseUrl; if (baseUrl) return baseUrl;
@@ -124,45 +122,8 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
enabled: open, enabled: open,
}, },
); );
const { data: bookmarkIds = [], isLoading: isLoadingBookmarks } =
api.user.getBookmarkedTemplates.useQuery(undefined, {
enabled: open,
});
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync: toggleBookmark } =
api.user.toggleTemplateBookmark.useMutation({
onMutate: async ({ templateId }) => {
await utils.user.getBookmarkedTemplates.cancel();
const previousBookmarks = utils.user.getBookmarkedTemplates.getData();
utils.user.getBookmarkedTemplates.setData(undefined, (old = []) => {
if (old.includes(templateId)) {
return old.filter((id) => id !== templateId);
}
return [...old, templateId];
});
return { previousBookmarks };
},
onError: (err, variables, context) => {
if (context?.previousBookmarks) {
utils.user.getBookmarkedTemplates.setData(
undefined,
context.previousBookmarks,
);
}
toast.error("Failed to update bookmark");
},
onSuccess: (data) => {
toast.success(
data.isBookmarked ? "Added to bookmarks" : "Removed from bookmarks",
);
},
});
const [serverId, setServerId] = useState<string | undefined>(undefined); const [serverId, setServerId] = useState<string | undefined>(undefined);
const { mutateAsync, isPending, error, isError } = const { mutateAsync, isPending, error, isError } =
api.compose.deployTemplate.useMutation(); api.compose.deployTemplate.useMutation();
@@ -176,9 +137,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
query === "" || query === "" ||
template.name.toLowerCase().includes(query.toLowerCase()) || template.name.toLowerCase().includes(query.toLowerCase()) ||
template.description.toLowerCase().includes(query.toLowerCase()); template.description.toLowerCase().includes(query.toLowerCase());
const matchesBookmarks = return matchesTags && matchesQuery;
!showBookmarksOnly || bookmarkIds.includes(template.id);
return matchesTags && matchesQuery && matchesBookmarks;
}) || []; }) || [];
const hasServers = servers && servers.length > 0; const hasServers = servers && servers.length > 0;
@@ -187,14 +146,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers) // Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers; const shouldShowServerDropdown = hasServers;
const handleToggleBookmark = async (
e: React.MouseEvent,
templateId: string,
) => {
e.stopPropagation();
await toggleBookmark({ templateId });
};
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="w-full"> <DialogTrigger className="w-full">
@@ -292,20 +243,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button
variant={showBookmarksOnly ? "default" : "outline"}
size="icon"
onClick={() => setShowBookmarksOnly(!showBookmarksOnly)}
className="h-9 w-9 flex-shrink-0"
disabled={isLoadingBookmarks}
>
<Bookmark
className={cn(
"size-4",
showBookmarksOnly && "fill-current",
)}
/>
</Button>
<Button <Button
size="icon" size="icon"
onClick={() => onClick={() =>
@@ -362,19 +299,11 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</div> </div>
</div> </div>
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<div className="flex flex-col justify-center items-center w-full gap-2 min-h-[50vh]"> <div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
<SearchIcon className="text-muted-foreground size-6" /> <SearchIcon className="text-muted-foreground size-6" />
<div className="text-xl font-medium text-muted-foreground"> <div className="text-xl font-medium text-muted-foreground">
{showBookmarksOnly No templates found
? "No bookmarked templates found"
: "No templates found"}
</div> </div>
{showBookmarksOnly && (
<p className="text-sm text-muted-foreground">
Click the bookmark icon on templates to add them to
bookmarks
</p>
)}
</div> </div>
) : ( ) : (
<div <div
@@ -394,25 +323,9 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
viewMode === "detailed" && "h-[400px]", viewMode === "detailed" && "h-[400px]",
)} )}
> >
<div className="absolute top-2 left-2 z-10"> <Badge className="absolute top-2 right-2" variant="blue">
<Button {template?.version}
variant="ghost" </Badge>
size="icon"
className="h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background"
onClick={(e) => handleToggleBookmark(e, template.id)}
>
<Bookmark
className={cn(
"size-4",
bookmarkIds.includes(template.id) &&
"fill-yellow-400 text-yellow-400",
)}
/>
</Button>
</div>
<div className="absolute top-2 right-2">
<Badge variant="blue">{template?.version}</Badge>
</div>
<div <div
className={cn( className={cn(
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30", "flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",

View File

@@ -298,19 +298,7 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 w-full justify-end"> <div className="flex items-center gap-2 w-full justify-end">
<Button <Button
onClick={() => { onClick={stepper.prev}
if (
stepper.current.id === "variant" &&
templateInfo.details
) {
setTemplateInfo((prev) => ({
...prev,
details: null,
}));
return;
}
stepper.prev();
}}
disabled={stepper.isFirst} disabled={stepper.isFirst}
variant="secondary" variant="secondary"
> >

View File

@@ -88,12 +88,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ( if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
(e.ctrlKey || e.metaKey) &&
e.code === "KeyS" &&
!isPending &&
isOpen
) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }

View File

@@ -87,12 +87,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ( if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
(e.ctrlKey || e.metaKey) &&
e.code === "KeyS" &&
!isPending &&
isOpen
) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }

View File

@@ -166,7 +166,6 @@ export const ShowProjects = () => {
return ( return (
total + total +
(env.applications?.length || 0) + (env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) + (env.mariadb?.length || 0) +
(env.mongo?.length || 0) + (env.mongo?.length || 0) +
(env.mysql?.length || 0) + (env.mysql?.length || 0) +
@@ -179,7 +178,6 @@ export const ShowProjects = () => {
return ( return (
total + total +
(env.applications?.length || 0) + (env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) + (env.mariadb?.length || 0) +
(env.mongo?.length || 0) + (env.mongo?.length || 0) +
(env.mysql?.length || 0) + (env.mysql?.length || 0) +
@@ -344,7 +342,7 @@ export const ShowProjects = () => {
} }
}} }}
> >
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border flex flex-col"> <Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between gap-2 overflow-clip"> <CardTitle className="flex items-center justify-between gap-2 overflow-clip">
<span className="flex flex-col gap-1.5 "> <span className="flex flex-col gap-1.5 ">
@@ -491,7 +489,7 @@ export const ShowProjects = () => {
</div> </div>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardFooter className="pt-4 mt-auto"> <CardFooter className="pt-4">
<div className="space-y-1 text-xs flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4"> <div className="space-y-1 text-xs flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}> <DateTooltip date={project.createdAt}>
Created Created

View File

@@ -1,6 +1,4 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -11,9 +9,6 @@ interface Props {
} }
export const ShowInternalRedisCredentials = ({ redisId }: Props) => { export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
const { data } = api.redis.one.useQuery({ redisId }); const { data } = api.redis.one.useQuery({ redisId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.redis.changePassword.useMutation();
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
@@ -29,21 +24,11 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
value={data?.databasePassword} value={data?.databasePassword}
disabled disabled
/> />
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
redisId,
password: newPassword,
});
toast.success("Password updated successfully");
utils.redis.one.invalidate({ redisId });
}}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View File

@@ -79,11 +79,8 @@ export const columns: ColumnDef<LogEntry>[] = [
: log.RequestPath} : log.RequestPath}
</div> </div>
<div className="flex flex-row gap-3 w-full"> <div className="flex flex-row gap-3 w-full">
<Badge <Badge variant={getStatusColor(log.OriginStatus)}>
variant={getStatusColor(log.OriginStatus || log.DownstreamStatus)} Status: {formatStatusLabel(log.OriginStatus)}
>
Status:{" "}
{formatStatusLabel(log.OriginStatus || log.DownstreamStatus)}
</Badge> </Badge>
<Badge variant={"secondary"}> <Badge variant={"secondary"}>
Exec Time: {formatDuration(log.Duration)} Exec Time: {formatDuration(log.Duration)}

View File

@@ -185,7 +185,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
<div className="flex flex-col gap-4 w-full overflow-auto"> <div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap"> <div className="flex items-center gap-2 max-sm:flex-wrap">
<Input <Input
placeholder="Filter by hostname..." placeholder="Filter by name..."
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
className="md:max-w-sm" className="md:max-w-sm"

View File

@@ -63,7 +63,7 @@ export const SearchCommand = () => {
React.useEffect(() => { React.useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
if (e.code === "KeyJ" && (e.metaKey || e.ctrlKey)) { if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
setOpen((open) => !open); setOpen((open) => !open);
} }
@@ -167,7 +167,7 @@ export const SearchCommand = () => {
<CommandGroup heading={"Application"} hidden={true}> <CommandGroup heading={"Application"} hidden={true}>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/home"); router.push("/dashboard/projects");
setOpen(false); setOpen(false);
}} }}
> >

View File

@@ -2,14 +2,12 @@ import { loadStripe } from "@stripe/stripe-js";
import clsx from "clsx"; import clsx from "clsx";
import { import {
AlertTriangle, AlertTriangle,
Bell,
CheckIcon, CheckIcon,
CreditCard, CreditCard,
FileText, FileText,
Loader2, Loader2,
MinusIcon, MinusIcon,
PlusIcon, PlusIcon,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -25,18 +23,8 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { NumberInput } from "@/components/ui/input"; import { NumberInput } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
@@ -101,8 +89,6 @@ export const ShowBilling = () => {
api.stripe.createCustomerPortalSession.useMutation(); api.stripe.createCustomerPortalSession.useMutation();
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } = const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
api.stripe.upgradeSubscription.useMutation(); api.stripe.upgradeSubscription.useMutation();
const { mutateAsync: updateInvoiceNotifications } =
api.stripe.updateInvoiceNotifications.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1); const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
@@ -155,7 +141,6 @@ export const ShowBilling = () => {
return isAnnual ? interval === "year" : interval === "month"; return isAnnual ? interval === "year" : interval === "month";
}); });
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
const maxServers = admin?.user.serversQuantity ?? 1; const maxServers = admin?.user.serversQuantity ?? 1;
const percentage = ((servers ?? 0) / maxServers) * 100; const percentage = ((servers ?? 0) / maxServers) * 100;
const safePercentage = Math.min(percentage, 100); const safePercentage = Math.min(percentage, 100);
@@ -164,66 +149,14 @@ export const ShowBilling = () => {
<div className="w-full"> <div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto"> <Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
<div className="rounded-xl bg-background shadow-md"> <div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row items-start justify-between"> <CardHeader>
<div> <CardTitle className="text-xl flex flex-row gap-2">
<CardTitle className="text-xl flex flex-row gap-2"> <CreditCard className="size-6 text-muted-foreground self-center" />
<CreditCard className="size-6 text-muted-foreground self-center" /> Billing
Billing </CardTitle>
</CardTitle> <CardDescription>
<CardDescription> Manage your subscription and invoices
Manage your subscription and invoices </CardDescription>
</CardDescription>
</div>
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Bell className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Notification Settings</DialogTitle>
<DialogDescription>
Configure your billing email notifications.
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="invoice-notifications">
Invoice Notifications
</Label>
<p className="text-sm text-muted-foreground">
Receive email notifications for payments and failed
charges.
</p>
</div>
<Switch
id="invoice-notifications"
checked={admin?.user.sendInvoiceNotifications ?? false}
onCheckedChange={async (checked) => {
await updateInvoiceNotifications({
enabled: checked,
})
.then(() => {
utils.user.get.invalidate();
toast.success(
checked
? "Invoice notifications enabled"
: "Invoice notifications disabled",
);
})
.catch(() => {
toast.error(
"Failed to update invoice notifications",
);
});
}}
/>
</div>
</DialogContent>
</Dialog>
)}
</CardHeader> </CardHeader>
<CardContent className="space-y-4 py-4 border-t"> <CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b"> <nav className="flex space-x-2 border-b">
@@ -249,7 +182,7 @@ export const ShowBilling = () => {
</nav> </nav>
<div className="flex flex-col gap-4 w-full mt-6"> <div className="flex flex-col gap-4 w-full mt-6">
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && ( {admin?.user.stripeSubscriptionId && (
<div className="space-y-2 flex flex-col"> <div className="space-y-2 flex flex-col">
<h3 className="text-lg font-medium">Servers Plan</h3> <h3 className="text-lg font-medium">Servers Plan</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -270,36 +203,8 @@ export const ShowBilling = () => {
)} )}
</div> </div>
)} )}
{isEnterpriseCloud && (
<div className="flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
<ShieldCheck className="h-6 w-6 text-primary shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold text-foreground">
Enterprise Cloud Plan
</h3>
<p className="text-sm text-muted-foreground">
Your organization is on a managed Enterprise plan. Billing
is handled separately contact your account manager for
any changes.
</p>
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-fit mt-2"
onClick={async () => {
const session = await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
)}
</div>
</div>
)}
{/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */} {/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */}
{!isEnterpriseCloud && {useNewPricing &&
useNewPricing &&
data?.currentPlan === "legacy" && data?.currentPlan === "legacy" &&
data?.subscriptions?.length > 0 && ( data?.subscriptions?.length > 0 && (
<div className="rounded-xl border border-border bg-primary/5 p-4 space-y-4 max-w-2xl"> <div className="rounded-xl border border-border bg-primary/5 p-4 space-y-4 max-w-2xl">
@@ -489,8 +394,7 @@ export const ShowBilling = () => {
</div> </div>
)} )}
{/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */} {/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */}
{!isEnterpriseCloud && {useNewPricing &&
useNewPricing &&
(data?.currentPlan === "hobby" || (data?.currentPlan === "hobby" ||
data?.currentPlan === "startup") && data?.currentPlan === "startup") &&
data?.subscriptions?.length > 0 && ( data?.subscriptions?.length > 0 && (
@@ -875,18 +779,17 @@ export const ShowBilling = () => {
Manage Subscription Manage Subscription
</Button> </Button>
)} )}
{!isEnterpriseCloud && {(data?.subscriptions?.length ?? 0) === 0 && (
(data?.subscriptions?.length ?? 0) === 0 && ( <Button
<Button className="w-full"
className="w-full" onClick={() =>
onClick={() => handleCheckout("hobby", data!.hobbyProductId!)
handleCheckout("hobby", data!.hobbyProductId!) }
} disabled={hobbyServerQuantity < 1}
disabled={hobbyServerQuantity < 1} >
> Get Started
Get Started </Button>
</Button> )}
)}
</div> </div>
</div> </div>
</section> </section>
@@ -1020,24 +923,22 @@ export const ShowBilling = () => {
Manage Subscription Manage Subscription
</Button> </Button>
)} )}
{!isEnterpriseCloud && {(data?.subscriptions?.length ?? 0) === 0 && (
(data?.subscriptions?.length ?? 0) === 0 && ( <Button
<Button className="w-full"
className="w-full" onClick={() =>
onClick={() => handleCheckout(
handleCheckout( "startup",
"startup", data!.startupProductId!,
data!.startupProductId!, )
) }
} disabled={
disabled={ startupServerQuantity < STARTUP_SERVERS_INCLUDED
startupServerQuantity < }
STARTUP_SERVERS_INCLUDED >
} Get Started
> </Button>
Get Started )}
</Button>
)}
</div> </div>
</div> </div>
</section> </section>
@@ -1242,18 +1143,17 @@ export const ShowBilling = () => {
Manage Subscription Manage Subscription
</Button> </Button>
)} )}
{!isEnterpriseCloud && {(data?.subscriptions?.length ?? 0) === 0 && (
(data?.subscriptions?.length ?? 0) === 0 && ( <Button
<Button className="w-full"
className="w-full" onClick={async () => {
onClick={async () => { handleCheckout("legacy", product.id);
handleCheckout("legacy", product.id); }}
}} disabled={hobbyServerQuantity < 1}
disabled={hobbyServerQuantity < 1} >
> Subscribe
Subscribe </Button>
</Button> )}
)}
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, PlusIcon, SquarePen } from "lucide-react"; import { HelpCircle, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -47,157 +47,108 @@ const certificateDataHolder =
const privateKeyDataHolder = const privateKeyDataHolder =
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----"; "-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
const handleCertificateSchema = z.object({ const addCertificate = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
certificateData: z.string().min(1, "Certificate data is required"), certificateData: z.string().min(1, "Certificate data is required"),
privateKey: z.string().min(1, "Private key is required"), privateKey: z.string().min(1, "Private key is required"),
autoRenew: z.boolean().optional(),
serverId: z.string().optional(), serverId: z.string().optional(),
}); });
type HandleCertificateForm = z.infer<typeof handleCertificateSchema>; type AddCertificate = z.infer<typeof addCertificate>;
interface Props { export const AddCertificate = () => {
certificateId?: string;
}
export const HandleCertificate = ({ certificateId }: Props) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync, isError, error, isPending } =
api.certificates.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0; const hasServers = servers && servers.length > 0;
const shouldShowServerDropdown = hasServers && !certificateId; // Hide on edit // Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const { data: existingCert, refetch } = api.certificates.one.useQuery( const form = useForm<AddCertificate>({
{ certificateId: certificateId || "" },
{ enabled: !!certificateId },
);
const createMutation = api.certificates.create.useMutation();
const updateMutation = api.certificates.update.useMutation();
const mutation = certificateId ? updateMutation : createMutation;
const { mutateAsync, isError, error, isPending } = mutation;
const form = useForm<HandleCertificateForm>({
defaultValues: { defaultValues: {
name: "", name: "",
certificateData: "", certificateData: "",
privateKey: "", privateKey: "",
autoRenew: false,
}, },
resolver: zodResolver(handleCertificateSchema), resolver: zodResolver(addCertificate),
}); });
useEffect(() => { useEffect(() => {
if (existingCert) { form.reset();
form.reset({ }, [form, form.formState.isSubmitSuccessful, form.reset]);
name: existingCert.name,
certificateData: existingCert.certificateData,
privateKey: existingCert.privateKey,
});
} else {
form.reset({
name: "",
certificateData: "",
privateKey: "",
});
}
}, [existingCert, form, open]);
const onSubmit = async (data: HandleCertificateForm) => { const onSubmit = async (data: AddCertificate) => {
const basePayload = { await mutateAsync({
name: data.name, name: data.name,
certificateData: data.certificateData, certificateData: data.certificateData,
privateKey: data.privateKey, privateKey: data.privateKey,
}; autoRenew: data.autoRenew,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
const promise = certificateId organizationId: "",
? updateMutation.mutateAsync({ })
certificateId,
...basePayload,
})
: createMutation.mutateAsync({
...basePayload,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
organizationId: "",
});
await promise
.then(async () => { .then(async () => {
toast.success( toast.success("Certificate Created");
certificateId ? "Certificate Updated" : "Certificate Created",
);
await utils.certificates.all.invalidate(); await utils.certificates.all.invalidate();
if (certificateId) {
refetch();
}
setOpen(false); setOpen(false);
}) })
.catch(() => { .catch(() => {
toast.error( toast.error("Error creating the Certificate");
certificateId
? "Error updating the Certificate"
: "Error creating the Certificate",
);
}); });
}; };
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger className="" asChild>
{certificateId ? ( <Button>
<Button {" "}
variant="ghost" <PlusIcon className="h-4 w-4" />
size="icon" Add Certificate
className="group hover:bg-blue-500/10" </Button>
>
<SquarePen className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button>
<PlusIcon className="h-4 w-4" />
Add Certificate
</Button>
)}
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>Add New Certificate</DialogTitle>
{certificateId ? "Update" : "Add New"} Certificate
</DialogTitle>
<DialogDescription> <DialogDescription>
{certificateId Upload or generate a certificate to secure your application
? "Modify the certificate details"
: "Upload or generate a certificate to secure your application"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}> <Form {...form}>
<form <form
id="hook-form-handle-certificate" id="hook-form-add-certificate"
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4" className="grid w-full gap-4 "
> >
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => {
<FormItem> return (
<FormLabel>Certificate Name</FormLabel> <FormItem>
<FormControl> <FormLabel>Certificate Name</FormLabel>
<Input placeholder="My Certificate" {...field} /> <FormControl>
</FormControl> <Input placeholder={"My Certificate"} {...field} />
<FormMessage /> </FormControl>
</FormItem> <FormMessage />
)} </FormItem>
);
}}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="certificateData" name="certificateData"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Certificate Data</FormLabel> <div className="space-y-0.5">
<FormLabel>Certificate Data</FormLabel>
</div>
<FormControl> <FormControl>
<Textarea <Textarea
className="h-32" className="h-32"
@@ -214,7 +165,9 @@ export const HandleCertificate = ({ certificateId }: Props) => {
name="privateKey" name="privateKey"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Private Key</FormLabel> <div className="space-y-0.5">
<FormLabel>Private Key</FormLabel>
</div>
<FormControl> <FormControl>
<Textarea <Textarea
className="h-32" className="h-32"
@@ -295,10 +248,10 @@ export const HandleCertificate = ({ certificateId }: Props) => {
<DialogFooter className="flex w-full flex-row !justify-end"> <DialogFooter className="flex w-full flex-row !justify-end">
<Button <Button
isLoading={isPending} isLoading={isPending}
form="hook-form-handle-certificate" form="hook-form-add-certificate"
type="submit" type="submit"
> >
{certificateId ? "Update" : "Create"} Create
</Button> </Button>
</DialogFooter> </DialogFooter>
</Form> </Form>

View File

@@ -1,14 +1,4 @@
import { import { AlertCircle, Link, Loader2, ShieldCheck, Trash2 } from "lucide-react";
AlertCircle,
ChevronDown,
ChevronRight,
Link,
Loader2,
Server,
ShieldCheck,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
@@ -21,20 +11,14 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { HandleCertificate } from "./handle-certificate"; import { AddCertificate } from "./add-certificate";
import { import { getCertificateChainInfo, getExpirationStatus } from "./utils";
extractLeafCommonName,
getCertificateChainExpirationDetails,
getCertificateChainInfo,
getExpirationStatus,
} from "./utils";
export const ShowCertificates = () => { export const ShowCertificates = () => {
const { mutateAsync, isPending: isRemoving } = const { mutateAsync, isPending: isRemoving } =
api.certificates.remove.useMutation(); api.certificates.remove.useMutation();
const { data, isPending, refetch } = api.certificates.all.useQuery(); const { data, isPending, refetch } = api.certificates.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery(); const { data: permissions } = api.user.getPermissions.useQuery();
const [expandedChains, setExpandedChains] = useState<Set<string>>(new Set());
return ( return (
<div className="w-full"> <div className="w-full">
@@ -70,7 +54,7 @@ export const ShowCertificates = () => {
<span className="text-base text-muted-foreground text-center"> <span className="text-base text-muted-foreground text-center">
You don't have any certificates created You don't have any certificates created
</span> </span>
{permissions?.certificate.create && <HandleCertificate />} {permissions?.certificate.create && <AddCertificate />}
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4 min-h-[25vh]"> <div className="flex flex-col gap-4 min-h-[25vh]">
@@ -82,30 +66,6 @@ export const ShowCertificates = () => {
const chainInfo = getCertificateChainInfo( const chainInfo = getCertificateChainInfo(
certificate.certificateData, certificate.certificateData,
); );
const commonName = extractLeafCommonName(
certificate.certificateData,
);
const chainDetails = chainInfo.isChain
? getCertificateChainExpirationDetails(
certificate.certificateData,
)
: null;
const isExpanded = expandedChains.has(
certificate.certificateId,
);
const toggleChain = () => {
setExpandedChains((prev) => {
const next = new Set(prev);
if (next.has(certificate.certificateId)) {
next.delete(certificate.certificateId);
} else {
next.add(certificate.certificateId);
}
return next;
});
};
return ( return (
<div <div
key={certificate.certificateId} key={certificate.certificateId}
@@ -117,58 +77,12 @@ export const ShowCertificates = () => {
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{index + 1}. {certificate.name} {index + 1}. {certificate.name}
</span> </span>
{commonName && (
<span className="text-xs text-muted-foreground">
CN: {commonName}
</span>
)}
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Server className="size-3" />
{certificate.server
? `${certificate.server.name} (${certificate.server.ipAddress})`
: "Dokploy (Local)"}
</span>
{chainInfo.isChain && ( {chainInfo.isChain && (
<div className="flex flex-col gap-1.5 mt-1"> <div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
<button <Link className="size-3 text-muted-foreground" />
type="button" <span className="text-xs text-muted-foreground">
onClick={toggleChain} Chain ({chainInfo.count})
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 w-fit hover:bg-muted transition-colors" </span>
>
{isExpanded ? (
<ChevronDown className="size-3 text-muted-foreground" />
) : (
<ChevronRight className="size-3 text-muted-foreground" />
)}
<Link className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Chain ({chainInfo.count} certificates)
</span>
</button>
{isExpanded && (
<div className="flex flex-col gap-3 pl-2 border-l-2 border-muted">
{chainDetails?.map((cert) => (
<div
key={cert.index}
className="flex flex-col gap-1 p-2 rounded-md bg-muted/30"
>
<span className="text-xs font-medium text-muted-foreground">
{cert.label}
</span>
{cert.commonName && (
<span className="text-xs text-muted-foreground/80">
CN: {cert.commonName}
</span>
)}
<span
className={`text-xs ${cert.className}`}
>
{cert.message}
</span>
</div>
))}
</div>
)}
</div> </div>
)} )}
<div <div
@@ -188,14 +102,8 @@ export const ShowCertificates = () => {
</div> </div>
</div> </div>
<div className="flex flex-row gap-1"> {permissions?.certificate.delete && (
{permissions?.certificate.update && ( <div className="flex flex-row gap-1">
<HandleCertificate
certificateId={certificate.certificateId}
/>
)}
{permissions?.certificate.delete && (
<DialogAction <DialogAction
title="Delete Certificate" title="Delete Certificate"
description="Are you sure you want to delete this certificate?" description="Are you sure you want to delete this certificate?"
@@ -221,14 +129,14 @@ export const ShowCertificates = () => {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-red-500/10" className="group hover:bg-red-500/10 "
isLoading={isRemoving} isLoading={isRemoving}
> >
<Trash2 className="size-4 text-primary group-hover:text-red-500" /> <Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button> </Button>
</DialogAction> </DialogAction>
)} </div>
</div> )}
</div> </div>
</div> </div>
); );
@@ -237,7 +145,7 @@ export const ShowCertificates = () => {
{permissions?.certificate.create && ( {permissions?.certificate.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4"> <div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleCertificate /> <AddCertificate />
</div> </div>
)} )}
</div> </div>

View File

@@ -1,13 +1,5 @@
// @ts-nocheck // @ts-nocheck
// Split certificate chain into individual certificates
export const splitCertificateChain = (certData: string): string[] => {
const certRegex =
/(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)/g;
const matches = certData.match(certRegex);
return matches || [];
};
export const extractExpirationDate = (certData: string): Date | null => { export const extractExpirationDate = (certData: string): Date | null => {
try { try {
// Decode PEM base64 to DER binary // Decode PEM base64 to DER binary
@@ -36,11 +28,11 @@ export const extractExpirationDate = (certData: string): Date | null => {
} }
// Skip the outer certificate sequence // Skip the outer certificate sequence
if (der[offset++] !== 0x30) return null; if (der[offset++] !== 0x30) throw new Error("Expected sequence");
({ offset } = readLength(offset)); ({ offset } = readLength(offset));
// Skip tbsCertificate sequence // Skip tbsCertificate sequence
if (der[offset++] !== 0x30) return null; if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
({ offset } = readLength(offset)); ({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0]) // Check for optional version field (context-specific tag [0])
@@ -52,14 +44,15 @@ export const extractExpirationDate = (certData: string): Date | null => {
// Skip serialNumber, signature, issuer // Skip serialNumber, signature, issuer
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
if (der[offset] !== 0x30 && der[offset] !== 0x02) return null; if (der[offset] !== 0x30 && der[offset] !== 0x02)
throw new Error("Unexpected structure");
offset++; offset++;
const fieldLen = readLength(offset); const fieldLen = readLength(offset);
offset = fieldLen.offset + fieldLen.length; offset = fieldLen.offset + fieldLen.length;
} }
// Validity sequence (notBefore and notAfter) // Validity sequence (notBefore and notAfter)
if (der[offset++] !== 0x30) return null; if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
const validityLen = readLength(offset); const validityLen = readLength(offset);
offset = validityLen.offset; offset = validityLen.offset;
@@ -101,156 +94,8 @@ export const extractExpirationDate = (certData: string): Date | null => {
} }
}; };
export const extractCommonName = (certData: string): string | null => {
try {
// Decode PEM base64 to DER binary
const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
const binStr = atob(b64);
const der = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) {
der[i] = binStr.charCodeAt(i);
}
let offset = 0;
// Helper: read ASN.1 length field
function readLength(pos: number): { length: number; offset: number } {
// biome-ignore lint/style/noParameterAssign: <explanation>
let len = der[pos++];
if (len & 0x80) {
const bytes = len & 0x7f;
len = 0;
for (let i = 0; i < bytes; i++) {
// biome-ignore lint/style/noParameterAssign: <explanation>
len = (len << 8) + der[pos++];
}
}
return { length: len, offset: pos };
}
// Helper: skip a field
function skipField(pos: number): number {
// biome-ignore lint/style/noParameterAssign: <explanation>
pos++;
const fieldLen = readLength(pos);
return fieldLen.offset + fieldLen.length;
}
// Skip the outer certificate sequence
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Skip tbsCertificate sequence
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0])
if (der[offset] === 0xa0) {
offset++;
const versionLen = readLength(offset);
offset = versionLen.offset + versionLen.length;
}
// Skip serialNumber
offset = skipField(offset);
// Skip signature
offset = skipField(offset);
// Skip issuer
offset = skipField(offset);
// Skip validity
offset = skipField(offset);
// Subject sequence - where we find the CN
if (der[offset++] !== 0x30) return null;
const subjectLen = readLength(offset);
const subjectEnd = subjectLen.offset + subjectLen.length;
offset = subjectLen.offset;
// Parse subject RDNs looking for CN (OID 2.5.4.3)
while (offset < subjectEnd) {
if (der[offset++] !== 0x31) continue; // SET
const setLen = readLength(offset);
offset = setLen.offset;
if (der[offset++] !== 0x30) continue; // SEQUENCE
const seqLen = readLength(offset);
offset = seqLen.offset;
if (der[offset++] !== 0x06) continue; // OID
const oidLen = readLength(offset);
offset = oidLen.offset;
// Check if OID is 2.5.4.3 (commonName)
const oid = Array.from(der.slice(offset, offset + oidLen.length));
offset += oidLen.length;
// OID 2.5.4.3 in DER: [0x55, 0x04, 0x03]
if (
oid.length === 3 &&
oid[0] === 0x55 &&
oid[1] === 0x04 &&
oid[2] === 0x03
) {
// Next should be the string value
const strType = der[offset++];
const strLen = readLength(offset);
const cnBytes = der.slice(strLen.offset, strLen.offset + strLen.length);
return new TextDecoder().decode(cnBytes);
}
}
return null;
} catch (error) {
console.error("Error parsing certificate CN:", error);
return null;
}
};
// Extract the Common Name from the first (leaf) certificate in a chain
export const extractLeafCommonName = (certData: string): string | null => {
const certs = splitCertificateChain(certData);
if (certs.length === 0) return null;
return extractCommonName(certs[0]);
};
// Extract expiration dates from all certificates in a chain
export const extractAllExpirationDates = (
certData: string,
): Array<{
cert: string;
index: number;
expirationDate: Date | null;
commonName: string | null;
}> => {
const certs = splitCertificateChain(certData);
return certs.map((cert, index) => ({
cert,
index,
expirationDate: extractExpirationDate(cert),
commonName: extractCommonName(cert),
}));
};
// Get the earliest expiration date from a certificate chain
export const getEarliestExpirationDate = (certData: string): Date | null => {
const expirationDates = extractAllExpirationDates(certData);
const validDates = expirationDates
.filter((item) => item.expirationDate !== null)
.map((item) => item.expirationDate as Date);
if (validDates.length === 0) return null;
return new Date(Math.min(...validDates.map((date) => date.getTime())));
};
export const getExpirationStatus = (certData: string) => { export const getExpirationStatus = (certData: string) => {
const chainInfo = getCertificateChainInfo(certData); const expirationDate = extractExpirationDate(certData);
const expirationDate = chainInfo.isChain
? getEarliestExpirationDate(certData)
: extractExpirationDate(certData);
if (!expirationDate) if (!expirationDate)
return { return {
@@ -308,67 +153,3 @@ export const getCertificateChainInfo = (certData: string) => {
count: 1, count: 1,
}; };
}; };
// Get detailed expiration information for all certificates in a chain
export const getCertificateChainExpirationDetails = (certData: string) => {
const allExpirations = extractAllExpirationDates(certData);
const now = new Date();
return allExpirations.map(({ index, expirationDate, commonName }) => {
if (!expirationDate) {
return {
index,
label: `Certificate ${index + 1}`,
commonName,
status: "unknown" as const,
className: "text-muted-foreground",
message: "Could not determine expiration",
expirationDate: null,
};
}
const daysUntilExpiration = Math.ceil(
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
let status: "expired" | "warning" | "valid";
let className: string;
let message: string;
if (daysUntilExpiration < 0) {
status = "expired";
className = "text-red-500";
message = `Expired on ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`;
} else if (daysUntilExpiration <= 30) {
status = "warning";
className = "text-yellow-500";
message = `Expires in ${daysUntilExpiration} days`;
} else {
status = "valid";
className = "text-muted-foreground";
message = `Expires ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`;
}
return {
index,
label:
index === 0
? `Certificate ${index + 1} (Leaf)`
: `Certificate ${index + 1}`,
commonName,
status,
className,
message,
expirationDate,
daysUntilExpiration,
};
});
};

View File

@@ -1,7 +1,3 @@
import {
ADDITIONAL_FLAG_ERROR,
ADDITIONAL_FLAG_REGEX,
} from "@dokploy/server/db/validations/destination";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon, Trash2 } from "lucide-react"; import { PenBoxIcon, PlusIcon, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -39,6 +35,10 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import {
ADDITIONAL_FLAG_ERROR,
ADDITIONAL_FLAG_REGEX,
} from "@dokploy/server/db/validations/destination";
import { S3_PROVIDERS } from "./constants"; import { S3_PROVIDERS } from "./constants";
const addDestination = z.object({ const addDestination = z.object({

View File

@@ -283,7 +283,7 @@ export const AddGitlabProvider = () => {
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="For organization/group access use the slug name of the group eg: my-org" placeholder="For organization/group access use the slugish name of the group eg: my-org"
{...field} {...field}
/> />
</FormControl> </FormControl>

View File

@@ -192,7 +192,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="For organization/group access use the slug name of the group eg: my-org" placeholder="For organization/group access use the slugish name of the group eg: my-org"
{...field} {...field}
/> />
</FormControl> </FormControl>

View File

@@ -5,7 +5,6 @@ import {
ImportIcon, ImportIcon,
Loader2, Loader2,
Trash2, Trash2,
Users,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -25,13 +24,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url"; import { useUrl } from "@/utils/hooks/use-url";
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider"; import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
@@ -47,8 +39,6 @@ export const ShowGitProviders = () => {
const { data, isPending, refetch } = api.gitProvider.getAll.useQuery(); const { data, isPending, refetch } = api.gitProvider.getAll.useQuery();
const { mutateAsync, isPending: isRemoving } = const { mutateAsync, isPending: isRemoving } =
api.gitProvider.remove.useMutation(); api.gitProvider.remove.useMutation();
const { mutateAsync: toggleShare, isPending: isToggling } =
api.gitProvider.toggleShare.useMutation();
const url = useUrl(); const url = useUrl();
const getGitlabUrl = ( const getGitlabUrl = (
@@ -164,62 +154,10 @@ export const ShowGitProviders = () => {
)} )}
</span> </span>
</div> </div>
{!gitProvider.isOwner && (
<Badge
variant="secondary"
className="text-xs"
>
<Users className="size-3 mr-1" />
Shared
</Badge>
)}
</div> </div>
</div> </div>
<div className="flex flex-row gap-1 items-center"> <div className="flex flex-row gap-1 items-center">
{gitProvider.isOwner && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 mr-2">
<Users className="size-4 text-muted-foreground" />
<Switch
disabled={isToggling}
checked={
gitProvider.sharedWithOrganization
}
onCheckedChange={async (
checked,
) => {
await toggleShare({
gitProviderId:
gitProvider.gitProviderId,
sharedWithOrganization: checked,
})
.then(() => {
toast.success(
checked
? "Provider shared with organization"
: "Provider unshared",
);
refetch();
})
.catch(() => {
toast.error(
"Error updating sharing",
);
});
}}
/>
</div>
</TooltipTrigger>
<TooltipContent>
Share with entire organization
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isBitbucket && {isBitbucket &&
gitProvider.bitbucket?.appPassword && gitProvider.bitbucket?.appPassword &&
!gitProvider.bitbucket?.apiToken ? ( !gitProvider.bitbucket?.apiToken ? (
@@ -284,71 +222,62 @@ export const ShowGitProviders = () => {
</div> </div>
)} )}
{gitProvider.isOwner && ( {isGithub && haveGithubRequirements && (
<> <EditGithubProvider
{isGithub && haveGithubRequirements && ( githubId={gitProvider.github?.githubId}
<EditGithubProvider />
githubId={gitProvider.github?.githubId}
/>
)}
{isGitlab && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab?.gitlabId}
/>
)}
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket?.bitbucketId
}
/>
)}
{isGitea && (
<EditGiteaProvider
giteaId={gitProvider.gitea?.giteaId}
/>
)}
<DialogAction
title="Delete Git Provider"
description={
gitProvider.sharedWithOrganization
? "This provider is shared with the organization. Deleting it will remove access for all members. Are you sure?"
: "Are you sure you want to delete this Git Provider?"
}
type="destructive"
onClick={async () => {
await mutateAsync({
gitProviderId:
gitProvider.gitProviderId,
})
.then(() => {
toast.success(
"Git Provider deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting Git Provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)} )}
{isGitlab && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab?.gitlabId}
/>
)}
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket?.bitbucketId
}
/>
)}
{isGitea && (
<EditGiteaProvider
giteaId={gitProvider.gitea?.giteaId}
/>
)}
<DialogAction
title="Delete Git Provider"
description="Are you sure you want to delete this Git Provider?"
type="destructive"
onClick={async () => {
await mutateAsync({
gitProviderId: gitProvider.gitProviderId,
})
.then(() => {
toast.success(
"Git Provider deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting Git Provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,13 +1,6 @@
"use client"; "use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
Check,
ChevronDown,
Loader2,
PenBoxIcon,
Plug,
PlusIcon,
} from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -44,34 +37,10 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
const AI_PROVIDERS = [
{ name: "OpenAI", apiUrl: "https://api.openai.com/v1" },
{ name: "Anthropic", apiUrl: "https://api.anthropic.com/v1" },
{
name: "Google Gemini",
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
},
{ name: "Mistral", apiUrl: "https://api.mistral.ai/v1" },
{ name: "Cohere", apiUrl: "https://api.cohere.ai/v2" },
{ name: "Perplexity", apiUrl: "https://api.perplexity.ai" },
{ name: "DeepInfra", apiUrl: "https://api.deepinfra.com/v1/openai" },
{ name: "Ollama", apiUrl: "http://localhost:11434" },
{ name: "OpenRouter", apiUrl: "https://openrouter.ai/api/v1" },
{ name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4" },
{ name: "MiniMax", apiUrl: "https://api.minimax.io/v1" },
] as const;
const Schema = z.object({ const Schema = z.object({
name: z.string().min(1, { message: "Name is required" }), name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }), apiUrl: z.string().url({ message: "Please enter a valid URL" }),
@@ -134,7 +103,7 @@ export const HandleAi = ({ aiId }: Props) => {
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama"); const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
const { const {
data: models, data: models,
isFetching: isLoadingServerModels, isPending: isLoadingServerModels,
error: modelsError, error: modelsError,
} = api.ai.getModels.useQuery( } = api.ai.getModels.useQuery(
{ {
@@ -203,34 +172,6 @@ export const HandleAi = ({ aiId }: Props) => {
<AlertBlock type="error">{modelsError.message}</AlertBlock> <AlertBlock type="error">{modelsError.message}</AlertBlock>
)} )}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<div className="space-y-1">
<FormLabel>Provider</FormLabel>
<Select
onValueChange={(value) => {
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
if (provider) {
form.setValue("name", provider.name);
form.setValue("apiUrl", provider.apiUrl);
form.setValue("model", "");
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider preset..." />
</SelectTrigger>
<SelectContent>
{AI_PROVIDERS.map((provider) => (
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.8rem] text-muted-foreground">
Quick-fill provider name and URL, or configure manually below
</p>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -312,129 +253,101 @@ export const HandleAi = ({ aiId }: Props) => {
</span> </span>
)} )}
<FormField {!isLoadingServerModels && !models?.length && (
control={form.control} <span className="text-sm text-muted-foreground">
name="model" No models available
render={({ field }) => { </span>
const hasModels = )}
!isLoadingServerModels && models && models.length > 0;
const selectedModel = models?.find((m) => m.id === field.value);
const filteredModels = (models ?? []).filter((model) =>
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
);
const displayModels = {!isLoadingServerModels && models && models.length > 0 && (
field.value && <FormField
!filteredModels.find((m) => m.id === field.value) && control={form.control}
selectedModel name="model"
? [selectedModel, ...filteredModels] render={({ field }) => {
: filteredModels; const selectedModel = models.find(
(m) => m.id === field.value,
);
const filteredModels = models.filter((model) =>
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
);
return ( // Ensure selected model is always in the filtered list
<FormItem> const displayModels =
<FormLabel>Model</FormLabel> field.value &&
<div className="flex gap-2"> !filteredModels.find((m) => m.id === field.value) &&
<div className="flex-1"> selectedModel
{hasModels ? ( ? [selectedModel, ...filteredModels]
<Popover : filteredModels;
open={modelPopoverOpen}
onOpenChange={setModelPopoverOpen} return (
> <FormItem>
<PopoverTrigger asChild> <FormLabel>Model</FormLabel>
<FormControl> <Popover
<Button open={modelPopoverOpen}
variant="outline" onOpenChange={setModelPopoverOpen}
className={cn( >
"w-full justify-between", <PopoverTrigger asChild>
!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 or type a custom model..."
value={modelSearch}
onValueChange={setModelSearch}
/>
<CommandList>
<CommandEmpty>
{modelSearch ? (
<button
type="button"
className="w-full cursor-pointer px-2 py-1.5 text-left text-sm hover:bg-accent"
onClick={() => {
field.onChange(modelSearch);
setModelPopoverOpen(false);
setModelSearch("");
}}
>
Use custom model: "{modelSearch}"
</button>
) : (
"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>
) : (
<FormControl> <FormControl>
<Input <Button
placeholder={ variant="outline"
isLoadingServerModels className={cn(
? "Loading models..." "w-full justify-between",
: "Enter model name (e.g. gpt-4o)" !field.value && "text-muted-foreground",
} )}
disabled={isLoadingServerModels} >
{...field} {field.value
/> ? (selectedModel?.id ?? field.value)
: "Select a model"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl> </FormControl>
)} </PopoverTrigger>
</div> <PopoverContent className="w-[400px] p-0" align="start">
</div> <Command>
<FormDescription> <CommandInput
Select a model from the list or type a custom model name placeholder="Search models..."
</FormDescription> value={modelSearch}
<FormMessage /> onValueChange={setModelSearch}
</FormItem> />
); <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>
);
}}
/>
)}
<FormField <FormField
control={form.control} control={form.control}
@@ -459,12 +372,7 @@ export const HandleAi = ({ aiId }: Props) => {
)} )}
/> />
<div className="flex justify-end gap-2 pt-4"> <div className="flex justify-end gap-2 pt-4">
<TestConnectionButton
apiUrl={apiUrl}
apiKey={apiKey}
model={form.watch("model")}
/>
<Button type="submit" isLoading={isPending}> <Button type="submit" isLoading={isPending}>
{aiId ? "Update" : "Create"} {aiId ? "Update" : "Create"}
</Button> </Button>
@@ -475,42 +383,3 @@ export const HandleAi = ({ aiId }: Props) => {
</Dialog> </Dialog>
); );
}; };
function TestConnectionButton({
apiUrl,
apiKey,
model,
}: {
apiUrl: string;
apiKey: string;
model: string;
}) {
const { mutate, isPending } = api.ai.testConnection.useMutation({
onSuccess: () => {
toast.success("Connection successful");
},
onError: (error) => {
toast.error("Connection failed", {
description: error.message,
});
},
});
const isDisabled = !apiUrl || !model;
return (
<Button
type="button"
variant="outline"
disabled={isDisabled || isPending}
onClick={() => mutate({ apiUrl, apiKey, model })}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plug className="mr-2 h-4 w-4" />
)}
Test Connection
</Button>
);
}

View File

@@ -12,9 +12,9 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { import {
DiscordIcon, DiscordIcon,
MattermostIcon,
GotifyIcon, GotifyIcon,
LarkIcon, LarkIcon,
MattermostIcon,
NtfyIcon, NtfyIcon,
PushoverIcon, PushoverIcon,
ResendIcon, ResendIcon,
@@ -54,7 +54,6 @@ const notificationBaseSchema = z.object({
appDeploy: z.boolean().default(false), appDeploy: z.boolean().default(false),
appBuildError: z.boolean().default(false), appBuildError: z.boolean().default(false),
databaseBackup: z.boolean().default(false), databaseBackup: z.boolean().default(false),
dokployBackup: z.boolean().default(false),
volumeBackup: z.boolean().default(false), volumeBackup: z.boolean().default(false),
dokployRestart: z.boolean().default(false), dokployRestart: z.boolean().default(false),
dockerCleanup: z.boolean().default(false), dockerCleanup: z.boolean().default(false),
@@ -356,7 +355,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup, volumeBackup: notification.volumeBackup,
dockerCleanup: notification.dockerCleanup, dockerCleanup: notification.dockerCleanup,
webhookUrl: notification.slack?.webhookUrl, webhookUrl: notification.slack?.webhookUrl,
@@ -371,7 +369,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup, volumeBackup: notification.volumeBackup,
botToken: notification.telegram?.botToken, botToken: notification.telegram?.botToken,
messageThreadId: notification.telegram?.messageThreadId || "", messageThreadId: notification.telegram?.messageThreadId || "",
@@ -387,7 +384,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup, volumeBackup: notification.volumeBackup,
type: notification.notificationType, type: notification.notificationType,
webhookUrl: notification.discord?.webhookUrl, webhookUrl: notification.discord?.webhookUrl,
@@ -402,7 +398,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup, volumeBackup: notification.volumeBackup,
type: notification.notificationType, type: notification.notificationType,
smtpServer: notification.email?.smtpServer, smtpServer: notification.email?.smtpServer,
@@ -421,7 +416,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup, volumeBackup: notification.volumeBackup,
type: notification.notificationType, type: notification.notificationType,
apiKey: notification.resend?.apiKey, apiKey: notification.resend?.apiKey,
@@ -437,7 +431,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup, volumeBackup: notification.volumeBackup,
type: notification.notificationType, type: notification.notificationType,
appToken: notification.gotify?.appToken, appToken: notification.gotify?.appToken,
@@ -453,7 +446,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup, volumeBackup: notification.volumeBackup,
type: notification.notificationType, type: notification.notificationType,
accessToken: notification.ntfy?.accessToken || "", accessToken: notification.ntfy?.accessToken || "",
@@ -470,7 +462,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup, volumeBackup: notification.volumeBackup,
type: notification.notificationType, type: notification.notificationType,
webhookUrl: notification.mattermost?.webhookUrl, webhookUrl: notification.mattermost?.webhookUrl,
@@ -486,7 +477,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
type: notification.notificationType, type: notification.notificationType,
webhookUrl: notification.lark?.webhookUrl, webhookUrl: notification.lark?.webhookUrl,
name: notification.name, name: notification.name,
@@ -500,7 +490,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup, volumeBackup: notification.volumeBackup,
type: notification.notificationType, type: notification.notificationType,
webhookUrl: notification.teams?.webhookUrl, webhookUrl: notification.teams?.webhookUrl,
@@ -514,7 +503,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
type: notification.notificationType, type: notification.notificationType,
endpoint: notification.custom?.endpoint || "", endpoint: notification.custom?.endpoint || "",
headers: notification.custom?.headers headers: notification.custom?.headers
@@ -536,7 +524,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: notification.appDeploy, appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart, dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup, databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
volumeBackup: notification.volumeBackup, volumeBackup: notification.volumeBackup,
type: notification.notificationType, type: notification.notificationType,
userKey: notification.pushover?.userKey, userKey: notification.pushover?.userKey,
@@ -575,7 +562,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy, appDeploy,
dokployRestart, dokployRestart,
databaseBackup, databaseBackup,
dokployBackup,
volumeBackup, volumeBackup,
dockerCleanup, dockerCleanup,
serverThreshold, serverThreshold,
@@ -587,7 +573,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl, webhookUrl: data.webhookUrl,
channel: data.channel, channel: data.channel,
@@ -603,7 +588,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
botToken: data.botToken, botToken: data.botToken,
messageThreadId: data.messageThreadId || "", messageThreadId: data.messageThreadId || "",
@@ -620,7 +604,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl, webhookUrl: data.webhookUrl,
decoration: data.decoration, decoration: data.decoration,
@@ -636,7 +619,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
smtpServer: data.smtpServer, smtpServer: data.smtpServer,
smtpPort: data.smtpPort, smtpPort: data.smtpPort,
@@ -656,7 +638,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
apiKey: data.apiKey, apiKey: data.apiKey,
fromAddress: data.fromAddress, fromAddress: data.fromAddress,
@@ -673,7 +654,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
serverUrl: data.serverUrl, serverUrl: data.serverUrl,
appToken: data.appToken, appToken: data.appToken,
@@ -690,7 +670,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
serverUrl: data.serverUrl, serverUrl: data.serverUrl,
accessToken: data.accessToken || "", accessToken: data.accessToken || "",
@@ -707,7 +686,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl, webhookUrl: data.webhookUrl,
channel: data.channel || undefined, channel: data.channel || undefined,
@@ -724,7 +702,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl, webhookUrl: data.webhookUrl,
name: data.name, name: data.name,
@@ -739,7 +716,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl, webhookUrl: data.webhookUrl,
name: data.name, name: data.name,
@@ -766,7 +742,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
endpoint: data.endpoint, endpoint: data.endpoint,
headers: headersRecord, headers: headersRecord,
@@ -786,7 +761,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy, appDeploy: appDeploy,
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
volumeBackup: volumeBackup, volumeBackup: volumeBackup,
userKey: data.userKey, userKey: data.userKey,
apiToken: data.apiToken, apiToken: data.apiToken,
@@ -1882,27 +1856,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
)} )}
/> />
<FormField
control={form.control}
name="dokployBackup"
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>Dokploy Backup</FormLabel>
<FormDescription>
Trigger the action when a dokploy backup is created.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="volumeBackup" name="volumeBackup"

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