mirror of
https://github.com/dokploy/dokploy.git
synced 2026-06-15 11:59:49 +00:00
Compare commits
241 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3c2e1e5af | |||
| 60867d0b60 | |||
| 6a0acd9cad | |||
| 64a606ffa4 | |||
| 29851491f6 | |||
| 95633b4122 | |||
| c73632cbe0 | |||
| 30b3e1fe48 | |||
| a07106d649 | |||
| df98cea19f | |||
| b109e0ebc4 | |||
| 282d358d04 | |||
| 2f08b33931 | |||
| ccc8f6d047 | |||
| 62aeed5aed | |||
| 5e021797f3 | |||
| 1c6fdc1b43 | |||
| 6270bad9af | |||
| 9c71458eff | |||
| 547ba2d04b | |||
| b9e97eb321 | |||
| a4e2317f3e | |||
| 06a349152f | |||
| fef2de1ec5 | |||
| b20ff64cbf | |||
| 5177580d51 | |||
| d3292a2810 | |||
| 0f526af2c8 | |||
| 72f5d711c8 | |||
| ffd51cf32f | |||
| e8b3d7ba7d | |||
| c182755591 | |||
| 8227a48ef4 | |||
| f5ddc36f24 | |||
| d5d8914bf6 | |||
| bf0890a6b0 | |||
| 4e07669464 | |||
| 4a3fa6e63f | |||
| 14af5d293a | |||
| 746bb3ddc6 | |||
| b13308dc69 | |||
| 16746a1609 | |||
| bca62d43d2 | |||
| d502f4a206 | |||
| de7d6f8147 | |||
| 9d6bc4cd18 | |||
| 65b27af0f5 | |||
| 6165114bc3 | |||
| d3109359fb | |||
| 58f527d029 | |||
| 1ed41fe2f8 | |||
| 9b416b3699 | |||
| 096b8b33fc | |||
| 741792883a | |||
| e0c6ed699d | |||
| 5f5ed0f2c2 | |||
| b9ff576682 | |||
| c854a38adb | |||
| 5fb365c08b | |||
| 15296d5c85 | |||
| 0e5fc584b2 | |||
| cc7ea5108b | |||
| 8f3d824ea6 | |||
| 0bdcbf5827 | |||
| 34564aec84 | |||
| ed006dc5f9 | |||
| 222b167a76 | |||
| fb6b06f064 | |||
| 09824facf8 | |||
| bd46eaec5c | |||
| e9fdc19b96 | |||
| 3e81cdac4d | |||
| e72c51444c | |||
| 940d18ad25 | |||
| c41b69c925 | |||
| b610f7aeff | |||
| cdd77a04dc | |||
| 05f22edfe5 | |||
| 29480cde90 | |||
| 232ccc9139 | |||
| 018e2b153e | |||
| ad490dca3f | |||
| f8c6c8f7cc | |||
| d7af82731c | |||
| c3fa638a56 | |||
| ce703ef478 | |||
| fc6df3ae05 | |||
| 8fb517152a | |||
| ba3591b3ac | |||
| bad9731878 | |||
| 98a586478e | |||
| 13248c8d8a | |||
| 54417ca8e7 | |||
| 598fae0e92 | |||
| b392e58001 | |||
| d9945c0a4f | |||
| f6e2c033ba | |||
| 5c787adae1 | |||
| 2ba1df1eaa | |||
| e7859395b1 | |||
| 6f0ed89ce7 | |||
| 4277a509b2 | |||
| f7b576cbf3 | |||
| 425fef6e28 | |||
| 958372c5f9 | |||
| e7c581476e | |||
| 0cae8330e2 | |||
| 7e13243c1d | |||
| 4a271c11e7 | |||
| fda367b2c5 | |||
| ea1238b1d1 | |||
| b060f80932 | |||
| 04b9f56333 | |||
| 599b97da51 | |||
| 415298fddb | |||
| ddff8b9de7 | |||
| 90f97912a4 | |||
| 9af745ce67 | |||
| d99f2cd460 | |||
| d234558822 | |||
| 7f25ddca44 | |||
| 638b3dd546 | |||
| 1a8fd8396d | |||
| 385850f354 | |||
| a48306a2c6 | |||
| 89737e7b65 | |||
| 00c708483e | |||
| ddf570a807 | |||
| f8eb2ba4ba | |||
| 9f07f8e9e1 | |||
| 3cefa43a21 | |||
| 0941ec9f3e | |||
| 879218a8b1 | |||
| d6124aae81 | |||
| f404b231a6 | |||
| 7a986e5fb3 | |||
| 9687ed0d83 | |||
| b4c57b6326 | |||
| f8eb3c2b76 | |||
| a30617d85d | |||
| b079cbd427 | |||
| aeda19db8a | |||
| cb64482649 | |||
| f4cae5f775 | |||
| 825e6b654c | |||
| c1b19376a9 | |||
| 6c3578a475 | |||
| b8db120432 | |||
| 7c10610a5a | |||
| 8d8658a478 | |||
| fbde5be02c | |||
| 090c0226ed | |||
| 4a1b42899b | |||
| 343514d4eb | |||
| 36067618f4 | |||
| cc74f9e38c | |||
| df7e1da776 | |||
| df9aa50ece | |||
| ebbc008dbe | |||
| 645a81b2ce | |||
| a6db83c758 | |||
| ac65cc97f4 | |||
| 30d5493281 | |||
| 91b44720ef | |||
| f700017ccf | |||
| 9287721dbf | |||
| 6cde04ea39 | |||
| 283eeeb3e6 | |||
| 19ae575fa8 | |||
| eafbd0353e | |||
| 91ebf3b6f5 | |||
| d9b2b48643 | |||
| 148c91bf5e | |||
| c42a16d658 | |||
| b222409129 | |||
| a322ac374c | |||
| 4d8a2a38e8 | |||
| 4ef8c94340 | |||
| ff369c9d3a | |||
| de3db08e60 | |||
| a2d655083a | |||
| f3356cfe90 | |||
| 2362778fe1 | |||
| 628f16e8cb | |||
| ea8e99d76d | |||
| d4719ece58 | |||
| e679a322b9 | |||
| f24f1ada5f | |||
| 5b6d80e177 | |||
| 2c9ca651a8 | |||
| 413ed9bd80 | |||
| 4f578516d6 | |||
| 1e57d48ab4 | |||
| a177d34dfd | |||
| 1034c79245 | |||
| 304454b22d | |||
| 42c2076281 | |||
| 5cd7de8188 | |||
| 1352b859e2 | |||
| 1c2307b86f | |||
| 4832fd929c | |||
| d1b639a55a | |||
| 40de13e4d4 | |||
| f0ea1c8796 | |||
| b45e7e415c | |||
| 67d3e92aaf | |||
| 76af74d8aa | |||
| b15ede8877 | |||
| ea805c1520 | |||
| 976932fb03 | |||
| ac8960efdd | |||
| d6050ce05a | |||
| 5a46b879f5 | |||
| 222e4878bd | |||
| fd267a64de | |||
| fa3cdf148b | |||
| 74caf141f4 | |||
| 8b7d9c0896 | |||
| 13e20e9ef8 | |||
| f9b0589070 | |||
| b615d04ad2 | |||
| 6c4efa48b1 | |||
| 85d48aba2b | |||
| 3b138f8e8a | |||
| b91067dc2a | |||
| 335a16b915 | |||
| 274f38029c | |||
| 4cbc91d3d0 | |||
| 10d17de186 | |||
| 65f0919fa7 | |||
| 9b7abfbed7 | |||
| 6676a86b34 | |||
| d603654ac1 | |||
| d9ffe519b0 | |||
| fa91a74462 | |||
| d7794286be | |||
| f337dd7e01 | |||
| 5d5d95bbd3 | |||
| 7be1084a10 | |||
| 19a525fac1 | |||
| 7984497398 |
@@ -138,6 +138,8 @@ jobs:
|
||||
needs: [combine-manifests]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -160,3 +162,80 @@ jobs:
|
||||
prerelease: false
|
||||
env:
|
||||
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 }}"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
|
||||
name: PR Quality
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
anti-slop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
blocked-commit-authors: "claude,copilot"
|
||||
require-description: true
|
||||
min-account-age: 5
|
||||
@@ -68,3 +68,66 @@ jobs:
|
||||
|
||||
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"
|
||||
|
||||
|
||||
Vendored
+3
@@ -4,5 +4,8 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Isolate the command builder from the compose-file I/O performed by
|
||||
// writeDomainsToCompose; we only care about the docker invocation it emits.
|
||||
vi.mock("@dokploy/server/utils/docker/domain", () => ({
|
||||
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
|
||||
}));
|
||||
|
||||
const baseCompose = {
|
||||
appName: "my-app",
|
||||
sourceType: "raw",
|
||||
command: "",
|
||||
composePath: "docker-compose.yml",
|
||||
composeType: "stack",
|
||||
isolatedDeployment: false,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
env: "",
|
||||
mounts: [],
|
||||
domains: [],
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
|
||||
|
||||
// Regression coverage for #4401: the deploy command runs under `env -i`, which
|
||||
// clears the environment except for the vars listed explicitly. HOME must be
|
||||
// preserved so docker can resolve ~/.docker/config.json — otherwise
|
||||
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
|
||||
// and private-registry images fail to pull.
|
||||
describe("getBuildComposeCommand registry auth (#4401)", () => {
|
||||
it("preserves HOME for swarm stack deploys", async () => {
|
||||
const command = await getBuildComposeCommand({
|
||||
...baseCompose,
|
||||
composeType: "stack",
|
||||
});
|
||||
|
||||
expect(command).toContain("stack deploy");
|
||||
expect(command).toContain("--with-registry-auth");
|
||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
||||
});
|
||||
|
||||
it("preserves HOME for docker compose deploys", async () => {
|
||||
const command = await getBuildComposeCommand({
|
||||
...baseCompose,
|
||||
composeType: "docker-compose",
|
||||
});
|
||||
|
||||
expect(command).toContain("compose -p my-app");
|
||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ describe("Host rule format regression tests", () => {
|
||||
stripPath: false,
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("createDomainLabels", () => {
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
@@ -103,6 +104,51 @@ describe("createDomainLabels", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should add tls=true for certificateType none on websecure entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(appName, noneDomain, "websecure");
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.tls=true",
|
||||
);
|
||||
// no cert resolver should be set when relying on a default/custom cert
|
||||
expect(labels).not.toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not add tls=true for certificateType none on web entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(appName, noneDomain, "web");
|
||||
expect(labels).not.toContain(
|
||||
"traefik.http.routers.test-app-1-web.tls=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add tls=true for certificateType none on a custom https entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "websecure-custom",
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
noneDomain,
|
||||
"websecure-custom",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure-custom.tls=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle different ports correctly", async () => {
|
||||
const customPortDomain = { ...baseDomain, port: 3000 };
|
||||
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
canEditDeployGitSource,
|
||||
getAccessibleGitProviderIds,
|
||||
} from "@dokploy/server/services/git-provider";
|
||||
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
query: {
|
||||
gitProvider: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
member: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
|
||||
|
||||
const mockHasValidLicense = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: mockHasValidLicense,
|
||||
}));
|
||||
|
||||
const ORG_ID = "org-1";
|
||||
const USER_OWNER = "user-owner";
|
||||
const USER_ADMIN = "user-admin";
|
||||
const USER_MEMBER = "user-member";
|
||||
const USER_MEMBER_2 = "user-member-2";
|
||||
|
||||
const providerOwned = {
|
||||
gitProviderId: "gp-owned",
|
||||
userId: USER_MEMBER,
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
const providerShared = {
|
||||
gitProviderId: "gp-shared",
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: true,
|
||||
};
|
||||
const providerPrivate = {
|
||||
gitProviderId: "gp-private",
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
const providerOtherMember = {
|
||||
gitProviderId: "gp-other",
|
||||
userId: USER_MEMBER_2,
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
|
||||
const allProviders = [
|
||||
providerOwned,
|
||||
providerShared,
|
||||
providerPrivate,
|
||||
providerOtherMember,
|
||||
];
|
||||
|
||||
function session(userId: string) {
|
||||
return { userId, activeOrganizationId: ORG_ID };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
|
||||
mockHasValidLicense.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe("getAccessibleGitProviderIds", () => {
|
||||
describe("owner", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "owner",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns all org providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
||||
});
|
||||
|
||||
it("includes providers owned by other members", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "admin",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns all org providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
||||
});
|
||||
|
||||
it("includes providers owned by other members — fixes issue #4469", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member without enterprise license", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||
});
|
||||
mockHasValidLicense.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("can access their own provider", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("can access shared providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot access private providers of other users even if assigned (no license)", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot access providers of other members", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member with enterprise license", () => {
|
||||
beforeEach(() => {
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("can access provider explicitly assigned to them", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot access provider not assigned and not shared", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||
});
|
||||
|
||||
it("can access shared provider even without explicit assignment", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("can access own provider regardless of assignments", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot access provider of other member even with license but no assignment", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member with no member record", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue(null);
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("only returns own providers and shared ones", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enterprise license — member assigned to a provider they do not own", () => {
|
||||
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
|
||||
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty org", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "admin",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty set when org has no providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||
expect(ids.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEditDeployGitSource", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("owner", () => {
|
||||
it("can edit deploy using any provider", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
|
||||
const result = await canEditDeployGitSource(
|
||||
providerPrivate.gitProviderId,
|
||||
session(USER_OWNER),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
|
||||
});
|
||||
|
||||
it("cannot edit deploy using owner's private provider (not shared)", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerPrivate.gitProviderId,
|
||||
session(USER_ADMIN),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("can edit deploy using a provider shared with the org", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: true,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerShared.gitProviderId,
|
||||
session(USER_ADMIN),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can edit deploy using their own provider", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_ADMIN,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
"gp-admin-owned",
|
||||
session(USER_ADMIN),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
|
||||
});
|
||||
|
||||
it("can edit deploy using their own provider", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_MEMBER,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerOwned.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can edit deploy using a provider shared with the org", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: true,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerShared.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
|
||||
// This is the key case: enterprise, provider del owner, no compartido,
|
||||
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerPrivate.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot edit deploy using another member's private provider", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_MEMBER_2,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerOtherMember.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if provider does not exist", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
|
||||
const result = await canEditDeployGitSource(
|
||||
"nonexistent-id",
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("static roles bypass enterprise resources", () => {
|
||||
describe("owner and admin bypass enterprise resources", () => {
|
||||
it("owner bypasses deployment.read", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
@@ -73,15 +73,8 @@ describe("static roles bypass enterprise resources", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses schedule.delete", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { schedule: ["delete"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
it("owner bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, {
|
||||
deployment: ["read"],
|
||||
@@ -92,6 +85,55 @@ describe("static roles bypass enterprise resources", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("member is denied org-level enterprise resources (CVE: bypass via staticRoles)", () => {
|
||||
it("member is denied registry.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { registry: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied certificate.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { certificate: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied destination.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { destination: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied notification.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { notification: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied auditLog.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { auditLog: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied server.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied registry.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { registry: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static roles validate free-tier resources", () => {
|
||||
it("owner passes project.create", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
|
||||
@@ -494,4 +494,49 @@ describe("processTemplate", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,9 +30,7 @@ describe("helpers functions", () => {
|
||||
const domain = processValue("${domain}", {}, mockSchema);
|
||||
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
||||
expect(
|
||||
domain.endsWith(
|
||||
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
|
||||
),
|
||||
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { ApplicationNested, Domain } from "@dokploy/server";
|
||||
import {
|
||||
buildForwardAuthEnv,
|
||||
createRouterConfig,
|
||||
deriveBaseDomain,
|
||||
deriveCookieSecret,
|
||||
forwardAuthCallbackUrl,
|
||||
forwardAuthMiddlewareName,
|
||||
} from "@dokploy/server";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
|
||||
const app = {
|
||||
appName: "my-app",
|
||||
redirects: [],
|
||||
security: [],
|
||||
} as unknown as ApplicationNested;
|
||||
|
||||
const baseDomain: Domain = {
|
||||
applicationId: "app-1",
|
||||
certificateType: "none",
|
||||
createdAt: "",
|
||||
domainId: "domain-1",
|
||||
host: "app.example.com",
|
||||
https: false,
|
||||
path: null,
|
||||
port: 3000,
|
||||
customEntrypoint: null,
|
||||
serviceName: "",
|
||||
composeId: "",
|
||||
customCertResolver: null,
|
||||
domainType: "application",
|
||||
uniqueConfigKey: 7,
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
};
|
||||
|
||||
describe("forwardAuthMiddlewareName", () => {
|
||||
test("is stable and unique per app + uniqueConfigKey", () => {
|
||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
||||
"forward-auth-my-app-7",
|
||||
);
|
||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
expect(forwardAuthMiddlewareName("my-app", 7)).not.toBe(
|
||||
forwardAuthMiddlewareName("my-app", 8),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRouterConfig forward-auth wiring", () => {
|
||||
test("does NOT add forward-auth middleware when no provider is linked", async () => {
|
||||
const config = await createRouterConfig(app, baseDomain, "websecure");
|
||||
expect(config.middlewares).not.toContain(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
});
|
||||
|
||||
test("adds forward-auth middleware when a provider is linked", async () => {
|
||||
const domain: Domain = {
|
||||
...baseDomain,
|
||||
forwardAuthEnabled: true,
|
||||
};
|
||||
const config = await createRouterConfig(app, domain, "websecure");
|
||||
expect(config.middlewares).toContain(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
});
|
||||
|
||||
test("forward-auth runs before custom domain middlewares", async () => {
|
||||
const domain: Domain = {
|
||||
...baseDomain,
|
||||
forwardAuthEnabled: true,
|
||||
middlewares: ["rate-limit@file"],
|
||||
};
|
||||
const config = await createRouterConfig(app, domain, "websecure");
|
||||
const forwardAuthIdx = config.middlewares?.indexOf(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
const customIdx = config.middlewares?.indexOf("rate-limit@file");
|
||||
expect(forwardAuthIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(customIdx).toBeGreaterThan(forwardAuthIdx as number);
|
||||
});
|
||||
|
||||
test("redirect-only web router does not get the forward-auth middleware", async () => {
|
||||
const domain: Domain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
forwardAuthEnabled: true,
|
||||
};
|
||||
const config = await createRouterConfig(app, domain, "web");
|
||||
expect(config.middlewares).toContain("redirect-to-https");
|
||||
expect(config.middlewares).not.toContain(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForwardAuthEnv", () => {
|
||||
const baseOptions = {
|
||||
oidc: {
|
||||
clientId: "client-123",
|
||||
clientSecret: "secret-xyz",
|
||||
issuer: "https://idp.example.com",
|
||||
},
|
||||
cookieSecret: "cookie-secret-value",
|
||||
authDomain: "auth.acme.com",
|
||||
baseDomain: ".acme.com",
|
||||
authDomainHttps: true,
|
||||
};
|
||||
|
||||
test("emits the required oauth2-proxy OIDC env vars", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain("OAUTH2_PROXY_PROVIDER=oidc");
|
||||
expect(env).toContain(
|
||||
"OAUTH2_PROXY_OIDC_ISSUER_URL=https://idp.example.com",
|
||||
);
|
||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_ID=client-123");
|
||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_SECRET=secret-xyz");
|
||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_SECRET=cookie-secret-value");
|
||||
expect(env).toContain("OAUTH2_PROXY_REVERSE_PROXY=true");
|
||||
expect(env).toContain("OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180");
|
||||
});
|
||||
|
||||
test("uses the central auth domain for the single fixed callback", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain(
|
||||
"OAUTH2_PROXY_REDIRECT_URL=https://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
});
|
||||
|
||||
test("shares cookie + whitelist on the base domain (no per-app redeploy)", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_DOMAINS=.acme.com");
|
||||
expect(env).toContain("OAUTH2_PROXY_WHITELIST_DOMAINS=.acme.com");
|
||||
});
|
||||
|
||||
test("matches cookie Secure flag and callback scheme to https setting", () => {
|
||||
const https = buildForwardAuthEnv(baseOptions);
|
||||
expect(https).toContain("OAUTH2_PROXY_COOKIE_SECURE=true");
|
||||
|
||||
const http = buildForwardAuthEnv({
|
||||
...baseOptions,
|
||||
authDomainHttps: false,
|
||||
});
|
||||
expect(http).toContain("OAUTH2_PROXY_COOKIE_SECURE=false");
|
||||
expect(http).toContain(
|
||||
"OAUTH2_PROXY_REDIRECT_URL=http://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
});
|
||||
|
||||
test("allows unverified emails so OIDC providers don't 500 the callback", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain(
|
||||
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
|
||||
);
|
||||
});
|
||||
|
||||
test("defaults to any authenticated user and standard scopes", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=*");
|
||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid email profile");
|
||||
});
|
||||
|
||||
test("honors custom scopes and email domains", () => {
|
||||
const env = buildForwardAuthEnv({
|
||||
...baseOptions,
|
||||
oidc: { ...baseOptions.oidc, scopes: ["openid", "groups"] },
|
||||
emailDomains: ["acme.com", "corp.com"],
|
||||
});
|
||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid groups");
|
||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=acme.com,corp.com");
|
||||
});
|
||||
|
||||
test("sets skip-discovery flag only when requested", () => {
|
||||
const withoutSkip = buildForwardAuthEnv(baseOptions);
|
||||
expect(withoutSkip).not.toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
||||
|
||||
const withSkip = buildForwardAuthEnv({
|
||||
...baseOptions,
|
||||
oidc: { ...baseOptions.oidc, skipDiscovery: true },
|
||||
});
|
||||
expect(withSkip).toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveBaseDomain", () => {
|
||||
test("strips the auth subdomain to the shared base", () => {
|
||||
expect(deriveBaseDomain("auth.acme.com")).toBe(".acme.com");
|
||||
expect(deriveBaseDomain("sso.apps.acme.com")).toBe(".apps.acme.com");
|
||||
});
|
||||
|
||||
test("keeps a two-label apex as the base", () => {
|
||||
expect(deriveBaseDomain("acme.com")).toBe(".acme.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("forwardAuthCallbackUrl", () => {
|
||||
test("builds the single IdP callback per scheme", () => {
|
||||
expect(forwardAuthCallbackUrl("auth.acme.com", true)).toBe(
|
||||
"https://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
expect(forwardAuthCallbackUrl("auth.acme.com", false)).toBe(
|
||||
"http://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveCookieSecret", () => {
|
||||
beforeAll(() => {
|
||||
process.env.BETTER_AUTH_SECRET = "test-root-secret";
|
||||
});
|
||||
|
||||
test("is deterministic for the same salt (survives service updates)", () => {
|
||||
expect(deriveCookieSecret(".acme.com")).toBe(
|
||||
deriveCookieSecret(".acme.com"),
|
||||
);
|
||||
});
|
||||
|
||||
test("differs per salt", () => {
|
||||
expect(deriveCookieSecret(".acme.com")).not.toBe(
|
||||
deriveCookieSecret(".other.com"),
|
||||
);
|
||||
});
|
||||
|
||||
test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
|
||||
const secret = deriveCookieSecret(".acme.com");
|
||||
expect(Buffer.from(secret, "hex")).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,8 @@ const baseSettings: WebServerSettings = {
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
cleanupCacheOnPreviews: false,
|
||||
remoteServersOnly: false,
|
||||
enforceSSO: false,
|
||||
createdAt: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -148,6 +148,7 @@ const baseDomain: Domain = {
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
@@ -424,6 +425,26 @@ test("Custom entrypoint with internalPath adds addprefix middleware", async () =
|
||||
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,
|
||||
|
||||
@@ -78,4 +78,20 @@ describe("readValidDirectory (path traversal)", () => {
|
||||
it("returns false for empty string (resolves to cwd)", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+33
-8
@@ -16,12 +16,17 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const optionalNumber = z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((val) => (val === "" ? undefined : Number(val)))
|
||||
.optional();
|
||||
|
||||
export const healthCheckFormSchema = z.object({
|
||||
Test: z.array(z.string()).optional(),
|
||||
Interval: z.coerce.number().optional(),
|
||||
Timeout: z.coerce.number().optional(),
|
||||
StartPeriod: z.coerce.number().optional(),
|
||||
Retries: z.coerce.number().optional(),
|
||||
Interval: optionalNumber,
|
||||
Timeout: optionalNumber,
|
||||
StartPeriod: optionalNumber,
|
||||
Retries: optionalNumber,
|
||||
});
|
||||
|
||||
interface HealthCheckFormProps {
|
||||
@@ -195,7 +200,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -212,7 +222,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Maximum time to wait for health check response
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -229,7 +244,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Initial grace period before health checks begin
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -247,7 +267,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
unhealthy
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="3"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -224,7 +224,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>Memory Limit</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -263,7 +263,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>Memory Reservation</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -303,7 +303,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>CPU Limit</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -343,7 +343,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>CPU Reservation</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -379,7 +379,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -165,6 +166,7 @@ export const ShowDeployment = ({
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<AnalyzeLogs logs={filteredLogs} context="build" />
|
||||
|
||||
{serverId && (
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
@@ -21,9 +21,9 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { RouterOutputs } from "@/utils/api";
|
||||
import type { ValidationStates } from "./show-domains";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
import type { ValidationStates } from "./show-domains";
|
||||
|
||||
export type Domain =
|
||||
| RouterOutputs["domain"]["byApplicationId"][0]
|
||||
@@ -168,7 +168,7 @@ export const createColumns = ({
|
||||
{domain.certificateType}
|
||||
</Badge>
|
||||
)}
|
||||
{!domain.host.includes("traefik.me") && (
|
||||
{!domain.host.includes("sslip.io") && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -256,7 +256,7 @@ export const createColumns = ({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{!domain.host.includes("traefik.me") && (
|
||||
{!domain.host.includes("sslip.io") && (
|
||||
<DnsHelperModal
|
||||
domain={{
|
||||
host: domain.host,
|
||||
|
||||
@@ -225,7 +225,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
const https = form.watch("https");
|
||||
const domainType = form.watch("domainType");
|
||||
const host = form.watch("host");
|
||||
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||
const isTraefikMeDomain = host?.includes("sslip.io") || false;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -513,7 +513,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{!canGenerateTraefikMeDomains &&
|
||||
field.value.includes("traefik.me") && (
|
||||
field.value.includes("sslip.io") && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
@@ -524,12 +524,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to make your traefik.me domain work.
|
||||
to make your sslip.io domain work.
|
||||
</AlertBlock>
|
||||
)}
|
||||
{isTraefikMeDomain && (
|
||||
<AlertBlock type="info">
|
||||
<strong>Note:</strong> traefik.me is a public HTTP
|
||||
<strong>Note:</strong> sslip.io is a public HTTP
|
||||
service and does not support SSL/HTTPS. HTTPS and
|
||||
certificate options will not have any effect.
|
||||
</AlertBlock>
|
||||
@@ -567,7 +567,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>Generate traefik.me domain</p>
|
||||
<p>Generate sslip.io domain</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -666,7 +666,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Custom Entrypoint</FormLabel>
|
||||
<FormDescription>
|
||||
Use custom entrypoint for domina
|
||||
Use custom entrypoint for domain
|
||||
<br />
|
||||
"web" and/or "websecure" is used by default.
|
||||
</FormDescription>
|
||||
@@ -806,7 +806,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
<FormLabel>Middlewares</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
domainId: string;
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const HandleForwardAuth = ({ domainId, applicationId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data: haveValidLicense } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: status } = api.forwardAuth.status.useQuery(
|
||||
{ domainId },
|
||||
{ enabled: isOpen },
|
||||
);
|
||||
|
||||
const { mutateAsync: enable, isPending: isEnabling } =
|
||||
api.forwardAuth.enable.useMutation();
|
||||
const { mutateAsync: disable, isPending: isDisabling } =
|
||||
api.forwardAuth.disable.useMutation();
|
||||
|
||||
if (!haveValidLicense) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isEnabled = !!status?.enabled;
|
||||
const isPending = isEnabling || isDisabling;
|
||||
|
||||
const refresh = async () => {
|
||||
await utils.forwardAuth.status.invalidate({ domainId });
|
||||
await utils.domain.byApplicationId.invalidate({ applicationId });
|
||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
||||
};
|
||||
|
||||
const handleToggle = async (next: boolean) => {
|
||||
try {
|
||||
if (next) {
|
||||
await enable({ domainId });
|
||||
toast.success("SSO authentication enabled for this domain");
|
||||
} else {
|
||||
await disable({ domainId });
|
||||
toast.success("SSO authentication disabled for this domain");
|
||||
}
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error updating SSO authentication",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-emerald-500/10"
|
||||
title="SSO authentication"
|
||||
>
|
||||
<ShieldCheck
|
||||
className={`size-4 ${
|
||||
isEnabled
|
||||
? "text-emerald-500"
|
||||
: "text-primary group-hover:text-emerald-500"
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>SSO Authentication</DialogTitle>
|
||||
<DialogDescription>
|
||||
Require visitors to authenticate against your identity provider
|
||||
before reaching this application.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<AlertBlock type="warning">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Requirements</span>
|
||||
<ol className="list-decimal pl-4 text-sm">
|
||||
<li>
|
||||
The authentication proxy container must be deployed and running
|
||||
on this app's server. Configure it under{" "}
|
||||
<span className="font-medium">
|
||||
Settings → SSO → Application Authentication
|
||||
</span>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
This domain must share the same base domain as the
|
||||
authentication domain (e.g. <code>app.acme.com</code> and{" "}
|
||||
<code>auth.acme.com</code>).
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</AlertBlock>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4 mt-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
Protect this domain with SSO
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isEnabled
|
||||
? "Visitors must log in via your identity provider."
|
||||
: "The domain is publicly accessible."}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
disabled={isPending}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -62,6 +62,7 @@ import { api } from "@/utils/api";
|
||||
import { createColumns } from "./columns";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
import { HandleForwardAuth } from "./handle-forward-auth";
|
||||
|
||||
export type ValidationState = {
|
||||
isLoading: boolean;
|
||||
@@ -425,7 +426,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{!item.host.includes("traefik.me") && (
|
||||
{!item.host.includes("sslip.io") && (
|
||||
<DnsHelperModal
|
||||
domain={{
|
||||
host: item.host,
|
||||
@@ -453,6 +454,12 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
{canCreateDomain && type === "application" && (
|
||||
<HandleForwardAuth
|
||||
domainId={item.domainId}
|
||||
applicationId={id}
|
||||
/>
|
||||
)}
|
||||
{canDeleteDomain && (
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
|
||||
@@ -56,17 +56,17 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
|
||||
const mutationMap = {
|
||||
compose: () => api.compose.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
compose: () => api.compose.saveEnvironment.useMutation(),
|
||||
libsql: () => api.libsql.saveEnvironment.useMutation(),
|
||||
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
|
||||
mongo: () => api.mongo.saveEnvironment.useMutation(),
|
||||
mysql: () => api.mysql.saveEnvironment.useMutation(),
|
||||
postgres: () => api.postgres.saveEnvironment.useMutation(),
|
||||
redis: () => api.redis.saveEnvironment.useMutation(),
|
||||
};
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
: api.mongo.saveEnvironment.useMutation();
|
||||
|
||||
const form = useForm<EnvironmentSchema>({
|
||||
defaultValues: {
|
||||
|
||||
+5
-1
@@ -1,3 +1,4 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -57,7 +58,10 @@ const BitbucketProviderSchema = z.object({
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
branch: z
|
||||
.string()
|
||||
.min(1, "Branch is required")
|
||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().optional(),
|
||||
|
||||
+90
-93
@@ -1,3 +1,4 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -41,7 +42,10 @@ const GitProviderSchema = z.object({
|
||||
repositoryURL: z.string().min(1, {
|
||||
message: "Repository URL is required",
|
||||
}),
|
||||
branch: z.string().min(1, "Branch required"),
|
||||
branch: z
|
||||
.string()
|
||||
.min(1, "Branch required")
|
||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||
sshKey: z.string().optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
@@ -55,7 +59,7 @@ interface Props {
|
||||
|
||||
export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync, isPending } =
|
||||
@@ -107,110 +111,103 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="flex items-end col-span-2 gap-4">
|
||||
<div className="grow">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repositoryURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository URL</FormLabel>
|
||||
{field.value?.startsWith("https://") && (
|
||||
<Link
|
||||
href={field.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<GitIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</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>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repositoryURL"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2 lg:col-span-3">
|
||||
<div className="flex items-center justify-between h-5">
|
||||
<FormLabel>Repository URL</FormLabel>
|
||||
{field.value?.startsWith("https://") && (
|
||||
<Link
|
||||
href={field.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<GitIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input placeholder="Repository URL" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
/>
|
||||
{sshKeys && sshKeys.length > 0 ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="branch"
|
||||
name="sshKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Branch</FormLabel>
|
||||
<FormItem className="col-span-2 lg:col-span-1">
|
||||
<FormLabel className="w-full inline-flex justify-between">
|
||||
SSH Key
|
||||
<LockIcon className="size-4 text-muted-foreground" />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Branch" {...field} />
|
||||
<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>
|
||||
<FormMessage />
|
||||
</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
|
||||
control={form.control}
|
||||
name="buildPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Build Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
@@ -223,7 +220,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormItem className="col-span-2 lg:col-span-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
|
||||
+5
-1
@@ -1,3 +1,4 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -72,7 +73,10 @@ const GiteaProviderSchema = z.object({
|
||||
owner: z.string().min(1, "Owner is required"),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
branch: z
|
||||
.string()
|
||||
.min(1, "Branch is required")
|
||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||
watchPaths: z.array(z.string()).default([]),
|
||||
enableSubmodules: z.boolean().optional(),
|
||||
|
||||
+5
-1
@@ -1,3 +1,4 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -55,7 +56,10 @@ const GithubProviderSchema = z.object({
|
||||
owner: z.string().min(1, "Owner is required"),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
branch: z
|
||||
.string()
|
||||
.min(1, "Branch is required")
|
||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||
githubId: z.string().min(1, "Github Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||
|
||||
+5
-1
@@ -1,3 +1,4 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -58,7 +59,10 @@ const GitlabProviderSchema = z.object({
|
||||
id: z.number().nullable(),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
branch: z
|
||||
.string()
|
||||
.min(1, "Branch is required")
|
||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
|
||||
@@ -58,7 +58,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<CardContent className="grid grid-cols-2 lg:flex lg:flex-row lg:flex-wrap gap-4">
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
@@ -274,14 +274,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2 col-span-2"
|
||||
>
|
||||
<Terminal className="size-4 mr-1" />
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<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">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
@@ -305,7 +305,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
)}
|
||||
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<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">
|
||||
<span className="text-sm font-medium">Clean Cache</span>
|
||||
<Switch
|
||||
aria-label="Toggle clean cache"
|
||||
|
||||
+3
-3
@@ -87,7 +87,7 @@ export const AddPreviewDomain = ({
|
||||
});
|
||||
|
||||
const host = form.watch("host");
|
||||
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||
const isTraefikMeDomain = host?.includes("sslip.io") || false;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -162,7 +162,7 @@ export const AddPreviewDomain = ({
|
||||
<FormItem>
|
||||
{isTraefikMeDomain && (
|
||||
<AlertBlock type="info">
|
||||
<strong>Note:</strong> traefik.me is a public HTTP
|
||||
<strong>Note:</strong> sslip.io is a public HTTP
|
||||
service and does not support SSL/HTTPS. HTTPS and
|
||||
certificate options will not have any effect.
|
||||
</AlertBlock>
|
||||
@@ -202,7 +202,7 @@ export const AddPreviewDomain = ({
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>Generate traefik.me domain</p>
|
||||
<p>Generate sslip.io domain</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
+5
-5
@@ -88,7 +88,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
env: "",
|
||||
wildcardDomain: "*.traefik.me",
|
||||
wildcardDomain: "*.sslip.io",
|
||||
port: 3000,
|
||||
previewLimit: 3,
|
||||
previewLabels: [],
|
||||
@@ -102,7 +102,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
|
||||
const previewHttps = form.watch("previewHttps");
|
||||
const wildcardDomain = form.watch("wildcardDomain");
|
||||
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
|
||||
const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false;
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||
@@ -114,7 +114,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
env: data.previewEnv || "",
|
||||
buildArgs: data.previewBuildArgs || "",
|
||||
buildSecrets: data.previewBuildSecrets || "",
|
||||
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
||||
wildcardDomain: data.previewWildcard || "*.sslip.io",
|
||||
port: data.previewPort || 3000,
|
||||
previewLabels: data.previewLabels || [],
|
||||
previewLimit: data.previewLimit || 3,
|
||||
@@ -173,7 +173,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
<div className="grid gap-4">
|
||||
{isTraefikMeDomain && (
|
||||
<AlertBlock type="info">
|
||||
<strong>Note:</strong> traefik.me is a public HTTP service and
|
||||
<strong>Note:</strong> sslip.io is a public HTTP service and
|
||||
does not support SSL/HTTPS. HTTPS and certificate options will
|
||||
not have any effect.
|
||||
</AlertBlock>
|
||||
@@ -192,7 +192,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Wildcard Domain</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="*.traefik.me" {...field} />
|
||||
<Input placeholder="*.sslip.io" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -80,6 +80,7 @@ export const commonCronExpressions = [
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional(),
|
||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||
shellType: z.enum(["bash", "sh"]).default("bash"),
|
||||
command: z.string(),
|
||||
@@ -224,6 +225,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
resolver: standardSchemaResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
cronExpression: "",
|
||||
shellType: "bash",
|
||||
command: "",
|
||||
@@ -263,6 +265,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
if (scheduleId && schedule) {
|
||||
form.reset({
|
||||
name: schedule.name,
|
||||
description: schedule.description || "",
|
||||
cronExpression: schedule.cronExpression,
|
||||
shellType: schedule.shellType,
|
||||
command: schedule.command,
|
||||
@@ -479,6 +482,26 @@ 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
|
||||
name="cronExpression"
|
||||
formControl={form.control}
|
||||
|
||||
@@ -125,6 +125,11 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
{schedule.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</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">
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -49,12 +49,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
const composeFile = form.watch("composeFile");
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !composeFile) {
|
||||
if (data) {
|
||||
form.reset({
|
||||
composeFile: data.composeFile || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
}, [form, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.composeFile !== undefined) {
|
||||
|
||||
+6
-2
@@ -1,3 +1,4 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -57,7 +58,10 @@ const BitbucketProviderSchema = z.object({
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
branch: z
|
||||
.string()
|
||||
.min(1, "Branch is required")
|
||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
@@ -418,7 +422,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
|
||||
+6
-2
@@ -1,3 +1,4 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -41,7 +42,10 @@ const GitProviderSchema = z.object({
|
||||
repositoryURL: z.string().min(1, {
|
||||
message: "Repository URL is required",
|
||||
}),
|
||||
branch: z.string().min(1, "Branch required"),
|
||||
branch: z
|
||||
.string()
|
||||
.min(1, "Branch required")
|
||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||
sshKey: z.string().optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
@@ -55,7 +59,7 @@ interface Props {
|
||||
|
||||
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||
|
||||
+6
-2
@@ -1,5 +1,6 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -57,7 +58,10 @@ const GiteaProviderSchema = z.object({
|
||||
owner: z.string().min(1, "Owner is required"),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
branch: z
|
||||
.string()
|
||||
.min(1, "Branch is required")
|
||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
|
||||
+6
-2
@@ -1,3 +1,4 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -55,7 +56,10 @@ const GithubProviderSchema = z.object({
|
||||
owner: z.string().min(1, "Owner is required"),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
branch: z
|
||||
.string()
|
||||
.min(1, "Branch is required")
|
||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||
githubId: z.string().min(1, "Github Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||
@@ -445,7 +449,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
|
||||
+6
-2
@@ -1,3 +1,4 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -58,7 +59,10 @@ const GitlabProviderSchema = z.object({
|
||||
gitlabPathNamespace: z.string().min(1),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
branch: z
|
||||
.string()
|
||||
.min(1, "Branch is required")
|
||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
@@ -436,7 +440,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
|
||||
@@ -288,7 +288,6 @@ export const RestoreBackup = ({
|
||||
toast.error("Please select a database type");
|
||||
return;
|
||||
}
|
||||
console.log({ data });
|
||||
setIsDeploying(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { AnalyzeLogs } from "./analyze-logs";
|
||||
import { LineCountFilter } from "./line-count-filter";
|
||||
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
||||
import { StatusLogsFilter } from "./status-logs-filter";
|
||||
@@ -346,11 +347,13 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
title={isPaused ? "Resume logs" : "Pause logs"}
|
||||
>
|
||||
{isPaused ? (
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
<Play className="size-4" />
|
||||
) : (
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
<Pause className="size-4" />
|
||||
)}
|
||||
{isPaused ? "Resume" : "Pause"}
|
||||
<span className="hidden lg:ml-2 lg:inline">
|
||||
{isPaused ? "Resume" : "Pause"}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -361,11 +364,13 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
title="Copy logs to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
Copy
|
||||
<span className="hidden lg:ml-2 lg:inline">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -373,16 +378,18 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
className="h-9 sm:w-auto w-full"
|
||||
onClick={handleDownload}
|
||||
disabled={filteredLogs.length === 0 || !data?.Name}
|
||||
title="Download logs as text file"
|
||||
>
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
Download logs
|
||||
<DownloadIcon className="size-4" />
|
||||
<span className="hidden lg:ml-2 lg:inline">Download logs</span>
|
||||
</Button>
|
||||
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
||||
</div>
|
||||
</div>
|
||||
{isPaused && (
|
||||
<AlertBlock type="warning">
|
||||
<AlertBlock type="warning" className="items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pause className="h-4 w-4" />
|
||||
<Pause className="size-4" />
|
||||
<span>
|
||||
Logs paused
|
||||
{messageBuffer.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ShowContainerConfig } from "../config/show-container-config";
|
||||
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 { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
||||
import { UploadFileModal } from "../upload/upload-file-modal";
|
||||
@@ -123,6 +125,14 @@ export const columns: ColumnDef<Container>[] = [
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
/>
|
||||
<ShowContainerMounts
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
/>
|
||||
<ShowContainerNetworks
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
/>
|
||||
<DockerTerminalModal
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
uploadFileToContainerSchema,
|
||||
type UploadFileToContainer,
|
||||
uploadFileToContainerSchema,
|
||||
} from "@/utils/schema";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -1,10 +1,10 @@
|
||||
import { toast } from "sonner";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
mariadbId: string;
|
||||
|
||||
@@ -82,7 +82,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
||||
const params = `authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`;
|
||||
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/?${params}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { toast } from "sonner";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
mongoId: string;
|
||||
@@ -62,7 +62,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
<Label>Internal Connection URL </Label>
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
|
||||
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017/?authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -220,11 +220,11 @@ export const ContainerFreeMonitoring = ({
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Used: {currentData.cpu.value}
|
||||
Used: {String(currentData.cpu.value ?? "0%")}
|
||||
</span>
|
||||
<Progress
|
||||
value={Number.parseInt(
|
||||
currentData.cpu.value.replace("%", ""),
|
||||
String(currentData.cpu.value ?? "0%").replace("%", ""),
|
||||
10,
|
||||
)}
|
||||
className="w-[100%]"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { toast } from "sonner";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
mysqlId: string;
|
||||
|
||||
+1
-1
@@ -1,10 +1,10 @@
|
||||
import { toast } from "sonner";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
postgresId: string;
|
||||
|
||||
@@ -71,6 +71,9 @@ interface Props {
|
||||
export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
|
||||
const [visible, setVisible] = useState(false);
|
||||
const slug = slugify(projectName);
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
@@ -171,7 +174,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
Select a Server {!isCloud ? "(Optional)" : ""}
|
||||
Select a Server{" "}
|
||||
{showLocalOption ? "(Optional)" : ""}
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</FormLabel>
|
||||
</TooltipTrigger>
|
||||
@@ -191,17 +195,19 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
field.value || (showLocalOption ? "dokploy" : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
||||
placeholder={
|
||||
showLocalOption ? "Dokploy" : "Select a Server"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
{showLocalOption && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
@@ -225,7 +231,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
Servers (
|
||||
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
|
||||
@@ -74,6 +74,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const slug = slugify(projectName);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const { mutateAsync, isPending, error, isError } =
|
||||
api.compose.create.useMutation();
|
||||
@@ -182,7 +185,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
Select a Server {!isCloud ? "(Optional)" : ""}
|
||||
Select a Server{" "}
|
||||
{showLocalOption ? "(Optional)" : ""}
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</FormLabel>
|
||||
</TooltipTrigger>
|
||||
@@ -202,17 +206,19 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
field.value || (showLocalOption ? "dokploy" : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
||||
placeholder={
|
||||
showLocalOption ? "Dokploy" : "Select a Server"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
{showLocalOption && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
@@ -236,7 +242,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
Servers (
|
||||
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
|
||||
@@ -219,6 +219,9 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const slug = slugify(projectName);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const libsqlMutation = api.libsql.create.useMutation();
|
||||
const mariadbMutation = api.mariadb.create.useMutation();
|
||||
@@ -470,19 +473,20 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
field.value ||
|
||||
(showLocalOption ? "dokploy" : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!isCloud ? "Dokploy" : "Select a Server"
|
||||
showLocalOption ? "Dokploy" : "Select a Server"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
{showLocalOption && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
@@ -501,7 +505,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
Servers (
|
||||
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
@@ -632,7 +637,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
control={form.control}
|
||||
name="enableNamespaces"
|
||||
render={({ field }) => {
|
||||
console.log(field.value);
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Enable Namespaces</FormLabel>
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
BookText,
|
||||
Bookmark,
|
||||
BookText,
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
Globe,
|
||||
|
||||
@@ -298,7 +298,19 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2 w-full justify-end">
|
||||
<Button
|
||||
onClick={stepper.prev}
|
||||
onClick={() => {
|
||||
if (
|
||||
stepper.current.id === "variant" &&
|
||||
templateInfo.details
|
||||
) {
|
||||
setTemplateInfo((prev) => ({
|
||||
...prev,
|
||||
details: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
stepper.prev();
|
||||
}}
|
||||
disabled={stepper.isFirst}
|
||||
variant="secondary"
|
||||
>
|
||||
|
||||
@@ -166,6 +166,7 @@ export const ShowProjects = () => {
|
||||
return (
|
||||
total +
|
||||
(env.applications?.length || 0) +
|
||||
(env.libsql?.length || 0) +
|
||||
(env.mariadb?.length || 0) +
|
||||
(env.mongo?.length || 0) +
|
||||
(env.mysql?.length || 0) +
|
||||
@@ -178,6 +179,7 @@ export const ShowProjects = () => {
|
||||
return (
|
||||
total +
|
||||
(env.applications?.length || 0) +
|
||||
(env.libsql?.length || 0) +
|
||||
(env.mariadb?.length || 0) +
|
||||
(env.mongo?.length || 0) +
|
||||
(env.mysql?.length || 0) +
|
||||
@@ -342,7 +344,7 @@ export const ShowProjects = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
||||
<span className="flex flex-col gap-1.5 ">
|
||||
@@ -489,7 +491,7 @@ export const ShowProjects = () => {
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="pt-4">
|
||||
<CardFooter className="pt-4 mt-auto">
|
||||
<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}>
|
||||
Created
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { toast } from "sonner";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
redisId: string;
|
||||
|
||||
@@ -79,8 +79,11 @@ export const columns: ColumnDef<LogEntry>[] = [
|
||||
: log.RequestPath}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<Badge variant={getStatusColor(log.OriginStatus)}>
|
||||
Status: {formatStatusLabel(log.OriginStatus)}
|
||||
<Badge
|
||||
variant={getStatusColor(log.OriginStatus || log.DownstreamStatus)}
|
||||
>
|
||||
Status:{" "}
|
||||
{formatStatusLabel(log.OriginStatus || log.DownstreamStatus)}
|
||||
</Badge>
|
||||
<Badge variant={"secondary"}>
|
||||
Exec Time: {formatDuration(log.Duration)}
|
||||
|
||||
@@ -185,7 +185,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
||||
<Input
|
||||
placeholder="Filter by name..."
|
||||
placeholder="Filter by hostname..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="md:max-w-sm"
|
||||
|
||||
@@ -167,7 +167,7 @@ export const SearchCommand = () => {
|
||||
<CommandGroup heading={"Application"} hidden={true}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/projects");
|
||||
router.push("/dashboard/home");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { loadStripe } from "@stripe/stripe-js";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckIcon,
|
||||
CreditCard,
|
||||
FileText,
|
||||
@@ -24,8 +25,18 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { NumberInput } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -90,6 +101,8 @@ export const ShowBilling = () => {
|
||||
api.stripe.createCustomerPortalSession.useMutation();
|
||||
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
|
||||
api.stripe.upgradeSubscription.useMutation();
|
||||
const { mutateAsync: updateInvoiceNotifications } =
|
||||
api.stripe.updateInvoiceNotifications.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||
@@ -151,14 +164,66 @@ export const ShowBilling = () => {
|
||||
<div className="w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
</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>
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
|
||||
@@ -49,7 +49,11 @@ export const ShowGitProviders = () => {
|
||||
api.gitProvider.remove.useMutation();
|
||||
const { mutateAsync: toggleShare, isPending: isToggling } =
|
||||
api.gitProvider.toggleShare.useMutation();
|
||||
const { data: currentMember } = api.user.get.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const url = useUrl();
|
||||
const isOrgAdmin =
|
||||
currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
|
||||
const getGitlabUrl = (
|
||||
clientId: string,
|
||||
@@ -87,18 +91,20 @@ export const ShowGitProviders = () => {
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<GitBranch className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
Create your first Git Provider
|
||||
No Git Providers configured
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
{permissions?.gitProviders.create && (
|
||||
<div>
|
||||
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
@@ -106,14 +112,16 @@ export const ShowGitProviders = () => {
|
||||
<span className="text-base font-medium">
|
||||
Available Providers
|
||||
</span>
|
||||
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
{permissions?.gitProviders.create && (
|
||||
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-lg ">
|
||||
@@ -123,17 +131,13 @@ export const ShowGitProviders = () => {
|
||||
const isBitbucket =
|
||||
gitProvider.providerType === "bitbucket";
|
||||
const isGitea = gitProvider.providerType === "gitea";
|
||||
const canManage = gitProvider.isOwner || isOrgAdmin;
|
||||
|
||||
const haveGithubRequirements =
|
||||
isGithub &&
|
||||
gitProvider.github?.githubPrivateKey &&
|
||||
gitProvider.github?.githubAppId &&
|
||||
gitProvider.github?.githubInstallationId;
|
||||
isGithub && gitProvider.github?.isConfigured;
|
||||
|
||||
const haveGitlabRequirements =
|
||||
isGitlab &&
|
||||
gitProvider.gitlab?.accessToken &&
|
||||
gitProvider.gitlab?.refreshToken;
|
||||
isGitlab && gitProvider.gitlab?.isConfigured;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -221,8 +225,7 @@ export const ShowGitProviders = () => {
|
||||
)}
|
||||
|
||||
{isBitbucket &&
|
||||
gitProvider.bitbucket?.appPassword &&
|
||||
!gitProvider.bitbucket?.apiToken ? (
|
||||
gitProvider.bitbucket?.isDeprecated ? (
|
||||
<Badge variant="yellow">Deprecated</Badge>
|
||||
) : null}
|
||||
|
||||
@@ -235,7 +238,7 @@ export const ShowGitProviders = () => {
|
||||
Action Required
|
||||
</Badge>
|
||||
<Link
|
||||
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github.githubId}`}
|
||||
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github?.githubId}`}
|
||||
className={buttonVariants({
|
||||
size: "icon",
|
||||
variant: "ghost",
|
||||
@@ -271,7 +274,7 @@ export const ShowGitProviders = () => {
|
||||
href={getGitlabUrl(
|
||||
gitProvider.gitlab?.applicationId || "",
|
||||
gitProvider.gitlab?.gitlabId || "",
|
||||
gitProvider.gitlab?.gitlabUrl,
|
||||
gitProvider.gitlab?.gitlabUrl || "",
|
||||
)}
|
||||
target="_blank"
|
||||
className={buttonVariants({
|
||||
@@ -284,31 +287,35 @@ export const ShowGitProviders = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gitProvider.isOwner && (
|
||||
{canManage && (
|
||||
<>
|
||||
{isGithub && haveGithubRequirements && (
|
||||
<EditGithubProvider
|
||||
githubId={gitProvider.github?.githubId}
|
||||
/>
|
||||
)}
|
||||
{isGithub &&
|
||||
haveGithubRequirements &&
|
||||
gitProvider.github?.githubId && (
|
||||
<EditGithubProvider
|
||||
githubId={gitProvider.github.githubId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitlab && (
|
||||
<EditGitlabProvider
|
||||
gitlabId={gitProvider.gitlab?.gitlabId}
|
||||
/>
|
||||
)}
|
||||
{isGitlab &&
|
||||
gitProvider.gitlab?.gitlabId && (
|
||||
<EditGitlabProvider
|
||||
gitlabId={gitProvider.gitlab.gitlabId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBitbucket && (
|
||||
<EditBitbucketProvider
|
||||
bitbucketId={
|
||||
gitProvider.bitbucket?.bitbucketId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isBitbucket &&
|
||||
gitProvider.bitbucket?.bitbucketId && (
|
||||
<EditBitbucketProvider
|
||||
bitbucketId={
|
||||
gitProvider.bitbucket.bitbucketId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitea && (
|
||||
{isGitea && gitProvider.gitea?.giteaId && (
|
||||
<EditGiteaProvider
|
||||
giteaId={gitProvider.gitea?.giteaId}
|
||||
giteaId={gitProvider.gitea.giteaId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
PenBoxIcon,
|
||||
Plug,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -37,10 +44,34 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const 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({
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||
@@ -103,7 +134,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
|
||||
const {
|
||||
data: models,
|
||||
isPending: isLoadingServerModels,
|
||||
isFetching: isLoadingServerModels,
|
||||
error: modelsError,
|
||||
} = api.ai.getModels.useQuery(
|
||||
{
|
||||
@@ -172,6 +203,34 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<AlertBlock type="error">{modelsError.message}</AlertBlock>
|
||||
)}
|
||||
<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
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -253,101 +312,129 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isLoadingServerModels && !models?.length && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No models available
|
||||
</span>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
render={({ field }) => {
|
||||
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()),
|
||||
);
|
||||
|
||||
{!isLoadingServerModels && models && models.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
render={({ field }) => {
|
||||
const selectedModel = models.find(
|
||||
(m) => m.id === field.value,
|
||||
);
|
||||
const filteredModels = models.filter((model) =>
|
||||
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
||||
);
|
||||
const displayModels =
|
||||
field.value &&
|
||||
!filteredModels.find((m) => m.id === field.value) &&
|
||||
selectedModel
|
||||
? [selectedModel, ...filteredModels]
|
||||
: filteredModels;
|
||||
|
||||
// Ensure selected model is always in the filtered list
|
||||
const displayModels =
|
||||
field.value &&
|
||||
!filteredModels.find((m) => m.id === field.value) &&
|
||||
selectedModel
|
||||
? [selectedModel, ...filteredModels]
|
||||
: filteredModels;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Model</FormLabel>
|
||||
<Popover
|
||||
open={modelPopoverOpen}
|
||||
onOpenChange={setModelPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Model</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
{hasModels ? (
|
||||
<Popover
|
||||
open={modelPopoverOpen}
|
||||
onOpenChange={setModelPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? (selectedModel?.id ?? field.value)
|
||||
: "Select a model"}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[400px] p-0"
|
||||
align="start"
|
||||
>
|
||||
{field.value
|
||||
? (selectedModel?.id ?? field.value)
|
||||
: "Select a model"}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search models..."
|
||||
value={modelSearch}
|
||||
onValueChange={setModelSearch}
|
||||
<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>
|
||||
<Input
|
||||
placeholder={
|
||||
isLoadingServerModels
|
||||
? "Loading models..."
|
||||
: "Enter model name (e.g. gpt-4o)"
|
||||
}
|
||||
disabled={isLoadingServerModels}
|
||||
{...field}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Select a model from the list or type a custom model name
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -372,7 +459,12 @@ 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}>
|
||||
{aiId ? "Update" : "Create"}
|
||||
</Button>
|
||||
@@ -383,3 +475,42 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+37
-1
@@ -1,6 +1,13 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
@@ -52,7 +59,36 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch checked={!!enabled} onCheckedChange={handleToggle} />
|
||||
<Label className="text-primary">Daily Docker Cleanup</Label>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
|
||||
Daily Docker Cleanup
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<p>
|
||||
Runs a full Docker cleanup daily, pruning stopped containers,
|
||||
unused images, volumes, build cache, and system resources. This
|
||||
may remove images built for Compose services that run on-demand
|
||||
(backup runners, cron jobs, one-off tasks).
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
For custom cleanup strategies, use{" "}
|
||||
<a
|
||||
href="https://docs.dokploy.com/docs/core/schedule-jobs#example-1-automatic-docker-cleanup"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary"
|
||||
>
|
||||
Schedule Jobs
|
||||
</a>{" "}
|
||||
on your web server or remote servers.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const ToggleEnforceSSO = () => {
|
||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||
const { mutateAsync } = api.settings.updateEnforceSSO.useMutation();
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
try {
|
||||
await mutateAsync({ enforceSSO: checked });
|
||||
await refetch();
|
||||
toast.success("Enforce SSO updated");
|
||||
} catch {
|
||||
toast.error("Error updating Enforce SSO");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch checked={!!data?.enforceSSO} onCheckedChange={handleToggle} />
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
|
||||
Enforce SSO
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<p>
|
||||
When enabled, the email/password login form is hidden and users
|
||||
must sign in exclusively through SSO.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const ToggleRemoteServersOnly = () => {
|
||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||
|
||||
const { mutateAsync } = api.settings.updateRemoteServersOnly.useMutation();
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
try {
|
||||
await mutateAsync({ remoteServersOnly: checked });
|
||||
await refetch();
|
||||
toast.success("Remote Servers Only updated");
|
||||
} catch {
|
||||
toast.error("Error updating Remote Servers Only");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
checked={!!data?.remoteServersOnly}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
|
||||
Remote Servers Only
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<p>
|
||||
When enabled, all services (applications, databases, compose) must
|
||||
be deployed to a remote server. Deploying directly to the Dokploy
|
||||
host VM is not allowed.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
@@ -53,6 +54,7 @@ const Schema = z.object({
|
||||
message: "SSH Key is required",
|
||||
}),
|
||||
serverType: z.enum(["deploy", "build"]).default("deploy"),
|
||||
enableDockerCleanup: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
@@ -90,6 +92,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
username: "root",
|
||||
sshKeyId: "",
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: true,
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -103,6 +106,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
username: data?.username || "root",
|
||||
sshKeyId: data?.sshKeyId || "",
|
||||
serverType: data?.serverType || "deploy",
|
||||
enableDockerCleanup: data?.enableDockerCleanup ?? true,
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||
|
||||
@@ -119,6 +123,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
username: data.username || "root",
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
serverType: data.serverType || "deploy",
|
||||
enableDockerCleanup: data.enableDockerCleanup,
|
||||
serverId: serverId || "",
|
||||
})
|
||||
.then(async (_data) => {
|
||||
@@ -418,6 +423,27 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableDockerCleanup"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Enable Docker Cleanup</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically prune unused Docker images daily. Keeps disk
|
||||
usage in check on this remote server.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -131,10 +131,10 @@ export const ShowServers = () => {
|
||||
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerIcon className="size-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="text-lg break-words min-w-0">
|
||||
{server.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ export const ShowServers = () => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-8 w-8 shrink-0 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
More options
|
||||
|
||||
+19
-3
@@ -55,7 +55,8 @@ export const WelcomeSubscription = () => {
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const stepper = useStepper();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const { push } = useRouter();
|
||||
const router = useRouter();
|
||||
const { push } = router;
|
||||
|
||||
useEffect(() => {
|
||||
const confettiShown = localStorage.getItem("hasShownConfetti");
|
||||
@@ -66,7 +67,22 @@ export const WelcomeSubscription = () => {
|
||||
}, [showConfetti]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
const { success, ...rest } = router.query;
|
||||
router.replace(
|
||||
{ pathname: router.pathname, query: rest },
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
||||
{showConfetti ?? "Flaso"}
|
||||
<div className="flex justify-center items-center w-full">
|
||||
@@ -409,7 +425,7 @@ export const WelcomeSubscription = () => {
|
||||
onClick={() => {
|
||||
if (stepper.isLast) {
|
||||
setIsOpen(false);
|
||||
push("/dashboard/projects");
|
||||
push("/dashboard/home");
|
||||
} else {
|
||||
stepper.next();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
|
||||
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
|
||||
|
||||
@@ -141,14 +141,14 @@ export const WebDomain = () => {
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4 md:grid-cols-2"
|
||||
className="grid w-full gap-4 grid-cols-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormItem className="col-span-2 md:col-span-1">
|
||||
<FormLabel>Domain</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -168,7 +168,7 @@ export const WebDomain = () => {
|
||||
name="letsEncryptEmail"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormItem className="col-span-2 md:col-span-1">
|
||||
<FormLabel>Let's Encrypt Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -209,7 +209,7 @@ export const WebDomain = () => {
|
||||
name="certificateType"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Certificate Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ServerIcon } from "lucide-react";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, ServerIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -49,8 +51,17 @@ export const WebServer = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-wrap justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
|
||||
Server IP: {webServerSettings?.serverIp}
|
||||
{webServerSettings?.serverIp && (
|
||||
<CopyIcon
|
||||
className="size-3.5 cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
copy(webServerSettings.serverIp ?? "");
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Version: {dokployVersion}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Forward,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
House,
|
||||
Key,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
@@ -148,6 +149,12 @@ type Menu = {
|
||||
// The `isEnabled` function is called to determine if the item should be displayed
|
||||
const MENU: Menu = {
|
||||
home: [
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Home",
|
||||
url: "/dashboard/home",
|
||||
icon: House,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Projects",
|
||||
@@ -861,6 +868,19 @@ function SidebarLogo() {
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCloser() {
|
||||
const pathname = usePathname();
|
||||
const { setOpenMobile, isMobile } = useSidebar();
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setOpenMobile(false);
|
||||
}
|
||||
}, [pathname, isMobile, setOpenMobile]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Page({ children }: Props) {
|
||||
const [defaultOpen, setDefaultOpen] = useState<boolean | undefined>(
|
||||
undefined,
|
||||
@@ -926,6 +946,7 @@ export default function Page({ children }: Props) {
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<MobileCloser />
|
||||
<Sidebar collapsible="icon" variant="floating">
|
||||
<SidebarHeader>
|
||||
{/* <SidebarMenuButton
|
||||
|
||||
@@ -80,7 +80,7 @@ export const UserNav = () => {
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push("/dashboard/projects");
|
||||
router.push("/dashboard/home");
|
||||
}}
|
||||
>
|
||||
Projects
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Copy,
|
||||
Dices,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DnsHelperModal } from "@/components/dashboard/application/domains/dns-helper-modal";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
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,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type ServerStatus = "running" | "stopped" | "unknown";
|
||||
type Target = { serverId: string | null; name: string };
|
||||
type CertType = "none" | "letsencrypt" | "custom";
|
||||
type DomainForm = {
|
||||
host: string;
|
||||
https: boolean;
|
||||
certificateType: CertType;
|
||||
customCertResolver: string;
|
||||
};
|
||||
|
||||
export const ForwardAuthServers = () => {
|
||||
const utils = api.useUtils();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [deployTarget, setDeployTarget] = useState<Target | null>(null);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState("");
|
||||
const [forms, setForms] = useState<Record<string, DomainForm>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setEnabled(true), 0);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const { data: hostIp } = api.settings.getIp.useQuery();
|
||||
const { data: servers, isPending } = api.forwardAuth.serverStatus.useQuery(
|
||||
undefined,
|
||||
{ enabled, refetchOnWindowFocus: false, staleTime: 30_000 },
|
||||
);
|
||||
const { data: providers } = api.forwardAuth.listProviders.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: !!deployTarget,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: saveAuthDomain, isPending: isSaving } =
|
||||
api.forwardAuth.setAuthDomain.useMutation();
|
||||
const { mutateAsync: deployOnServer, isPending: isDeploying } =
|
||||
api.forwardAuth.deployOnServer.useMutation();
|
||||
const { mutateAsync: removeOnServer, isPending: isRemoving } =
|
||||
api.forwardAuth.removeOnServer.useMutation();
|
||||
const { mutateAsync: generateDomain, isPending: isGenerating } =
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const keyOf = (serverId: string | null) => serverId ?? "local";
|
||||
|
||||
useEffect(() => {
|
||||
if (!servers) return;
|
||||
setForms((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const srv of servers) {
|
||||
const key = srv.serverId ?? "local";
|
||||
if (next[key] === undefined) {
|
||||
next[key] = {
|
||||
host: srv.authDomain ?? "",
|
||||
https: srv.https ?? true,
|
||||
certificateType: (srv.certificateType ?? "letsencrypt") as CertType,
|
||||
customCertResolver: srv.customCertResolver ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [servers]);
|
||||
|
||||
const hasProviders = (providers?.length ?? 0) > 0;
|
||||
|
||||
const patchForm = (serverId: string | null, patch: Partial<DomainForm>) =>
|
||||
setForms((p) => {
|
||||
const key = keyOf(serverId);
|
||||
const current: DomainForm = p[key] ?? {
|
||||
host: "",
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
customCertResolver: "",
|
||||
};
|
||||
return { ...p, [key]: { ...current, ...patch } };
|
||||
});
|
||||
|
||||
const handleSaveDomain = async (serverId: string | null) => {
|
||||
const f = forms[keyOf(serverId)];
|
||||
if (!f?.host.trim()) {
|
||||
toast.error("Enter an auth domain first");
|
||||
return false;
|
||||
}
|
||||
if (f.certificateType === "custom" && !f.customCertResolver.trim()) {
|
||||
toast.error("Enter the custom certificate resolver");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await saveAuthDomain({
|
||||
serverId,
|
||||
authDomain: f.host.trim(),
|
||||
https: f.https,
|
||||
certificateType: f.certificateType,
|
||||
customCertResolver: f.customCertResolver.trim() || undefined,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error saving auth domain",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!deployTarget || !selectedProviderId) {
|
||||
toast.error("Select an SSO provider first");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const saved = await handleSaveDomain(deployTarget.serverId);
|
||||
if (!saved) return;
|
||||
await deployOnServer({
|
||||
serverId: deployTarget.serverId,
|
||||
providerId: selectedProviderId,
|
||||
});
|
||||
await utils.forwardAuth.serverStatus.invalidate();
|
||||
toast.success("Authentication proxy deployed");
|
||||
setDeployTarget(null);
|
||||
setSelectedProviderId("");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error deploying proxy",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (serverId: string | null) => {
|
||||
try {
|
||||
await removeOnServer({ serverId });
|
||||
await utils.forwardAuth.serverStatus.invalidate();
|
||||
toast.success("Authentication proxy removed");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error removing proxy",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateDomain = async (serverId: string | null) => {
|
||||
try {
|
||||
const host = await generateDomain({
|
||||
appName: "auth",
|
||||
serverId: serverId ?? undefined,
|
||||
});
|
||||
patchForm(serverId, { host, https: false, certificateType: "none" });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error generating domain",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = (status: ServerStatus) => {
|
||||
if (status === "running") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-500/40 text-emerald-500"
|
||||
>
|
||||
<ShieldCheck className="mr-1 size-3" />
|
||||
Running
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === "stopped") {
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<ShieldOff className="mr-1 size-3" />
|
||||
Not deployed
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-500"
|
||||
title="Could not reach this server in time"
|
||||
>
|
||||
<HelpCircle className="mr-1 size-3" />
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<ShieldCheck className="size-5" />
|
||||
Application Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Each server has its own authentication domain and proxy. Set an auth
|
||||
domain (e.g. auth.acme.com) per server, register its callback URL once
|
||||
in your identity provider, then deploy the proxy. Apps on that server
|
||||
under the same base domain are then one click to protect.
|
||||
<span className="mt-2 block font-medium">
|
||||
Only OIDC providers are supported — SAML is not compatible with the
|
||||
forward-auth proxy.
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isPending || !enabled ? (
|
||||
<div className="flex items-center gap-2 justify-center py-6 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span className="text-sm">Checking servers...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{servers?.map((srv) => {
|
||||
const key = keyOf(srv.serverId);
|
||||
const f = forms[key];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{srv.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusBadge(srv.status)}
|
||||
{srv.status === "running" && (
|
||||
<DialogAction
|
||||
title="Remove authentication proxy"
|
||||
description="Domains on this server protected with SSO will stop requiring authentication until re-enabled. Continue?"
|
||||
type="destructive"
|
||||
onClick={() => handleRemove(srv.serverId)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">Auth domain</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="auth.acme.com"
|
||||
value={f?.host ?? ""}
|
||||
onChange={(e) =>
|
||||
patchForm(srv.serverId, { host: e.target.value })
|
||||
}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{f?.host && !f.host.includes("sslip.io") && (
|
||||
<DnsHelperModal
|
||||
domain={{
|
||||
host: f.host,
|
||||
https: f.https,
|
||||
}}
|
||||
serverIp={
|
||||
srv.ipAddress ?? hostIp?.toString() ?? undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
isLoading={isGenerating}
|
||||
title="Generate sslip.io domain"
|
||||
onClick={() => handleGenerateDomain(srv.serverId)}
|
||||
>
|
||||
<Dices className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">
|
||||
Certificate provider
|
||||
</span>
|
||||
<Select
|
||||
value={f?.https ? f.certificateType : "none"}
|
||||
onValueChange={(v) =>
|
||||
patchForm(srv.serverId, {
|
||||
certificateType: v as CertType,
|
||||
https: v !== "none",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (HTTP)</SelectItem>
|
||||
<SelectItem value="letsencrypt">
|
||||
Let's Encrypt
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{f?.certificateType === "custom" && f?.https && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">
|
||||
Custom certificate resolver
|
||||
</span>
|
||||
<Input
|
||||
placeholder="Enter your custom certificate resolver"
|
||||
value={f?.customCertResolver ?? ""}
|
||||
onChange={(e) =>
|
||||
patchForm(srv.serverId, {
|
||||
customCertResolver: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!f?.host?.trim()}
|
||||
onClick={() =>
|
||||
setDeployTarget({
|
||||
serverId: srv.serverId,
|
||||
name: srv.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{srv.callbackUrl && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">
|
||||
Callback URL (register once in your IdP)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={srv.callbackUrl}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
srv.callbackUrl as string,
|
||||
);
|
||||
toast.success("Callback URL copied");
|
||||
}}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog
|
||||
open={!!deployTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeployTarget(null);
|
||||
setSelectedProviderId("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deploy authentication proxy</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deploy the SSO proxy on{" "}
|
||||
<span className="font-medium">{deployTarget?.name}</span> using an
|
||||
OIDC provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!hasProviders && (
|
||||
<AlertBlock type="warning">
|
||||
No SSO providers configured. Add an OIDC provider above first.
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<span className="text-sm font-medium">Identity provider</span>
|
||||
<Select
|
||||
value={selectedProviderId}
|
||||
onValueChange={setSelectedProviderId}
|
||||
disabled={!hasProviders}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an SSO provider">
|
||||
{selectedProviderId || ""}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.map((provider) => (
|
||||
<SelectItem
|
||||
key={provider.providerId}
|
||||
value={provider.providerId}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{provider.providerId}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{provider.issuer}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isSaving || isDeploying}
|
||||
disabled={!hasProviders || !selectedProviderId}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -29,10 +29,15 @@ type SSOEmailForm = z.infer<typeof ssoEmailSchema>;
|
||||
|
||||
interface SignInWithSSOProps {
|
||||
/** Content shown when SSO is collapsed (e.g. email/password form) */
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
/** When true, SSO is the only option — no fallback to email/password */
|
||||
enforce?: boolean;
|
||||
}
|
||||
|
||||
export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
||||
export function SignInWithSSO({
|
||||
children,
|
||||
enforce = false,
|
||||
}: SignInWithSSOProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const form = useForm<SSOEmailForm>({
|
||||
@@ -44,7 +49,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.sso({
|
||||
email: values.email,
|
||||
callbackURL: "/dashboard/projects",
|
||||
callbackURL: "/dashboard/home",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to sign in with SSO");
|
||||
@@ -72,7 +77,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
{children}
|
||||
{!enforce && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,13 +118,15 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Use email and password instead
|
||||
</button>
|
||||
{!enforce && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Use email and password instead
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -342,7 +342,7 @@ export const AdvanceBreadcrumb = () => {
|
||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||
>
|
||||
<FolderInput className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium max-w-[150px] truncate">
|
||||
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||
{currentProject?.name || "Select Project"}
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
@@ -478,7 +478,7 @@ export const AdvanceBreadcrumb = () => {
|
||||
aria-expanded={environmentOpen}
|
||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||
>
|
||||
<span className="font-medium max-w-[150px] truncate">
|
||||
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||
{currentEnvironment?.name || "production"}
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
@@ -533,7 +533,7 @@ export const AdvanceBreadcrumb = () => {
|
||||
)}
|
||||
|
||||
{projectEnvironments && projectEnvironments.length === 1 && (
|
||||
<p className="text-sm font-normal ml-1">
|
||||
<p className="text-sm font-normal ml-1 max-w-[50px] md:max-w-[150px] truncate">
|
||||
{currentEnvironment?.name || "production"}
|
||||
</p>
|
||||
)}
|
||||
@@ -551,7 +551,7 @@ export const AdvanceBreadcrumb = () => {
|
||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||
>
|
||||
{getServiceIcon(currentService.type)}
|
||||
<span className="font-medium max-w-[150px] truncate">
|
||||
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||
{currentService.name}
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
@@ -617,7 +617,7 @@ export const AdvanceBreadcrumb = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 ml-1"
|
||||
className="size-7 ml-1 hidden md:flex"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
|
||||
@@ -167,7 +167,13 @@ export const CodeEditor = ({
|
||||
? css()
|
||||
: language === "shell"
|
||||
? StreamLanguage.define(shell)
|
||||
: StreamLanguage.define(properties),
|
||||
: StreamLanguage.define({
|
||||
...properties,
|
||||
// The legacy properties mode lacks comment metadata, so
|
||||
// CodeMirror's toggle-comment shortcut (Mod-/) has no comment
|
||||
// token to use. Declare `#` as the line comment for env editors.
|
||||
languageData: { commentTokens: { line: "#" } },
|
||||
}),
|
||||
props.lineWrapping ? EditorView.lineWrapping : [],
|
||||
language === "yaml"
|
||||
? autocompletion({
|
||||
|
||||
@@ -116,6 +116,14 @@ export function TagSelector({
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{tags.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags created yet.
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.id);
|
||||
|
||||
@@ -63,6 +63,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
className={cn(
|
||||
buttonVariants({ variant, size, className }),
|
||||
"flex gap-2",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "sendInvoiceNotifications" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "schedule" ADD COLUMN "description" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "remoteServersOnly" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "enforceSSO" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "schedule" DROP CONSTRAINT "schedule_userId_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD COLUMN "organizationId" text;--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
UPDATE "schedule" s
|
||||
SET "organizationId" = m."organization_id"
|
||||
FROM "member" m
|
||||
WHERE s."scheduleType" = 'dokploy-server'
|
||||
AND s."userId" = m."user_id"
|
||||
AND m."role" = 'owner';--> statement-breakpoint
|
||||
ALTER TABLE "schedule" DROP COLUMN "userId";
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "forward_auth_settings" (
|
||||
"forwardAuthSettingsId" text PRIMARY KEY NOT NULL,
|
||||
"authDomain" text NOT NULL,
|
||||
"baseDomain" text NOT NULL,
|
||||
"https" boolean DEFAULT true NOT NULL,
|
||||
"certificateType" "certificateType" DEFAULT 'letsencrypt' NOT NULL,
|
||||
"customCertResolver" text,
|
||||
"providerId" text,
|
||||
"serverId" text,
|
||||
"createdAt" text NOT NULL,
|
||||
CONSTRAINT "forward_auth_settings_serverId_unique" UNIQUE("serverId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "domain" ADD COLUMN "forwardAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "forward_auth_settings" ADD CONSTRAINT "forward_auth_settings_providerId_sso_provider_provider_id_fk" FOREIGN KEY ("providerId") REFERENCES "public"."sso_provider"("provider_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "forward_auth_settings" ADD CONSTRAINT "forward_auth_settings_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "sso_provider" DROP CONSTRAINT "sso_provider_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user