mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
Compare commits
2 Commits
main
..
issue-5151
| Author | SHA1 | Date | |
|---|---|---|---|
| ebeb95cefd | |||
| 2d8dfee9f3 |
@@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree:
|
||||
until it answers (usually ~10-15s).
|
||||
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
|
||||
|
||||
Note: `mcp__dokploy__*` resolves its URL from
|
||||
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
|
||||
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
||||
discovered mid-session. If those tools are unavailable or point at the wrong
|
||||
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"dokploy": {
|
||||
"type": "http",
|
||||
"url": "${DOKPLOY_BASE_URL}/api/mcp",
|
||||
"headers": { "Authorization": "Bearer ${DOKPLOY_TOKEN}" }
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
## Code style
|
||||
- Don't write comments that restate what the code already says.
|
||||
- Comment only the "why" when something isn't obvious: workarounds,
|
||||
counterintuitive decisions, constraints from an external API.
|
||||
- No section-divider comments like `// --- Helpers ---`.
|
||||
- Don't leave comments describing the change you just made.
|
||||
+2
-10
@@ -120,20 +120,12 @@ pnpm run docker:push
|
||||
|
||||
## Password Reset
|
||||
|
||||
In the case you lost your password, you can reset the owner's password using the following command
|
||||
In the case you lost your password, you can reset it using the following command
|
||||
|
||||
```bash
|
||||
pnpm --filter=dokploy run reset-password
|
||||
pnpm run reset-password
|
||||
```
|
||||
|
||||
To reset the password of a specific user instead, pass their email as an argument
|
||||
|
||||
```bash
|
||||
pnpm --filter=dokploy run reset-password user@example.com
|
||||
```
|
||||
|
||||
Both commands print the new randomly generated password to the console.
|
||||
|
||||
If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -6,13 +6,3 @@ npm run dev
|
||||
```
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
## Inngest
|
||||
|
||||
This service uses Inngest to queue deployments. Run the local dev server in a separate terminal:
|
||||
|
||||
```
|
||||
npm run dev:inngest
|
||||
```
|
||||
|
||||
It syncs with the app at `http://localhost:4000/api/inngest` and serves its dashboard at `http://localhost:8288`.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "PORT=4000 tsx watch src/index.ts",
|
||||
"dev:inngest": "npx --yes inngest-cli@1.9.0 dev -u http://localhost:4000/api/inngest --no-discovery",
|
||||
"build": "rimraf dist && tsc --project tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const base = {
|
||||
composeType: "docker-compose" as const,
|
||||
appName: "compose-app",
|
||||
sourceType: "github" as const,
|
||||
command: "",
|
||||
};
|
||||
|
||||
describe("compose createCommand --project-directory", () => {
|
||||
it("pins --project-directory to the code dir when composePath is nested", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
expect(cmd).toContain("-f ./deploy/docker-compose.yml");
|
||||
});
|
||||
|
||||
it("omits --project-directory when no projectPath is passed", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
});
|
||||
|
||||
it("does not add --project-directory to stack deploy (unsupported flag)", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composeType: "stack",
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
expect(cmd.startsWith("stack deploy")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
expect(cmd).toContain("-f docker-compose.yml");
|
||||
});
|
||||
|
||||
it("resolves build.context against the compose file's own directory when there are no mounts", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composePath: "./backend/docker-compose.yml",
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
expect(cmd).toContain("-f ./backend/docker-compose.yml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compose createCommand --env-file", () => {
|
||||
it("points --env-file at the generated .env next to a nested compose file", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--env-file deploy/.env");
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits --env-file when createEnvFile is disabled", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--env-file");
|
||||
});
|
||||
|
||||
it("uses the code-root .env for raw sourceType", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
sourceType: "raw",
|
||||
composePath: "docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--env-file .env");
|
||||
});
|
||||
|
||||
it("does not add --env-file to stack deploy (unsupported flag)", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composeType: "stack",
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--env-file");
|
||||
});
|
||||
});
|
||||
@@ -32,9 +32,6 @@ const cases: Record<string, string> = {
|
||||
APOSTROPHE: "it's a test",
|
||||
UNICODE: "héllo wörld 日本語 🚀",
|
||||
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
|
||||
APP_URL: "https://example.com",
|
||||
ASSET_URL: "https://example.com",
|
||||
DB_HOST: "localhost",
|
||||
};
|
||||
|
||||
// How each value must be typed in the UI so the (unchanged) dotenv input
|
||||
@@ -49,21 +46,9 @@ const inputEncoding: Record<string, string> = {
|
||||
APOSTROPHE: "it's a test",
|
||||
UNICODE: "héllo wörld 日本語 🚀",
|
||||
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
|
||||
APP_URL: "https://example.com",
|
||||
ASSET_URL: '"${APP_URL}"',
|
||||
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
||||
};
|
||||
|
||||
const hasDocker = () => {
|
||||
try {
|
||||
execFileSync("docker", ["info"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
|
||||
describe("getCreateEnvFileCommand", () => {
|
||||
it("writes special environment values that Docker Compose reads back literally", () => {
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
|
||||
@@ -108,4 +93,46 @@ describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
|
||||
expect(actual[key], key).toBe(value);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it("still interpolates a ${VAR} self-reference in the .env file", () => {
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
|
||||
const serviceEnv = [
|
||||
"APP_URL=https://example.com",
|
||||
'ASSET_URL="${APP_URL}"',
|
||||
].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 - ASSET_URL=${ASSET_URL}\n";
|
||||
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
|
||||
|
||||
const out = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"compose",
|
||||
"run",
|
||||
"--rm",
|
||||
"-T",
|
||||
"test",
|
||||
"sh",
|
||||
"-c",
|
||||
"printf '%s' \"$ASSET_URL\"",
|
||||
],
|
||||
{ cwd: codePath, encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(out).toBe("https://example.com");
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -50,15 +50,6 @@ describe("getRailpackCommand", () => {
|
||||
expect(command).toContain("--build-arg cache-key=");
|
||||
});
|
||||
|
||||
it("installs Railpack through sudo for non-root users", () => {
|
||||
const command = getRailpackCommand(createApplication());
|
||||
|
||||
expect(command).toContain(
|
||||
'$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"',
|
||||
);
|
||||
expect(command).toContain("sudo -n true 2>/dev/null");
|
||||
});
|
||||
|
||||
it("changes secrets-hash when an environment value changes", () => {
|
||||
const firstCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
|
||||
@@ -16,30 +16,6 @@ describe("shouldDeploy", () => {
|
||||
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("should apply multiple negated watch paths as one pattern set", () => {
|
||||
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should deploy when a changed file remains after exclusions", () => {
|
||||
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true);
|
||||
});
|
||||
|
||||
it("should combine positive and negated watch paths", () => {
|
||||
const watchPaths = ["src/**", "!src/**/*.test.ts"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true);
|
||||
expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("should not throw when modified files contain non-string values", () => {
|
||||
expect(() =>
|
||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||
|
||||
@@ -87,224 +87,6 @@ describe("cloudflareClient.listRecords", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient MX priority", () => {
|
||||
it("inlines the priority into the content when listing", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
cfSuccess([
|
||||
{
|
||||
id: "mx-1",
|
||||
type: "MX",
|
||||
name: "example.com",
|
||||
content: "mail.example.com",
|
||||
ttl: 300,
|
||||
priority: 20,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await cloudflareClient.listRecords(config, "zone-1");
|
||||
|
||||
expect(records[0]?.content).toBe("20 mail.example.com");
|
||||
});
|
||||
|
||||
it("splits the priority back out when writing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "MX",
|
||||
name: "example.com",
|
||||
content: "20 mail.example.com",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
content: "mail.example.com",
|
||||
priority: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to priority 10 when the content has no leading number", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "MX",
|
||||
name: "example.com",
|
||||
content: "mail.example.com",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
content: "mail.example.com",
|
||||
priority: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves non-MX content untouched", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: "10 not-a-priority",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.content).toBe("10 not-a-priority");
|
||||
expect(body.priority).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient structured records", () => {
|
||||
const stubCreate = () =>
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "rec-1" }));
|
||||
|
||||
it("sends SRV values as structured data", async () => {
|
||||
stubCreate();
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "SRV",
|
||||
name: "_sip._tcp.example.com",
|
||||
content: "1 10 5269 talk.example.com",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.data).toEqual({
|
||||
priority: 1,
|
||||
weight: 10,
|
||||
port: 5269,
|
||||
target: "talk.example.com",
|
||||
});
|
||||
expect(body.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends CAA values as structured data and drops the quotes", async () => {
|
||||
stubCreate();
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "CAA",
|
||||
name: "example.com",
|
||||
content: '0 issue "letsencrypt.org"',
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.data).toEqual({
|
||||
flags: 0,
|
||||
tag: "issue",
|
||||
value: "letsencrypt.org",
|
||||
});
|
||||
expect(body.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an SRV value that is missing a part", async () => {
|
||||
await expect(
|
||||
cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "SRV",
|
||||
name: "_sip._tcp.example.com",
|
||||
content: "1 10 5269",
|
||||
}),
|
||||
).rejects.toThrow(/SRV value/);
|
||||
});
|
||||
|
||||
it("rejects a CAA value that has no tag", async () => {
|
||||
await expect(
|
||||
cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "CAA",
|
||||
name: "example.com",
|
||||
content: "0",
|
||||
}),
|
||||
).rejects.toThrow(/CAA value/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient proxy status", () => {
|
||||
it("sends the proxy status for proxiable types", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
proxied: true,
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string).proxied).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the proxy status for types Cloudflare cannot proxy", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: "hello",
|
||||
proxied: true,
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string).proxied).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the proxy status untouched when the caller does not set it", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect("proxied" in JSON.parse(init.body as string)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the proxy status when listing records", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
cfSuccess([
|
||||
{
|
||||
id: "a-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 1,
|
||||
proxied: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await cloudflareClient.listRecords(config, "zone-1");
|
||||
|
||||
expect(records[0]?.proxied).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.upsertRecord", () => {
|
||||
it("creates a record when none exists for the name/type", async () => {
|
||||
mockFetch
|
||||
@@ -323,11 +105,9 @@ describe("cloudflareClient.upsertRecord", () => {
|
||||
expect(createInit.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("updates the existing record when content matches", async () => {
|
||||
it("updates the existing record instead of creating a duplicate", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
@@ -346,25 +126,6 @@ describe("cloudflareClient.upsertRecord", () => {
|
||||
expect(updateInit.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "new-2" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-2" });
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(createInit.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
|
||||
@@ -1,486 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
|
||||
|
||||
const ikPage = (data: unknown, page: number, pages: number) =>
|
||||
jsonResponse({ result: "success", data, page, pages });
|
||||
|
||||
const ikError = (description: string, status = 400) =>
|
||||
jsonResponse(
|
||||
{ result: "error", error: { code: "not_authorized", description } },
|
||||
false,
|
||||
status,
|
||||
);
|
||||
|
||||
const config = {
|
||||
providerType: "infomaniak" as const,
|
||||
apiToken: "ik_test_token",
|
||||
};
|
||||
|
||||
const lastCall = () =>
|
||||
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
|
||||
|
||||
const lastBody = () => JSON.parse(lastCall()[1].body as string);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("infomaniakClient.listZones", () => {
|
||||
it("exposes each domain product as a zone keyed by its name", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikPage(
|
||||
[
|
||||
{ id: 1, customer_name: "example.com" },
|
||||
{ id: 2, customer_name: "example.ch" },
|
||||
],
|
||||
1,
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([
|
||||
{ id: "example.com", name: "example.com" },
|
||||
{ id: "example.ch", name: "example.ch" },
|
||||
]);
|
||||
const [url, init] = lastCall();
|
||||
// The documented endpoint is the plural one; the singular is legacy and
|
||||
// returns no pagination metadata at all.
|
||||
expect(url).toContain("/1/products?service_name=domain");
|
||||
expect(url).toContain("page=1");
|
||||
expect(init.headers).toMatchObject({
|
||||
Authorization: "Bearer ik_test_token",
|
||||
});
|
||||
});
|
||||
|
||||
it("walks every page so accounts with many domains keep all their zones", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
|
||||
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
|
||||
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
|
||||
});
|
||||
|
||||
it("stops after a single page when the response has no pagination", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates the API error description", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||
|
||||
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
|
||||
"Authorization required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.listRecords", () => {
|
||||
it("rebuilds the fqdn from the relative source", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "10",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 300,
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "5.6.7.8",
|
||||
ttl: 600,
|
||||
},
|
||||
]);
|
||||
expect(lastCall()[0]).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.name).toBe("example.com");
|
||||
});
|
||||
|
||||
it("unquotes TXT targets", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 13,
|
||||
type: "TXT",
|
||||
source: "_acme-challenge",
|
||||
target: '"token-value"',
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.content).toBe("token-value");
|
||||
});
|
||||
|
||||
it("leaves a CAA target untouched", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 14,
|
||||
type: "CAA",
|
||||
source: "",
|
||||
target: '0 issue "letsencrypt.org"',
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.upsertRecord", () => {
|
||||
it("creates the record when no matching source and type exists", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "42" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(lastBody()).toEqual({
|
||||
type: "A",
|
||||
source: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the existing record when content matches", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "7" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(lastBody().ttl).toBe(300);
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "50" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||
expect(init.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com.",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(lastBody().source).toBe(".");
|
||||
});
|
||||
|
||||
it.each([".", "", "@"])(
|
||||
"matches an existing apex record stored with a %s source",
|
||||
async (source) => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "8" });
|
||||
expect(lastCall()[1].method).toBe("PUT");
|
||||
},
|
||||
);
|
||||
|
||||
it("matches the existing apex record instead of creating a duplicate", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 8,
|
||||
type: "TXT",
|
||||
source: ".",
|
||||
target: '"v=spf1 -all"',
|
||||
ttl: 3600,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: "v=spf1 -all",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "8" });
|
||||
const [url, init] = lastCall();
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/8",
|
||||
);
|
||||
expect(lastBody().target).toBe('"v=spf1 -all"');
|
||||
});
|
||||
|
||||
it("quotes a TXT target on write", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "_acme-challenge.example.com",
|
||||
content: "token-value",
|
||||
});
|
||||
|
||||
expect(lastBody().target).toBe('"token-value"');
|
||||
});
|
||||
|
||||
it("does not double-quote a TXT target that is already quoted", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "_acme-challenge.example.com",
|
||||
content: '"token-value"',
|
||||
});
|
||||
|
||||
expect(lastBody().target).toBe('"token-value"');
|
||||
});
|
||||
|
||||
it("queries the API with a source and type filter instead of the whole zone", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [url] = mockFetch.mock.calls[0] as [string];
|
||||
expect(url).toContain("filter%5Bsource%5D=app");
|
||||
expect(url).toContain("filter%5Btypes%5D%5B%5D=A");
|
||||
});
|
||||
|
||||
it("ignores a partial filter hit rather than overwriting a different record", async () => {
|
||||
// filter[source] matches substrings: asking for "auto" also returns
|
||||
// "autoconfig" and "autodiscover". Trusting it would overwrite one of them.
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 61,
|
||||
type: "CNAME",
|
||||
source: "autoconfig",
|
||||
target: "a.example.net",
|
||||
ttl: 300,
|
||||
},
|
||||
{
|
||||
id: 62,
|
||||
type: "CNAME",
|
||||
source: "autodiscover",
|
||||
target: "b.example.net",
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 63 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "CNAME",
|
||||
name: "auto.example.com",
|
||||
content: "c.example.net",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "63" });
|
||||
expect(lastCall()[1].method).toBe("POST");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.updateRecord", () => {
|
||||
it("updates the record and keeps its id", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||
|
||||
const result = await infomaniakClient.updateRecord(
|
||||
config,
|
||||
"example.com",
|
||||
"7",
|
||||
{
|
||||
type: "CNAME",
|
||||
name: "www.example.com",
|
||||
content: "example.com",
|
||||
ttl: 900,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: "7" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(lastBody()).toEqual({
|
||||
type: "CNAME",
|
||||
source: "www",
|
||||
target: "example.com",
|
||||
ttl: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default ttl when none is provided", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||
|
||||
await infomaniakClient.updateRecord(config, "example.com", "7", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(lastBody().ttl).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.deleteRecord", () => {
|
||||
it("deletes the record", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess(null));
|
||||
|
||||
await infomaniakClient.deleteRecord(config, "example.com", "7");
|
||||
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("propagates a delete failure", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Record not found", 404));
|
||||
|
||||
await expect(
|
||||
infomaniakClient.deleteRecord(config, "example.com", "7"),
|
||||
).rejects.toThrow("Record not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.testConnection", () => {
|
||||
it("resolves when the domain listing succeeds", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess([]));
|
||||
|
||||
await expect(
|
||||
infomaniakClient.testConnection(config),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects on an invalid token", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||
|
||||
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
|
||||
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,581 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { ovhClient } from "@dokploy/server/utils/dns/ovh";
|
||||
|
||||
const textResponse = (body: string, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
text: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const ovhSuccess = (data: unknown) =>
|
||||
textResponse(data === undefined ? "" : JSON.stringify(data));
|
||||
|
||||
const ovhError = (message: string, status = 403) =>
|
||||
textResponse(JSON.stringify({ message }), false, status);
|
||||
|
||||
const SERVER_TIME = 1788268225;
|
||||
|
||||
const config = {
|
||||
providerType: "ovh" as const,
|
||||
endpoint: "ovh-eu" as const,
|
||||
applicationKey: "app-key",
|
||||
applicationSecret: "app-secret",
|
||||
consumerKey: "consumer-key",
|
||||
};
|
||||
|
||||
// Each test uses a distinct endpoint so the module-level clock-skew cache, which
|
||||
// is keyed by base url, never leaks a measurement between them.
|
||||
let endpointCursor = 0;
|
||||
const endpoints = [
|
||||
"ovh-eu",
|
||||
"ovh-ca",
|
||||
"ovh-us",
|
||||
"kimsufi-eu",
|
||||
"kimsufi-ca",
|
||||
"soyoustart-eu",
|
||||
"soyoustart-ca",
|
||||
] as const;
|
||||
const baseUrls: Record<(typeof endpoints)[number], string> = {
|
||||
"ovh-eu": "https://eu.api.ovh.com/1.0",
|
||||
"ovh-ca": "https://ca.api.ovh.com/1.0",
|
||||
"ovh-us": "https://api.us.ovhcloud.com/1.0",
|
||||
"kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
|
||||
"kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
|
||||
"soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
|
||||
"soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
|
||||
};
|
||||
|
||||
/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */
|
||||
const freshConfig = () => {
|
||||
const endpoint = endpoints[
|
||||
endpointCursor % endpoints.length
|
||||
] as (typeof endpoints)[number];
|
||||
endpointCursor += 1;
|
||||
return { ...config, endpoint, baseUrl: baseUrls[endpoint] };
|
||||
};
|
||||
|
||||
/** Replies to /auth/time, then to each queued API response in order. */
|
||||
const mockApi = (...responses: Response[]) => {
|
||||
let call = 0;
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url.endsWith("/auth/time")) {
|
||||
return Promise.resolve(textResponse(String(SERVER_TIME)));
|
||||
}
|
||||
const response = responses[call];
|
||||
call += 1;
|
||||
return Promise.resolve(response ?? ovhSuccess(null));
|
||||
});
|
||||
};
|
||||
|
||||
const apiCalls = () =>
|
||||
mockFetch.mock.calls.filter(
|
||||
([url]) => !(url as string).endsWith("/auth/time"),
|
||||
) as [string, RequestInit][];
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("ovhClient request signing", () => {
|
||||
it("signs the request with the API server clock, not the local one", async () => {
|
||||
const { baseUrl, ...cfg } = freshConfig();
|
||||
mockApi(ovhSuccess(["example.com"]));
|
||||
vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000);
|
||||
|
||||
await ovhClient.listZones(cfg);
|
||||
|
||||
const [url, init] = apiCalls()[0] as [string, RequestInit];
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(url).toBe(`${baseUrl}/domain/zone`);
|
||||
expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME));
|
||||
expect(headers["X-Ovh-Application"]).toBe("app-key");
|
||||
expect(headers["X-Ovh-Consumer"]).toBe("consumer-key");
|
||||
|
||||
const expected = createHash("sha1")
|
||||
.update(
|
||||
["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"),
|
||||
)
|
||||
.digest("hex");
|
||||
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("signs a request body when one is sent", async () => {
|
||||
const { baseUrl, ...cfg } = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null));
|
||||
vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000);
|
||||
|
||||
await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [url, init] = apiCalls()[1] as [string, RequestInit];
|
||||
const headers = init.headers as Record<string, string>;
|
||||
const body = init.body as string;
|
||||
expect(body).not.toBe("");
|
||||
const expected = createHash("sha1")
|
||||
.update(
|
||||
["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join(
|
||||
"+",
|
||||
),
|
||||
)
|
||||
.digest("hex");
|
||||
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.listZones", () => {
|
||||
it("maps each zone name to a zone", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess(["example.com", "example.fr"]));
|
||||
|
||||
const zones = await ovhClient.listZones(cfg);
|
||||
|
||||
expect(zones).toEqual([
|
||||
{ id: "example.com", name: "example.com" },
|
||||
{ id: "example.fr", name: "example.fr" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the missing root right when OVH refuses the zone listing", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("This call has not been granted", 403));
|
||||
|
||||
// A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
|
||||
// so the raw OVH message would send users looking in the wrong place.
|
||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow(
|
||||
/missing the `GET \/domain\/zone` right/,
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates the API error message", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("Invalid signature", 403));
|
||||
|
||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.listRecords", () => {
|
||||
it("resolves each id into a full record", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess([1, 2]),
|
||||
ovhSuccess({
|
||||
id: 1,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 600,
|
||||
}),
|
||||
ovhSuccess({
|
||||
id: 2,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: null,
|
||||
target: "5.6.7.8",
|
||||
ttl: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const records = await ovhClient.listRecords(cfg, "example.com");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "5.6.7.8",
|
||||
ttl: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the records in the order of the returned ids", async () => {
|
||||
const cfg = freshConfig();
|
||||
const record = (id: number, subDomain: string) => ({
|
||||
id,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain,
|
||||
target: `10.0.0.${id}`,
|
||||
ttl: 60,
|
||||
});
|
||||
mockApi(
|
||||
ovhSuccess([1, 2, 3, 4, 5]),
|
||||
...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))),
|
||||
);
|
||||
|
||||
const records = await ovhClient.listRecords(cfg, "example.com");
|
||||
|
||||
expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.upsertRecord", () => {
|
||||
it("creates the record then refreshes the zone", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||
|
||||
const result = await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "9" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[0]?.[0]).toContain(
|
||||
"/domain/zone/example.com/record?fieldType=A&subDomain=app",
|
||||
);
|
||||
expect(calls[1]?.[1].method).toBe("POST");
|
||||
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh");
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
});
|
||||
|
||||
it("updates the existing record when content matches", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess([4]),
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "4" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4");
|
||||
expect(calls[2]?.[1].method).toBe("PUT");
|
||||
expect(calls[3]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess([4]),
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess({ id: 10 }),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "10" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
expect(calls[3]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("omits the ttl so OVH applies the zone default", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||
|
||||
await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty(
|
||||
"ttl",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes an empty subDomain for the apex and strips the trailing dot", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||
|
||||
await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com.",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(apiCalls()[0]?.[0]).toContain("subDomain=");
|
||||
expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.updateRecord", () => {
|
||||
it("updates in place when the type is unchanged", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "4" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[1]?.[1].method).toBe("PUT");
|
||||
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 300,
|
||||
});
|
||||
expect(calls[2]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("replaces the record when the type changes, since PUT carries no fieldType", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess({ id: 11 }),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "example.com",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "11" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[1]?.[1].method).toBe("DELETE");
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME");
|
||||
expect(calls[3]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("restores the original record when the replacement fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
const original = {
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
};
|
||||
mockApi(
|
||||
ovhSuccess(original),
|
||||
ovhSuccess(null),
|
||||
ovhError("Invalid target", 400),
|
||||
ovhSuccess({ id: 12 }),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "not a valid target",
|
||||
}),
|
||||
).rejects.toThrow("Invalid target");
|
||||
|
||||
const calls = apiCalls();
|
||||
expect(calls[1]?.[1].method).toBe("DELETE");
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
// The original record is put back with its own type, target and ttl.
|
||||
expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
});
|
||||
expect(calls[4]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("reports the lost record when the restore also fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhError("Invalid target", 400),
|
||||
ovhError("Service unavailable", 503),
|
||||
);
|
||||
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "not a valid target",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/,
|
||||
);
|
||||
});
|
||||
|
||||
it("says the change was applied when only the zone refresh fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess({ id: 11 }),
|
||||
ovhError("Service unavailable", 503),
|
||||
);
|
||||
|
||||
// The replacement succeeded, so the record exists at the provider — only
|
||||
// publishing failed. Rolling back would destroy correct state.
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "example.com",
|
||||
}),
|
||||
).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/);
|
||||
});
|
||||
|
||||
it("does not tell the user to recreate a record that was restored but not published", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null), // DELETE de l'ancien
|
||||
ovhError("Invalid target", 400), // POST de remplacement -> échec
|
||||
ovhSuccess({ id: 12 }), // POST de restauration -> succès
|
||||
ovhError("Service unavailable", 503), // refresh -> échec
|
||||
);
|
||||
|
||||
const attempt = ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "not a valid target",
|
||||
});
|
||||
|
||||
// L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait.
|
||||
await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/);
|
||||
await expect(attempt).rejects.not.toThrow(/Recreate it manually/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.deleteRecord", () => {
|
||||
it("deletes the record then refreshes the zone", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess(null), ovhSuccess(null));
|
||||
|
||||
await ovhClient.deleteRecord(cfg, "example.com", "4");
|
||||
|
||||
const calls = apiCalls();
|
||||
expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4");
|
||||
expect(calls[0]?.[1].method).toBe("DELETE");
|
||||
expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh");
|
||||
});
|
||||
|
||||
it("does not refresh the zone when the delete fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("This object does not exist", 404));
|
||||
|
||||
await expect(
|
||||
ovhClient.deleteRecord(cfg, "example.com", "4"),
|
||||
).rejects.toThrow("This object does not exist");
|
||||
expect(apiCalls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.testConnection", () => {
|
||||
it("resolves when the zone listing succeeds", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]));
|
||||
|
||||
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects on invalid credentials", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("Invalid signature", 403));
|
||||
|
||||
await expect(ovhClient.testConnection(cfg)).rejects.toThrow(
|
||||
"Invalid signature",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,237 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { porkbunClient } from "@dokploy/server/utils/dns/porkbun";
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const pbSuccess = (result: Record<string, unknown> = {}) =>
|
||||
jsonResponse({ status: "SUCCESS", ...result });
|
||||
|
||||
const pbError = (message: string, status = 400) =>
|
||||
jsonResponse({ status: "ERROR", message }, false, status);
|
||||
|
||||
const config = {
|
||||
providerType: "porkbun" as const,
|
||||
apiKey: "pk1_test",
|
||||
secretApiKey: "sk1_test",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("porkbunClient.listZones", () => {
|
||||
it("lists all domains as zones", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
pbSuccess({ domains: [{ domain: "example.com" }] }),
|
||||
);
|
||||
|
||||
const zones = await porkbunClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/domain/listAll");
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body).toMatchObject({
|
||||
apikey: "pk1_test",
|
||||
secretapikey: "sk1_test",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.listRecords", () => {
|
||||
it("lists records for a domain", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
pbSuccess({
|
||||
records: [
|
||||
{
|
||||
id: "1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: "600",
|
||||
prio: "0",
|
||||
notes: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const records = await porkbunClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
},
|
||||
]);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.upsertRecord", () => {
|
||||
it("creates a record when none exists for the name/type", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
|
||||
|
||||
const result = await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-1" });
|
||||
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
|
||||
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app");
|
||||
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(createUrl).toContain("/dns/create/example.com");
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body).toMatchObject({ name: "app", type: "A", content: "1.2.3.4" });
|
||||
});
|
||||
|
||||
it("resolves the apex domain to an empty subdomain", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
|
||||
|
||||
await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
|
||||
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
|
||||
});
|
||||
|
||||
it("edits the existing record when content matches", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
|
||||
)
|
||||
.mockResolvedValueOnce(pbSuccess({}));
|
||||
|
||||
const result = await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "existing-1" });
|
||||
const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }),
|
||||
)
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
|
||||
|
||||
const result = await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-2" });
|
||||
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(createUrl).toContain("/dns/create/example.com");
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" });
|
||||
});
|
||||
|
||||
it("defaults ttl to 600 when not provided", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
|
||||
|
||||
await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "CNAME",
|
||||
name: "www.example.com",
|
||||
content: "example.com",
|
||||
});
|
||||
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body.ttl).toBe(600);
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.updateRecord", () => {
|
||||
it("edits the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||
|
||||
const result = await porkbunClient.updateRecord(
|
||||
config,
|
||||
"example.com",
|
||||
"1",
|
||||
{
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: "1" });
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/dns/edit/example.com/1");
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
name: "app",
|
||||
type: "A",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.deleteRecord", () => {
|
||||
it("posts to the delete endpoint for the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||
|
||||
await porkbunClient.deleteRecord(config, "example.com", "1");
|
||||
|
||||
const [url] = mockFetch.mock.calls[0] as [string];
|
||||
expect(url).toContain("/dns/delete/example.com/1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.testConnection", () => {
|
||||
it("succeeds when the credentials can ping the API", async () => {
|
||||
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||
await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces Porkbun's error message on invalid credentials", async () => {
|
||||
mockFetch.mockResolvedValue(pbError("Invalid API key."));
|
||||
|
||||
await expect(porkbunClient.testConnection(config)).rejects.toThrow(
|
||||
"Invalid API key.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -142,15 +142,13 @@ describe("route53Client.listRecords", () => {
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
|
||||
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.upsertRecord", () => {
|
||||
it("sends a single UPSERT change when nothing exists yet", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
it("sends a single UPSERT change", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
const result = await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
@@ -160,7 +158,7 @@ describe("route53Client.upsertRecord", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "A:app.example.com" });
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
expect(command.input.HostedZoneId).toBe("Z123");
|
||||
expect(command.input.ChangeBatch.Changes).toEqual([
|
||||
{
|
||||
@@ -174,82 +172,6 @@ describe("route53Client.upsertRecord", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the values already in the record set", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "3.3.3.3",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([
|
||||
{ Value: "1.1.1.1" },
|
||||
{ Value: "2.2.2.2" },
|
||||
{ Value: "3.3.3.3" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate a value that is already in the record set", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.1.1.1",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([{ Value: "1.1.1.1" }]);
|
||||
});
|
||||
|
||||
it("wraps unquoted TXT values in double quotes", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: 'v=spf1 ~all\n"already quoted"',
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.updateRecord", () => {
|
||||
@@ -300,25 +222,6 @@ describe("route53Client.updateRecord", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps every line of a multi-value record set", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "NS:example.com", {
|
||||
type: "NS",
|
||||
name: "example.com",
|
||||
content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([
|
||||
{ Value: "ns1.example.com" },
|
||||
{ Value: "ns2.example.com" },
|
||||
{ Value: "ns3.example.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the DELETE when the old record no longer exists", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import os from "node:os";
|
||||
import {
|
||||
getServerIpCandidates,
|
||||
validateDomain,
|
||||
} from "@dokploy/server/services/domain";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execAsyncRemote: vi.fn(),
|
||||
findServerById: vi.fn(),
|
||||
getPublicIpWithFallback: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
resolve4: vi.fn(),
|
||||
resolve6: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:dns", () => ({
|
||||
default: {
|
||||
resolve4: mocks.resolve4,
|
||||
resolve6: mocks.resolve6,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: mocks.findServerById,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/wss/utils", () => ({
|
||||
getPublicIpWithFallback: mocks.getPublicIpWithFallback,
|
||||
}));
|
||||
|
||||
describe("getServerIpCandidates", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("includes every address reported by a multi-homed remote server", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
ipAddress: "10.0.0.10",
|
||||
});
|
||||
mocks.execAsyncRemote.mockResolvedValue({
|
||||
stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
await expect(getServerIpCandidates("server-id")).resolves.toEqual([
|
||||
"10.0.0.10",
|
||||
"192.0.2.10",
|
||||
"2001:db8::10",
|
||||
]);
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
|
||||
"server-id",
|
||||
expect.stringContaining("ip -o addr show scope global"),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes every address assigned to the local Dokploy host", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
serverIp: "10.0.0.10",
|
||||
});
|
||||
mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
|
||||
vi.spyOn(os, "networkInterfaces").mockReturnValue({
|
||||
eth0: [
|
||||
{
|
||||
address: "192.0.2.10",
|
||||
netmask: "255.255.255.0",
|
||||
family: "IPv4",
|
||||
mac: "00:00:00:00:00:00",
|
||||
internal: false,
|
||||
cidr: "192.0.2.10/24",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(getServerIpCandidates()).resolves.toEqual([
|
||||
"10.0.0.10",
|
||||
"192.0.2.10",
|
||||
"2001:db8::10",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains remote interface addresses when public IP detection times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
ipAddress: "10.0.0.10",
|
||||
});
|
||||
mocks.execAsyncRemote.mockImplementation(
|
||||
(_serverId: string, command: string) => {
|
||||
if (command.includes("curl")) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: "192.0.2.10\n",
|
||||
stderr: "",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const candidatesPromise = getServerIpCandidates("server-id");
|
||||
await vi.advanceTimersByTimeAsync(7000);
|
||||
|
||||
await expect(candidatesPromise).resolves.toEqual([
|
||||
"10.0.0.10",
|
||||
"192.0.2.10",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDomain", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("validates an IPv6-only domain against an IPv6 server address", async () => {
|
||||
const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
|
||||
code: "ENODATA",
|
||||
});
|
||||
mocks.resolve4.mockImplementation(
|
||||
(_domain: string, callback: (error: Error | null) => void) =>
|
||||
callback(noIpv4),
|
||||
);
|
||||
mocks.resolve6.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (error: Error | null, addresses?: string[]) => void,
|
||||
) => callback(null, ["2001:db8::10"]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
validateDomain("example.com", ["2001:db8::10"]),
|
||||
).resolves.toMatchObject({
|
||||
isValid: true,
|
||||
resolvedIp: "2001:db8::10",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type HasInput = { input: Record<string, unknown> };
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
|
||||
const {
|
||||
send,
|
||||
paginate,
|
||||
SSMClient,
|
||||
GetParametersCommand,
|
||||
DescribeParametersCommand,
|
||||
} = vi.hoisted(() => {
|
||||
class FakeCommand {
|
||||
input: Record<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input;
|
||||
}
|
||||
}
|
||||
const send = vi.fn();
|
||||
const paginate = vi.fn();
|
||||
class SSMClient {
|
||||
send(command: unknown) {
|
||||
return send(command);
|
||||
}
|
||||
}
|
||||
return {
|
||||
send,
|
||||
paginate,
|
||||
SSMClient,
|
||||
GetParametersCommand: class extends FakeCommand {},
|
||||
DescribeParametersCommand: class extends FakeCommand {},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@aws-sdk/client-ssm", () => ({
|
||||
SSMClient,
|
||||
GetParametersCommand,
|
||||
DescribeParametersCommand,
|
||||
paginateDescribeParameters: paginate,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
vaultProvider: {
|
||||
findMany: (...args: unknown[]) => findMany(...args),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { resolveVaultReferences } from "@dokploy/server/utils/vault";
|
||||
import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
|
||||
|
||||
const config = {
|
||||
providerType: "aws-parameter-store" as const,
|
||||
region: "eu-central-1",
|
||||
accessKeyId: "AKIA_TEST",
|
||||
secretAccessKey: "secret",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
send.mockReset();
|
||||
paginate.mockReset();
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("awsParameterStoreClient", () => {
|
||||
it("decrypts parameters in batches of ten and preserves selectors", async () => {
|
||||
const refs = [
|
||||
"/prod/database:CURRENT",
|
||||
...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
|
||||
];
|
||||
send.mockImplementation(async (command: HasInput) => ({
|
||||
Parameters: (command.input.Names as string[]).map((ref) => {
|
||||
if (ref === "/prod/database:CURRENT") {
|
||||
return {
|
||||
Name: "/prod/database",
|
||||
Selector: ":CURRENT",
|
||||
Value: "selected-value",
|
||||
};
|
||||
}
|
||||
return { Name: ref, Value: `value-for-${ref}` };
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await awsParameterStoreClient.getSecrets(config, refs);
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
|
||||
WithDecryption: true,
|
||||
});
|
||||
expect(
|
||||
((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
|
||||
).toBe(10);
|
||||
expect(
|
||||
((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
|
||||
).toBe(1);
|
||||
expect(result["/prod/database:CURRENT"]).toBe("selected-value");
|
||||
expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
|
||||
});
|
||||
|
||||
it("reports a missing parameter without exposing other values", async () => {
|
||||
send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
|
||||
|
||||
await expect(
|
||||
awsParameterStoreClient.getSecrets(config, ["/missing"]),
|
||||
).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
|
||||
});
|
||||
|
||||
it("tests the connection within the configured hierarchy", async () => {
|
||||
send.mockResolvedValue({ Parameters: [] });
|
||||
|
||||
await awsParameterStoreClient.testConnection({
|
||||
...config,
|
||||
parameterPath: "/production/my-app/",
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
|
||||
ParameterFilters: [
|
||||
{
|
||||
Key: "Path",
|
||||
Option: "Recursive",
|
||||
Values: ["/production/my-app"],
|
||||
},
|
||||
],
|
||||
MaxResults: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains the discovery permission when connection testing is denied", async () => {
|
||||
const error = new Error("not authorized");
|
||||
error.name = "AccessDeniedException";
|
||||
send.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
awsParameterStoreClient.testConnection(config),
|
||||
).rejects.toThrow("ssm:DescribeParameters");
|
||||
});
|
||||
|
||||
it("lists parameter names across pages within the configured hierarchy", async () => {
|
||||
paginate.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { Parameters: [{ Name: "/prod/db" }] };
|
||||
yield { Parameters: [{ Name: "/prod/api" }] };
|
||||
})(),
|
||||
);
|
||||
|
||||
const names = await awsParameterStoreClient.listSecretNames?.({
|
||||
...config,
|
||||
parameterPath: " /production/my-app/ ",
|
||||
});
|
||||
|
||||
expect(names).toEqual(["/prod/db", "/prod/api"]);
|
||||
expect(paginate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pageSize: 50 }),
|
||||
{
|
||||
ParameterFilters: [
|
||||
{
|
||||
Key: "Path",
|
||||
Option: "Recursive",
|
||||
Values: ["/production/my-app"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a vault reference through the registered provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "ssm-prod",
|
||||
providerType: "aws-parameter-store",
|
||||
config,
|
||||
assignments: [{ projectId: "project-1", environmentIds: [] }],
|
||||
},
|
||||
]);
|
||||
send.mockResolvedValue({
|
||||
Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
|
||||
});
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
|
||||
{
|
||||
organizationId: "organization-1",
|
||||
projectId: "project-1",
|
||||
environmentId: "environment-1",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_PASSWORD=resolved");
|
||||
});
|
||||
});
|
||||
+51
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForFile,
|
||||
prepareEnvironmentVariablesForShell,
|
||||
} from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -642,3 +643,53 @@ SPECIAL=café résumé naïve
|
||||
expect(resolved[2]).toContain("café");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareEnvironmentVariablesForFile (.env file escaping)", () => {
|
||||
it("keeps a well-formed ${VAR} self-reference interpolatable", () => {
|
||||
const serviceEnv = `
|
||||
APP_URL=https://example.com
|
||||
ASSET_URL=\${APP_URL}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([
|
||||
`APP_URL="https://example.com"`,
|
||||
`ASSET_URL="\${APP_URL}"`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a ${VAR:-default} self-reference with a modifier interpolatable", () => {
|
||||
const serviceEnv = `ASSET_URL=\${APP_URL:-https://default.com}`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`ASSET_URL="\${APP_URL:-https://default.com}"`]);
|
||||
});
|
||||
|
||||
it("escapes a literal dollar sign followed by a word so it isn't interpolated", () => {
|
||||
const serviceEnv = "PASSWORD=pa$word";
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`PASSWORD="pa\\$word"`]);
|
||||
});
|
||||
|
||||
it("escapes a trailing dollar sign", () => {
|
||||
const serviceEnv = "PRICE=100$";
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`PRICE="100\\$"`]);
|
||||
});
|
||||
|
||||
it("still escapes double quotes and backslashes", () => {
|
||||
const serviceEnv = String.raw`MESSAGE=say "hi" and \backslash`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([
|
||||
String.raw`MESSAGE="say \"hi\" and \\backslash"`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
-274
@@ -20,8 +20,6 @@ import {
|
||||
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 { infisicalClient } from "@dokploy/server/utils/vault/infisical";
|
||||
import { phaseClient } from "@dokploy/server/utils/vault/phase";
|
||||
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
@@ -432,152 +430,6 @@ describe("azure client", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("infisical client", () => {
|
||||
const config = {
|
||||
providerType: "infisical" as const,
|
||||
siteUrl: "https://app.infisical.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "client-secret",
|
||||
projectId: "workspace-1",
|
||||
environmentSlug: "prod",
|
||||
secretPath: "/frontend",
|
||||
};
|
||||
|
||||
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
|
||||
const list = (secrets: Record<string, string>) =>
|
||||
jsonResponse({
|
||||
secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
|
||||
secretKey,
|
||||
secretValue,
|
||||
})),
|
||||
});
|
||||
const listPathOf = (callIndex: number) => {
|
||||
const [url] = mockFetch.mock.calls[callIndex] as [string];
|
||||
return new URL(url).searchParams.get("secretPath");
|
||||
};
|
||||
|
||||
it("asks the list endpoint to expand secret references", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://real" });
|
||||
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||
const params = new URL(listUrl).searchParams;
|
||||
expect(params.get("expandSecretReferences")).toBe("true");
|
||||
expect(params.get("workspaceId")).toBe("workspace-1");
|
||||
expect(params.get("environment")).toBe("prod");
|
||||
expect(params.get("secretPath")).toBe("/frontend");
|
||||
});
|
||||
|
||||
it("throws a clear error for a missing secret", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(jsonResponse({ secrets: [] }));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["ABSENT"]),
|
||||
).rejects.toThrow('secret "ABSENT" not found in environment "prod"');
|
||||
});
|
||||
|
||||
it("propagates authentication failures with the status code", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["DB_URL"]),
|
||||
).rejects.toThrow("authentication failed (status 401)");
|
||||
});
|
||||
|
||||
it("resolves a relative <path>:<KEY> ref against the provider path", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, [
|
||||
"shared/sentry:SENTRY_DSN",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
|
||||
});
|
||||
expect(listPathOf(1)).toBe("/frontend/shared/sentry");
|
||||
});
|
||||
|
||||
it("treats a leading slash as an absolute path", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||
|
||||
await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
|
||||
|
||||
expect(listPathOf(1)).toBe("/external/sentry");
|
||||
});
|
||||
|
||||
it("keeps the root path clean when the provider sits at /", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ KEY: "value" }));
|
||||
|
||||
await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
|
||||
"external/sentry:KEY",
|
||||
]);
|
||||
|
||||
expect(listPathOf(1)).toBe("/external/sentry");
|
||||
});
|
||||
|
||||
it("logs in once and fetches each path once", async () => {
|
||||
const byPath: Record<string, Record<string, string>> = {
|
||||
"/frontend": { A: "a", B: "b" },
|
||||
"/frontend/other": { C: "c" },
|
||||
};
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/auth/universal-auth/login")) return loginResponse();
|
||||
const path = new URL(url).searchParams.get("secretPath") as string;
|
||||
return list(byPath[path] ?? {});
|
||||
});
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, [
|
||||
"A",
|
||||
"B",
|
||||
"other:C",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
|
||||
|
||||
const urls = mockFetch.mock.calls.map(([url]) => url as string);
|
||||
expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
|
||||
expect(
|
||||
urls
|
||||
.filter((u) => u.includes("/secrets/raw?"))
|
||||
.map((u) => new URL(u).searchParams.get("secretPath"))
|
||||
.sort(),
|
||||
).toEqual(["/frontend", "/frontend/other"]);
|
||||
});
|
||||
|
||||
it("names the path when a secret is missing from an explicit one", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({}));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
|
||||
).rejects.toThrow(
|
||||
'secret "ABSENT" not found at "/frontend/external/sentry"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a ref with an empty path or key", async () => {
|
||||
await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
|
||||
"expected format <path>:<KEY>",
|
||||
);
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["external/sentry:"]),
|
||||
).rejects.toThrow("expected format <path>:<KEY>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("doppler client", () => {
|
||||
it("propagates auth errors with the status code", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
||||
@@ -764,129 +616,3 @@ describe("scaleway client", () => {
|
||||
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase client", () => {
|
||||
const config = {
|
||||
providerType: "phase" as const,
|
||||
token: "phase-rest-token",
|
||||
appId: "app-123",
|
||||
env: "Production",
|
||||
path: "/",
|
||||
apiUrl: "https://api.phase.dev",
|
||||
};
|
||||
|
||||
it("fetches secrets by key and sends ServiceAccount auth", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ key: "DB_URL", value: "postgres://real", path: "/" },
|
||||
{ key: "API_KEY", value: "key-123", path: "/" },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
|
||||
);
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer ServiceAccount phase-rest-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when a requested secret is missing", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]),
|
||||
);
|
||||
|
||||
await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow(
|
||||
'secret "MISSING" not found in environment "Production"',
|
||||
);
|
||||
});
|
||||
|
||||
it("reports authentication failures with the status code", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ error: "unauthorized" }, false, 401),
|
||||
);
|
||||
|
||||
await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow(
|
||||
"authentication failed (status 401: unauthorized)",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects apps without SSE enabled during testConnection", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "app-123",
|
||||
name: "My App",
|
||||
sseEnabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(phaseClient.testConnection(config)).rejects.toThrow(
|
||||
"enable Server-side Encryption (SSE)",
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/apps/app-123/",
|
||||
);
|
||||
});
|
||||
|
||||
it("tests connection against the app then secrets listing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "app-123",
|
||||
name: "My App",
|
||||
sseEnabled: true,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse([]));
|
||||
|
||||
await phaseClient.testConnection(config);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/apps/app-123/",
|
||||
);
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists secret names from the configured path", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ key: "DB_URL", value: "x", path: "/" },
|
||||
{ key: "API_KEY", value: "y", path: "/" },
|
||||
]),
|
||||
);
|
||||
|
||||
const names = await phaseClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toEqual(["DB_URL", "API_KEY"]);
|
||||
});
|
||||
|
||||
it("resolves env refs end to end through a phase provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "phase-prod",
|
||||
providerType: "phase",
|
||||
config,
|
||||
assignments: assignedEverywhere,
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]),
|
||||
);
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}",
|
||||
scope,
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,13 +126,6 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
|
||||
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied server.terminal", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied registry.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
|
||||
@@ -105,7 +105,6 @@ describe("enterprise resources for static roles", () => {
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.server.read).toBe(false);
|
||||
expect(perms.server.terminal).toBe(false);
|
||||
expect(perms.registry.read).toBe(false);
|
||||
expect(perms.certificate.read).toBe(false);
|
||||
expect(perms.destination.read).toBe(false);
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (role: string) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
accessedServers: [] as string[],
|
||||
canCreateProjects: false,
|
||||
canDeleteProjects: false,
|
||||
canCreateServices: false,
|
||||
canDeleteServices: false,
|
||||
canCreateEnvironments: false,
|
||||
canDeleteEnvironments: false,
|
||||
canAccessToTraefikFiles: false,
|
||||
canAccessToDocker: false,
|
||||
canAccessToAPI: false,
|
||||
canAccessToSSHKeys: false,
|
||||
canAccessToGitProviders: false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn = mockMemberData("deployer");
|
||||
let rolesToReturn: { permission: string }[] = [];
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve(rolesToReturn)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
const { checkPermission, resolvePermissions } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
const withPermissions = (permissions: Record<string, string[]>) => {
|
||||
rolesToReturn = [{ permission: JSON.stringify(permissions) }];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
memberToReturn = mockMemberData("deployer");
|
||||
rolesToReturn = [];
|
||||
});
|
||||
|
||||
describe("server.terminal on custom roles", () => {
|
||||
it("a role with server.read alone cannot open a terminal", async () => {
|
||||
withPermissions({ server: ["read"] });
|
||||
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.read).toBe(true);
|
||||
expect(perms.server.terminal).toBe(false);
|
||||
});
|
||||
|
||||
it("a role with server.terminal can open a terminal", async () => {
|
||||
withPermissions({ server: ["read", "terminal"] });
|
||||
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.terminal).toBe(true);
|
||||
});
|
||||
|
||||
it("owner and admin keep terminal access", async () => {
|
||||
for (const role of ["owner", "admin"]) {
|
||||
memberToReturn = mockMemberData(role);
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.terminal).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -64,21 +64,6 @@ const serviceExists = async (name: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Swarm keeps converging a service for a bit after it's created (scheduling
|
||||
// tasks, resolving endpoints), which bumps Version.Index on its own. Calling
|
||||
// setupMonitoring again before that settles races that internal bump, so wait
|
||||
// for two consecutive reads to agree before treating the service as stable.
|
||||
const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastIndex: string | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const inspect = await docker.getService(name).inspect();
|
||||
if (inspect.Version.Index === lastIndex) return;
|
||||
lastIndex = inspect.Version.Index;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
};
|
||||
|
||||
const swarmTaskNames = async () => {
|
||||
const list = await docker.listContainers({ all: true });
|
||||
return list
|
||||
@@ -154,16 +139,7 @@ const hasRealMonitoring = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const hasDocker = () => {
|
||||
try {
|
||||
execSync("docker info", { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)(
|
||||
describe.skipIf(hasRealMonitoring())(
|
||||
"setupMonitoring - legacy container cleanup (real docker)",
|
||||
() => {
|
||||
beforeEach(async () => {
|
||||
@@ -217,7 +193,6 @@ describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)(
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
await waitForServiceConvergence(SERVICE_NAME);
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
|
||||
@@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = {
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
ogImageUrl: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execAsyncRemote: vi.fn(),
|
||||
writeFileRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
writeFileRemote: mocks.writeFileRemote,
|
||||
};
|
||||
});
|
||||
|
||||
describe("writeAppTraefikConfig", () => {
|
||||
let cwd: string;
|
||||
let dynamicPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-"));
|
||||
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
|
||||
fs.mkdirSync(dynamicPath, { recursive: true });
|
||||
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Regression test for #5189: Traefik's file provider rejects a standalone
|
||||
// `routers: {}` / `services: {}` map and aborts its watcher for every
|
||||
// dynamic config once it hits one, so an app with no domains must never
|
||||
// get an on-disk config file at all.
|
||||
it("removes the file instead of writing empty routers/services", async () => {
|
||||
const appName = "no-domain-app";
|
||||
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||
fs.writeFileSync(configPath, "stale content", "utf8");
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{ http: { routers: {}, services: {} } },
|
||||
appName,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the file when routers/services are present", async () => {
|
||||
const appName = "with-domain-app";
|
||||
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
[`${appName}-router-1`]: {
|
||||
rule: "Host(`x`)",
|
||||
service: `${appName}-service`,
|
||||
},
|
||||
},
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
appName,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes the remote file instead of writing empty routers/services", async () => {
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{ http: { routers: {}, services: {} } },
|
||||
"no-domain-app",
|
||||
"server-id",
|
||||
);
|
||||
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
|
||||
expect(command).toMatch(/^rm -f /);
|
||||
expect(command).toContain("no-domain-app.yml");
|
||||
});
|
||||
|
||||
it("writes the remote file when routers/services are present", async () => {
|
||||
mocks.writeFileRemote.mockResolvedValue(undefined);
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
"with-domain-app-router-1": {
|
||||
rule: "Host(`x`)",
|
||||
service: "with-domain-app-service",
|
||||
},
|
||||
},
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
"with-domain-app",
|
||||
"server-id",
|
||||
);
|
||||
|
||||
expect(mocks.writeFileRemote).toHaveBeenCalledOnce();
|
||||
const [serverId, remotePath, content] =
|
||||
mocks.writeFileRemote.mock.calls[0] ?? [];
|
||||
expect(serverId).toBe("server-id");
|
||||
expect(remotePath).toContain("with-domain-app.yml");
|
||||
expect(content).toContain("with-domain-app-router-1");
|
||||
});
|
||||
});
|
||||
@@ -95,35 +95,10 @@ describe("canAccessTerminalOverWss", () => {
|
||||
});
|
||||
|
||||
it("gates a remote server terminal on server access", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
||||
// the remote path must never fall through to the owner/admin local branch
|
||||
// role lookup must not be needed for the remote path
|
||||
expect(mockFindMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies a remote server terminal without the server.terminal permission", async () => {
|
||||
// Reaching a server (to deploy on it) must not imply a root shell on it.
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(false);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false);
|
||||
expect(mockHasPermission).toHaveBeenCalledWith(
|
||||
{ user: { id: USER.id }, session: { activeOrganizationId: "org-1" } },
|
||||
{ server: ["terminal"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a remote server terminal with the server.terminal permission", async () => {
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not check permissions for a server the caller cannot access", async () => {
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
||||
expect(mockHasPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,3 +8,4 @@ export { RestartPolicyForm } from "./restart-policy-form";
|
||||
export { RollbackConfigForm } from "./rollback-config-form";
|
||||
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||
export { UpdateConfigForm } from "./update-config-form";
|
||||
export { filterEmptyValues, hasValues } from "./utils";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Filters out undefined, null, and empty string values from form data
|
||||
* Only returns fields that have actual values
|
||||
*/
|
||||
export const filterEmptyValues = (
|
||||
formData: Record<string, any>,
|
||||
): Record<string, any> => {
|
||||
return Object.entries(formData).reduce(
|
||||
(acc, [key, value]) => {
|
||||
// Keep arrays even if empty (they might be intentionally cleared)
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
acc[key] = value;
|
||||
}
|
||||
}
|
||||
// For other values, filter out undefined, null, and empty strings
|
||||
else if (value !== undefined && value !== null && value !== "") {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if filtered data has any values to save
|
||||
*/
|
||||
export const hasValues = (data: Record<string, any>): boolean => {
|
||||
return Object.keys(data).length > 0;
|
||||
};
|
||||
@@ -31,45 +31,6 @@ import { api } from "@/utils/api";
|
||||
|
||||
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||
export const RAILPACK_VERSIONS = [
|
||||
"0.39.0",
|
||||
"0.38.0",
|
||||
"0.37.1",
|
||||
"0.37.0",
|
||||
"0.36.4",
|
||||
"0.36.3",
|
||||
"0.36.2",
|
||||
"0.36.1",
|
||||
"0.36.0",
|
||||
"0.35.0",
|
||||
"0.34.0",
|
||||
"0.33.0",
|
||||
"0.32.0",
|
||||
"0.31.2",
|
||||
"0.31.1",
|
||||
"0.31.0",
|
||||
"0.30.1",
|
||||
"0.30.0",
|
||||
"0.29.0",
|
||||
"0.28.0",
|
||||
"0.27.2",
|
||||
"0.27.1",
|
||||
"0.27.0",
|
||||
"0.26.1",
|
||||
"0.26.0",
|
||||
"0.25.0",
|
||||
"0.24.0",
|
||||
"0.23.0",
|
||||
"0.22.2",
|
||||
"0.22.1",
|
||||
"0.22.0",
|
||||
"0.21.0",
|
||||
"0.20.0",
|
||||
"0.19.0",
|
||||
"0.18.0",
|
||||
"0.17.2",
|
||||
"0.17.1",
|
||||
"0.17.0",
|
||||
"0.16.0",
|
||||
"0.15.4",
|
||||
"0.15.3",
|
||||
"0.15.2",
|
||||
@@ -84,22 +45,12 @@ export const RAILPACK_VERSIONS = [
|
||||
"0.9.1",
|
||||
"0.9.0",
|
||||
"0.8.0",
|
||||
"0.7.2",
|
||||
"0.7.1",
|
||||
"0.7.0",
|
||||
"0.6.1",
|
||||
"0.6.0",
|
||||
"0.5.1",
|
||||
"0.5.0",
|
||||
"0.4.0",
|
||||
"0.3.0",
|
||||
"0.2.3",
|
||||
"0.2.2",
|
||||
"0.2.1",
|
||||
"0.2.0",
|
||||
"0.1.2",
|
||||
"0.1.1",
|
||||
"0.1.0",
|
||||
] as const;
|
||||
|
||||
export enum BuildType {
|
||||
|
||||
@@ -342,7 +342,7 @@ export const ShowDeployments = ({
|
||||
)}
|
||||
{/* Hash (from description) - shown in compact form */}
|
||||
{deployment.description?.trim() && (
|
||||
<span className="wrap-anywhere text-xs text-muted-foreground font-mono">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
domainId,
|
||||
},
|
||||
{
|
||||
enabled: isOpen && !!domainId,
|
||||
enabled: !!domainId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
applicationId: id,
|
||||
},
|
||||
{
|
||||
enabled: isOpen && !!id,
|
||||
enabled: !!id,
|
||||
},
|
||||
)
|
||||
: api.compose.one.useQuery(
|
||||
@@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
composeId: id,
|
||||
},
|
||||
{
|
||||
enabled: isOpen && !!id,
|
||||
enabled: !!id,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -187,14 +187,9 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const { data: canGenerateTraefikMeDomains } =
|
||||
api.domain.canGenerateTraefikMeDomains.useQuery(
|
||||
{
|
||||
serverId: application?.serverId || "",
|
||||
},
|
||||
{
|
||||
enabled: isOpen,
|
||||
},
|
||||
);
|
||||
api.domain.canGenerateTraefikMeDomains.useQuery({
|
||||
serverId: application?.serverId || "",
|
||||
});
|
||||
|
||||
const {
|
||||
data: services,
|
||||
@@ -209,7 +204,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: isOpen && type === "compose" && !!id,
|
||||
enabled: type === "compose" && !!id,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -191,7 +191,8 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
try {
|
||||
const result = await validateDomain({
|
||||
domain: host,
|
||||
serverId: application?.serverId ?? undefined,
|
||||
serverIp:
|
||||
application?.server?.ipAddress?.toString() || ip?.toString() || "",
|
||||
});
|
||||
|
||||
setValidationStates((prev) => ({
|
||||
|
||||
@@ -6,7 +6,6 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -198,18 +197,6 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
name="environment"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex justify-end">
|
||||
<VaultImportDialog
|
||||
projectId={data?.environment?.projectId}
|
||||
environmentId={data?.environment?.environmentId}
|
||||
currentEnv={field.value ?? ""}
|
||||
onImport={(next) =>
|
||||
form.setValue("environment", next, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormControl className="">
|
||||
<CodeEditor
|
||||
style={
|
||||
|
||||
@@ -151,8 +151,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
}
|
||||
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
|
||||
completionSource={completionSource}
|
||||
projectId={data?.environment?.projectId}
|
||||
environmentId={data?.environment?.environmentId}
|
||||
/>
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -13,7 +14,6 @@ import { Dropzone } from "@/components/ui/dropzone";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
||||
import { api } from "@/utils/api";
|
||||
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||
|
||||
interface ShowIconSettingsProps {
|
||||
serviceId: string;
|
||||
@@ -89,6 +89,15 @@ export const ShowIconSettings = ({
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeSvg = (svgContent: string): string | null => {
|
||||
const clean = DOMPurify.sanitize(svgContent, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ["use"],
|
||||
});
|
||||
if (!clean) return null;
|
||||
return `data:image/svg+xml;base64,${btoa(clean)}`;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const file = files[0];
|
||||
|
||||
@@ -102,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-2">
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
<Label>Select a container to view logs</Label>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Rocket } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const TrialBanner = () => {
|
||||
const router = useRouter();
|
||||
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||
const { mutateAsync: createCustomerPortalSession, isPending } =
|
||||
api.stripe.createCustomerPortalSession.useMutation();
|
||||
|
||||
const handleAddPaymentMethod = async () => {
|
||||
const session = await createCustomerPortalSession().catch(() => null);
|
||||
if (session?.url) {
|
||||
window.open(session.url);
|
||||
return;
|
||||
}
|
||||
router.push("/dashboard/settings/billing");
|
||||
};
|
||||
|
||||
if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
|
||||
const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 mx-4 mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Rocket className="h-4 w-4 text-primary shrink-0" />
|
||||
{daysRemaining > 0
|
||||
? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.`
|
||||
: "Your free trial ends today. Add a payment method to keep your servers."}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7"
|
||||
isLoading={isPending}
|
||||
onClick={handleAddPaymentMethod}
|
||||
>
|
||||
Add payment method
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
HardDriveDownload,
|
||||
RefreshCcw,
|
||||
Rocket,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import { toast } from "sonner";
|
||||
@@ -89,51 +82,6 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && data?.composeType === "docker-compose" && (
|
||||
<DialogAction
|
||||
title="Deploy with Fresh Volumes"
|
||||
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
composeId: composeId,
|
||||
freshVolumes: true,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose deployed with fresh volumes");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<HardDriveDownload className="size-4 mr-1" />
|
||||
Fresh Volumes
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Deploy with fresh volumes (removes all persistent data)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Rebuild Compose"
|
||||
|
||||
@@ -55,7 +55,6 @@ export const ShowDockerLogsStack = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName && option === "swarm",
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -68,7 +67,6 @@ export const ShowDockerLogsStack = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName && option === "native",
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
@@ -53,15 +52,14 @@ export const ShowDockerLogsCompose = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName,
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
setContainerId((currentContainerId) =>
|
||||
resolveContainerSelection(currentContainerId, data),
|
||||
);
|
||||
if (data && data?.length > 0) {
|
||||
setContainerId(data[0]?.containerId);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -352,11 +352,11 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-full sm:w-auto"
|
||||
className="h-9"
|
||||
onClick={handlePauseResume}
|
||||
title={isPaused ? "Resume logs" : "Pause logs"}
|
||||
>
|
||||
@@ -372,7 +372,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-full sm:w-auto"
|
||||
className="h-9"
|
||||
onClick={handleCopy}
|
||||
disabled={filteredLogs.length === 0}
|
||||
title="Copy logs to clipboard"
|
||||
@@ -389,7 +389,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-full sm:w-auto"
|
||||
className="h-9 sm:w-auto w-full"
|
||||
onClick={handleDownload}
|
||||
disabled={filteredLogs.length === 0 || !data?.Name}
|
||||
title="Download logs as text file"
|
||||
@@ -418,7 +418,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[50vh] sm:h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||
>
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((filteredLog: LogLine, index: number) => (
|
||||
|
||||
@@ -64,9 +64,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
|
||||
const tooltip = (color: string, timestamp: string | null) => {
|
||||
const square = (
|
||||
<div
|
||||
className={cn("w-2 min-h-4 h-full flex-shrink-0 rounded-[3px]", color)}
|
||||
/>
|
||||
<div className={cn("w-2 h-full shrink-0 rounded-[3px]", color)} />
|
||||
);
|
||||
return timestamp ? (
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||
@@ -90,7 +88,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"font-mono text-xs flex flex-col sm:flex-row gap-1 sm:gap-3 py-2 sm:py-0.5 group",
|
||||
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
|
||||
type === "error"
|
||||
? "bg-red-500/10 hover:bg-red-500/15"
|
||||
: type === "warning"
|
||||
@@ -106,7 +104,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
|
||||
{tooltip(color, rawTimestamp)}
|
||||
{!noTimestamp && (
|
||||
<span className="select-none pl-2 text-muted-foreground w-auto sm:w-40 flex-shrink-0">
|
||||
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 shrink-0">
|
||||
{formattedTime}
|
||||
</span>
|
||||
)}
|
||||
|
||||
+1
-6
@@ -1,6 +1,5 @@
|
||||
import { formatMb } from "@dokploy/server/monitoring/units";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -168,8 +167,6 @@ export const ContainerFreeMonitoring = ({
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appName) return;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
@@ -199,9 +196,7 @@ export const ContainerFreeMonitoring = ({
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
if (e.reason) {
|
||||
toast.error(e.reason);
|
||||
}
|
||||
console.log(e.reason);
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
|
||||
@@ -19,11 +19,6 @@ interface NetworkChartProps {
|
||||
data: any[];
|
||||
}
|
||||
|
||||
function formatNetworkGB(valueInMB: number) {
|
||||
if (Number.isNaN(valueInMB)) return "0";
|
||||
return (valueInMB / 1024).toFixed(1);
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
networkIn: {
|
||||
label: "Network In",
|
||||
@@ -43,8 +38,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
<CardHeader className="border-b py-5">
|
||||
<CardTitle>Network</CardTitle>
|
||||
<CardDescription>
|
||||
Network Total: ↑ {formatNetworkGB(latestData.networkOut)} GB ↓{" "}
|
||||
{formatNetworkGB(latestData.networkIn)} GB (since Boot)
|
||||
Network Traffic: ↑ {latestData.networkOut} KB/s ↓{" "}
|
||||
{latestData.networkIn} KB/s
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
@@ -88,7 +83,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatTimestamp(value)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${formatNetworkGB(value)} GB`} />
|
||||
<YAxis tickFormatter={(value) => `${value} KB/s`} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
@@ -110,8 +105,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
Network
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
↑ {formatNetworkGB(data.networkOut)} GB
|
||||
<br />↓ {formatNetworkGB(data.networkIn)} GB
|
||||
↑ {data.networkOut} KB/s
|
||||
<br />↓ {data.networkIn} KB/s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,6 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
const importMutation = api.network.import.useMutation();
|
||||
const removeMutation = api.network.remove.useMutation();
|
||||
const recreateMutation = api.network.recreate.useMutation();
|
||||
const resyncMutation = api.network.resync.useMutation();
|
||||
|
||||
const toggleSelected = (name: string) => {
|
||||
setSelected((prev) => {
|
||||
@@ -103,20 +102,6 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onResync = async (networkId: string, name: string) => {
|
||||
try {
|
||||
await resyncMutation.mutateAsync({ networkId });
|
||||
toast.success(`Network "${name}" updated from Docker`);
|
||||
await utils.network.all.invalidate();
|
||||
await utils.network.networksToSync.invalidate();
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error("Error updating network", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -192,45 +177,6 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!data?.changed.length && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Changed ({data.changed.length})
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
These networks were deleted and recreated in Docker under
|
||||
the same name — attributes in Dokploy are outdated.
|
||||
</span>
|
||||
{data.changed.map((changed) => (
|
||||
<div
|
||||
key={changed.networkId}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm">{changed.name}</span>
|
||||
{changed.driver && (
|
||||
<Badge variant="outline">{changed.driver}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
isLoading={resyncMutation.isPending}
|
||||
onClick={() =>
|
||||
onResync(changed.networkId, changed.name)
|
||||
}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!data?.missing.length && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Fraunces } from "next/font/google";
|
||||
|
||||
export const displayFont = Fraunces({
|
||||
subsets: ["latin"],
|
||||
weight: ["600"],
|
||||
style: ["normal", "italic"],
|
||||
display: "swap",
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
const ONBOARDING_ACTIVE_KEY = "dokploy_onboarding_active";
|
||||
const ONBOARDING_STATE_KEY = "dokploy_onboarding_state";
|
||||
|
||||
interface OnboardingState {
|
||||
stepId?: string;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
}
|
||||
|
||||
export const isOnboardingActive = () => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(ONBOARDING_ACTIVE_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const markOnboardingActive = () => {
|
||||
try {
|
||||
window.localStorage.setItem(ONBOARDING_ACTIVE_KEY, "true");
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const clearOnboardingActive = () => {
|
||||
try {
|
||||
window.localStorage.removeItem(ONBOARDING_ACTIVE_KEY);
|
||||
window.localStorage.removeItem(ONBOARDING_STATE_KEY);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const getOnboardingState = (): OnboardingState => {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ONBOARDING_STATE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const setOnboardingState = (state: OnboardingState) => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
ONBOARDING_STATE_KEY,
|
||||
JSON.stringify({ ...getOnboardingState(), ...state }),
|
||||
);
|
||||
} catch {}
|
||||
};
|
||||
@@ -1,296 +0,0 @@
|
||||
import { defineStepper } from "@stepperize/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import { getOnboardingState, setOnboardingState } from "./onboarding-lock";
|
||||
import { CompleteStep } from "./steps/complete-step";
|
||||
import { DeployStep } from "./steps/deploy-step";
|
||||
import { PlanStep } from "./steps/plan-step";
|
||||
import { ProjectStep } from "./steps/project-step";
|
||||
import { ServerStep } from "./steps/server-step";
|
||||
import { WelcomeStep } from "./steps/welcome-step";
|
||||
|
||||
const { useStepper, steps, Scoped } = defineStepper(
|
||||
{ id: "welcome", title: "Welcome" },
|
||||
{ id: "plan", title: "Pick a plan" },
|
||||
{ id: "project", title: "New project" },
|
||||
{ id: "server", title: "Connect server" },
|
||||
{ id: "deploy", title: "Ship something" },
|
||||
{ id: "complete", title: "You're live" },
|
||||
);
|
||||
|
||||
type StepId = (typeof steps)[number]["id"];
|
||||
const isStepId = (id: string | undefined): id is StepId =>
|
||||
!!id && steps.some((step) => step.id === id);
|
||||
|
||||
interface Props {
|
||||
onClose: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const OnboardingWizard = ({ onClose }: Props) => {
|
||||
const router = useRouter();
|
||||
const persisted = getOnboardingState();
|
||||
const stepper = useStepper(
|
||||
isStepId(persisted.stepId) ? persisted.stepId : undefined,
|
||||
);
|
||||
const [projectId, setProjectId] = useState<string | undefined>(
|
||||
persisted.projectId,
|
||||
);
|
||||
const [environmentId, setEnvironmentId] = useState<string | undefined>(
|
||||
persisted.environmentId,
|
||||
);
|
||||
|
||||
const { data: isCloud = true } = api.settings.isCloud.useQuery();
|
||||
const visibleStepIds = isCloud
|
||||
? steps.map((step) => step.id)
|
||||
: steps
|
||||
.filter((step) => step.id !== "plan" && step.id !== "server")
|
||||
.map((step) => step.id);
|
||||
const visibleIndex = visibleStepIds.indexOf(stepper.current.id);
|
||||
const isLastVisible = visibleIndex === visibleStepIds.length - 1;
|
||||
const goToNextVisible = () => {
|
||||
const nextId = visibleStepIds[visibleIndex + 1];
|
||||
if (nextId) stepper.goTo(nextId);
|
||||
};
|
||||
|
||||
const [skipAllOpen, setSkipAllOpen] = useState(false);
|
||||
const handleSkipAll = async () => {
|
||||
await onClose();
|
||||
router.push(
|
||||
projectId && environmentId
|
||||
? `/dashboard/project/${projectId}/environment/${environmentId}`
|
||||
: "/dashboard/projects",
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setOnboardingState({ stepId: stepper.current.id });
|
||||
}, [stepper.current.id]);
|
||||
|
||||
const { error: projectCheckError } = api.project.one.useQuery(
|
||||
{ projectId: projectId ?? "" },
|
||||
{ enabled: !!projectId, retry: false },
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!projectCheckError || !projectId) return;
|
||||
setProjectId(undefined);
|
||||
setEnvironmentId(undefined);
|
||||
setOnboardingState({ projectId: undefined, environmentId: undefined });
|
||||
stepper.goTo("project");
|
||||
}, [projectCheckError, projectId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex flex-col md:flex-row bg-background text-foreground">
|
||||
<aside className="relative shrink-0 md:w-[300px] lg:w-[340px] overflow-hidden bg-zinc-950 text-zinc-400">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.07]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px)",
|
||||
backgroundSize: "28px 28px",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute -top-24 -left-24 size-72 rounded-full bg-white/10 blur-[100px]" />
|
||||
|
||||
<div className="relative flex md:h-full flex-col p-6 lg:p-8">
|
||||
<div className="flex items-center justify-between md:block">
|
||||
<div className="flex items-center gap-2.5 text-zinc-50 [&_path]:!fill-white [&_path]:!stroke-white">
|
||||
<Logo className="size-7" />
|
||||
<span className="text-lg font-semibold tracking-tight">
|
||||
Dokploy
|
||||
</span>
|
||||
</div>
|
||||
{!isLastVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSkipAllOpen(true)}
|
||||
className="font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors md:hidden"
|
||||
>
|
||||
Skip all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Scoped>
|
||||
<ol className="hidden md:flex flex-col gap-0.5 mt-12">
|
||||
{visibleStepIds.map((id, index) => {
|
||||
const step = steps.find((s) => s.id === id)!;
|
||||
const isDone = index < visibleIndex;
|
||||
const isCurrent = stepper.current.id === step.id;
|
||||
return (
|
||||
<li key={step.id} className="flex gap-3.5">
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={`flex items-center justify-center font-mono text-[11px] tabular-nums size-5 rounded-full ${
|
||||
isCurrent
|
||||
? "bg-white text-zinc-950 font-semibold"
|
||||
: isDone
|
||||
? "text-zinc-100"
|
||||
: "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckIcon className="size-3" />
|
||||
) : (
|
||||
String(index + 1).padStart(2, "0")
|
||||
)}
|
||||
</span>
|
||||
{index < visibleStepIds.length - 1 && (
|
||||
<span
|
||||
className={`w-px flex-1 min-h-7 my-1.5 ${
|
||||
isDone ? "bg-zinc-400" : "bg-zinc-700"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isDone ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stepper.goTo(step.id)}
|
||||
className="text-sm font-medium transition-colors pb-6 last:pb-0 text-zinc-100 hover:text-white text-left"
|
||||
>
|
||||
{step.title}
|
||||
</button>
|
||||
) : (
|
||||
<p
|
||||
className={`text-sm font-medium transition-colors pb-6 last:pb-0 ${
|
||||
isCurrent ? "text-white" : "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Scoped>
|
||||
|
||||
<div className="hidden md:flex flex-col gap-4 mt-auto pt-8 border-t border-zinc-800">
|
||||
{!isLastVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSkipAllOpen(true)}
|
||||
className="w-fit font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
Skip all →
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-zinc-500">
|
||||
<Link
|
||||
href="https://github.com/dokploy/dokploy"
|
||||
target="_blank"
|
||||
className="hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<GithubIcon />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://x.com/getdokploy"
|
||||
target="_blank"
|
||||
className="hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-4"
|
||||
>
|
||||
<path d="M10.4883 14.651L15.25 21H22.25L14.3917 10.5223L20.9308 3H18.2808L13.1643 8.88578L8.75 3H1.75L9.26086 13.0145L2.31915 21H4.96917L10.4883 14.651ZM16.25 19L5.75 5H7.75L18.25 19H16.25Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://discord.com/invite/2tBnJ3jDJc"
|
||||
target="_blank"
|
||||
className="hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
className="size-4"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M39.248,10.177c-2.804-1.287-5.812-2.235-8.956-2.778c-0.057-0.01-0.114,0.016-0.144,0.068 c-0.387,0.688-0.815,1.585-1.115,2.291c-3.382-0.506-6.747-0.506-10.059,0c-0.3-0.721-0.744-1.603-1.133-2.291 c-0.03-0.051-0.087-0.077-0.144-0.068c-3.143,0.541-6.15,1.489-8.956,2.778c-0.024,0.01-0.045,0.028-0.059,0.051 c-5.704,8.522-7.267,16.835-6.5,25.044c0.003,0.04,0.026,0.079,0.057,0.103c3.763,2.764,7.409,4.442,10.987,5.554 c0.057,0.017,0.118-0.003,0.154-0.051c0.846-1.156,1.601-2.374,2.248-3.656c0.038-0.075,0.002-0.164-0.076-0.194 c-1.197-0.454-2.336-1.007-3.432-1.636c-0.087-0.051-0.094-0.175-0.014-0.234c0.231-0.173,0.461-0.353,0.682-0.534 c0.04-0.033,0.095-0.04,0.142-0.019c7.201,3.288,14.997,3.288,22.113,0c0.047-0.023,0.102-0.016,0.144,0.017 c0.22,0.182,0.451,0.363,0.683,0.536c0.08,0.059,0.075,0.183-0.012,0.234c-1.096,0.641-2.236,1.182-3.434,1.634 c-0.078,0.03-0.113,0.12-0.075,0.196c0.661,1.28,1.415,2.498,2.246,3.654c0.035,0.049,0.097,0.07,0.154,0.052 c3.595-1.112,7.241-2.79,11.004-5.554c0.033-0.024,0.054-0.061,0.057-0.101c0.917-9.491-1.537-17.735-6.505-25.044 C39.293,10.205,39.272,10.187,39.248,10.177z M16.703,30.273c-2.168,0-3.954-1.99-3.954-4.435s1.752-4.435,3.954-4.435 c2.22,0,3.989,2.008,3.954,4.435C20.658,28.282,18.906,30.273,16.703,30.273z M31.324,30.273c-2.168,0-3.954-1.99-3.954-4.435 s1.752-4.435,3.954-4.435c2.22,0,3.989,2.008,3.954,4.435C35.278,28.282,33.544,30.273,31.324,30.273z"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-2xl px-6 py-14 lg:py-20">
|
||||
{stepper.switch({
|
||||
welcome: () => <WelcomeStep onNext={goToNextVisible} />,
|
||||
plan: () => <PlanStep onNext={goToNextVisible} />,
|
||||
project: () => (
|
||||
<ProjectStep
|
||||
onNext={(project) => {
|
||||
setProjectId(project.projectId);
|
||||
setEnvironmentId(project.environmentId);
|
||||
setOnboardingState({
|
||||
projectId: project.projectId,
|
||||
environmentId: project.environmentId,
|
||||
});
|
||||
goToNextVisible();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
server: () => <ServerStep onNext={goToNextVisible} />,
|
||||
deploy: () => (
|
||||
<DeployStep
|
||||
environmentId={environmentId}
|
||||
onNext={goToNextVisible}
|
||||
/>
|
||||
),
|
||||
complete: () => (
|
||||
<CompleteStep
|
||||
projectId={projectId}
|
||||
environmentId={environmentId}
|
||||
onFinish={onClose}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={skipAllOpen} onOpenChange={setSkipAllOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skip onboarding?</DialogTitle>
|
||||
<DialogDescription>
|
||||
If this is your first time using Dokploy, we recommend going
|
||||
through these steps – it only takes a couple of minutes and gives
|
||||
you a feel for how projects, servers, and deployments fit
|
||||
together.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSkipAllOpen(false)}>
|
||||
Continue setup
|
||||
</Button>
|
||||
<Button onClick={handleSkipAll}>Skip anyway</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
import {
|
||||
BookIcon,
|
||||
DatabaseIcon,
|
||||
GitMergeIcon,
|
||||
GlobeIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import ConfettiExplosion from "react-confetti-explosion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
interface Props {
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
onFinish: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: DatabaseIcon,
|
||||
title: "Databases",
|
||||
description: "Postgres, MySQL, MongoDB, Redis and more, one click away.",
|
||||
},
|
||||
{
|
||||
icon: GlobeIcon,
|
||||
title: "Custom domains",
|
||||
description: "Attach your own domains and get automatic HTTPS.",
|
||||
},
|
||||
{
|
||||
icon: GitMergeIcon,
|
||||
title: "CI/CD",
|
||||
description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.",
|
||||
},
|
||||
{
|
||||
icon: UsersIcon,
|
||||
title: "Team collaboration",
|
||||
description: "Invite teammates with fine-grained permissions.",
|
||||
},
|
||||
];
|
||||
|
||||
export const CompleteStep = ({ projectId, environmentId, onFinish }: Props) => {
|
||||
const router = useRouter();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [isFinishing, setIsFinishing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowConfetti(true);
|
||||
}, []);
|
||||
|
||||
const projectHref =
|
||||
projectId && environmentId
|
||||
? `/dashboard/project/${projectId}/environment/${environmentId}`
|
||||
: "/dashboard/projects";
|
||||
|
||||
const handleFinish = async () => {
|
||||
setIsFinishing(true);
|
||||
await onFinish();
|
||||
router.push(projectHref);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="fixed inset-x-0 top-0 flex justify-center pointer-events-none">
|
||||
{showConfetti && (
|
||||
<ConfettiExplosion
|
||||
duration={3000}
|
||||
force={0.35}
|
||||
particleCount={200}
|
||||
width={1200}
|
||||
zIndex={60}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Done
|
||||
</span>
|
||||
<h1
|
||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||
>
|
||||
You're all set.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
This is just the beginning — here's some of what else you can do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="flex flex-col divide-y">
|
||||
{features.map((feature, index) => (
|
||||
<div key={feature.title} className="flex gap-6 py-5 first:pt-0">
|
||||
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</dt>
|
||||
<dd className="flex flex-col gap-1">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
<feature.icon className="size-4 text-muted-foreground" />
|
||||
{feature.title}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{feature.description}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-fit px-8"
|
||||
isLoading={isFinishing}
|
||||
onClick={handleFinish}
|
||||
>
|
||||
Go to my project
|
||||
</Button>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link
|
||||
href="https://docs.dokploy.com/docs/core"
|
||||
target="_blank"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<BookIcon size={14} />
|
||||
Read the docs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,295 +0,0 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
CheckCircle2,
|
||||
CopyIcon,
|
||||
Loader2,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
interface Props {
|
||||
environmentId?: string;
|
||||
onNext: () => void;
|
||||
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||
* post-checkout welcome modal) that want the app's regular typography. */
|
||||
plainTitle?: boolean;
|
||||
}
|
||||
|
||||
const CURATED_TEMPLATES = [
|
||||
{ id: "wordpress", name: "WordPress", logo: "wordpress.png" },
|
||||
{ id: "ghost", name: "Ghost", logo: "ghost.jpeg" },
|
||||
{ id: "n8n", name: "n8n", logo: "n8n.png" },
|
||||
{ id: "uptime-kuma", name: "Uptime Kuma", logo: "uptime-kuma.png" },
|
||||
];
|
||||
|
||||
type Deploying = { kind: "app" | "template"; id: string; url: string };
|
||||
|
||||
export const DeployStep = ({ environmentId, onNext, plainTitle }: Props) => {
|
||||
const titleClassName = plainTitle
|
||||
? "text-xl font-semibold tracking-tight"
|
||||
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||
const { data: isCloud = true } = api.settings.isCloud.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const serverId = servers?.[0]?.serverId;
|
||||
|
||||
const [deploying, setDeploying] = useState<Deploying | null>(null);
|
||||
const [starting, setStarting] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: deployNginx } =
|
||||
api.application.deployNginxQuickstart.useMutation();
|
||||
const { mutateAsync: deployTemplate } =
|
||||
api.compose.deployTemplate.useMutation();
|
||||
const { mutateAsync: deployCompose } = api.compose.deploy.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: application } = api.application.one.useQuery(
|
||||
{ applicationId: deploying?.id ?? "" },
|
||||
{
|
||||
enabled: deploying?.kind === "app",
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.applicationStatus;
|
||||
return status === "done" || status === "error" ? false : 2500;
|
||||
},
|
||||
},
|
||||
);
|
||||
const { data: compose } = api.compose.one.useQuery(
|
||||
{ composeId: deploying?.id ?? "" },
|
||||
{
|
||||
enabled: deploying?.kind === "template",
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.composeStatus;
|
||||
return status === "done" || status === "error" ? false : 2500;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const status =
|
||||
deploying?.kind === "app"
|
||||
? application?.applicationStatus
|
||||
: compose?.composeStatus;
|
||||
|
||||
if (!environmentId || (isCloud && !serverId)) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
First deploy
|
||||
</span>
|
||||
<h1 className={titleClassName}>Deploy something.</h1>
|
||||
</div>
|
||||
<AlertBlock type="info" className="max-w-md">
|
||||
{isCloud
|
||||
? "You'll need a project and a connected server before deploying — you can do that anytime from the dashboard."
|
||||
: "You'll need a project before deploying — you can do that anytime from the dashboard."}
|
||||
</AlertBlock>
|
||||
<Button onClick={onNext} className="w-fit px-8">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleNginx = async () => {
|
||||
setStarting("nginx");
|
||||
try {
|
||||
const res = await deployNginx({ environmentId, serverId });
|
||||
setDeploying({ kind: "app", id: res.applicationId, url: res.domainUrl });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error deploying the app",
|
||||
);
|
||||
} finally {
|
||||
setStarting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplate = async (id: string) => {
|
||||
setStarting(id);
|
||||
try {
|
||||
const composeResult = await deployTemplate({
|
||||
environmentId,
|
||||
serverId,
|
||||
id,
|
||||
});
|
||||
await deployCompose({ composeId: composeResult.composeId });
|
||||
const domains = await utils.domain.byComposeId.fetch({
|
||||
composeId: composeResult.composeId,
|
||||
});
|
||||
const host = domains?.[0]?.host;
|
||||
setDeploying({
|
||||
kind: "template",
|
||||
id: composeResult.composeId,
|
||||
url: host ? `http://${host}` : "",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error deploying the template",
|
||||
);
|
||||
} finally {
|
||||
setStarting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (deploying) {
|
||||
const isDone = status === "done";
|
||||
const isError = status === "error";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span
|
||||
className={`font-mono text-xs uppercase tracking-[0.2em] flex items-center gap-2 ${
|
||||
isError ? "text-destructive" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
) : isError ? (
|
||||
<XCircleIcon className="size-3.5" />
|
||||
) : (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{isDone ? "Live" : isError ? "Deploy failed" : "Deploying"}
|
||||
</span>
|
||||
<h1 className={titleClassName}>
|
||||
{isDone
|
||||
? "It's live."
|
||||
: isError
|
||||
? "Something went wrong."
|
||||
: "Building your app..."}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
{isDone
|
||||
? "Your app is up and reachable at:"
|
||||
: isError
|
||||
? "The deployment failed — you can check the logs from the dashboard later."
|
||||
: "This usually takes a minute or two the first time."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isDone && deploying.url ? (
|
||||
<div className="flex flex-col rounded-2xl border overflow-hidden max-w-md">
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-b bg-muted/30">
|
||||
<span className="size-2 rounded-full bg-green-500 shrink-0" />
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
Reachable now
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 p-5">
|
||||
<span className="font-mono text-sm truncate">
|
||||
{deploying.url}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
copy(deploying.url);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a href={deploying.url} target="_blank" rel="noreferrer">
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !isDone && !isError ? (
|
||||
<div className="rounded-2xl border p-5 max-w-md">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||
Waiting for the build to finish...
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="w-fit px-8"
|
||||
disabled={!isDone && !isError}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
First deploy
|
||||
</span>
|
||||
<h1 className={titleClassName}>Ship something.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Pick a quickstart — we'll generate a domain and deploy it for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNginx}
|
||||
disabled={!!starting}
|
||||
className="flex flex-col items-start justify-between gap-8 bg-background p-7 text-left hover:bg-muted/40 transition-colors disabled:opacity-60"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">Simple app</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A "Hello World" demo app, live in seconds.
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||
{starting === "nginx" ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
"Deploy now →"
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col justify-between gap-8 bg-background p-7">
|
||||
<div>
|
||||
<p className="font-medium">Docker Compose template</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Popular open source stacks, one click away.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{CURATED_TEMPLATES.map((template) => (
|
||||
<Button
|
||||
key={template.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!!starting}
|
||||
isLoading={starting === template.id}
|
||||
onClick={() => handleTemplate(template.id)}
|
||||
className="justify-start"
|
||||
>
|
||||
<img
|
||||
src={`https://templates.dokploy.com/blueprints/${template.id}/${template.logo}`}
|
||||
alt={template.name}
|
||||
className="size-4 object-contain shrink-0"
|
||||
/>
|
||||
{template.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { ArrowRightIcon, CheckIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
calculatePriceHobby,
|
||||
calculatePriceStartup,
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
} from "@/components/dashboard/settings/billing/show-billing";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
const stripePromise = loadStripe(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
|
||||
type Tier = "hobby" | "startup";
|
||||
|
||||
const PLANS: {
|
||||
tier: Tier;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
features: string[];
|
||||
recommended?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
tier: "hobby",
|
||||
name: "Hobby",
|
||||
description: "For individual developers",
|
||||
price: calculatePriceHobby(1, false),
|
||||
features: [
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"2 environments",
|
||||
"Community support",
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: "startup",
|
||||
name: "Startup",
|
||||
description: "For small to mid-size teams",
|
||||
price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false),
|
||||
recommended: true,
|
||||
features: [
|
||||
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
|
||||
"Unlimited users & environments",
|
||||
"Basic RBAC + 2FA",
|
||||
"Email & chat support",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export const PlanStep = ({ onNext }: Props) => {
|
||||
const [loading, setLoading] = useState<
|
||||
`${"trial" | "checkout"}:${Tier}` | null
|
||||
>(null);
|
||||
const { data } = api.stripe.getProducts.useQuery();
|
||||
const { mutateAsync: createCheckoutSession } =
|
||||
api.stripe.createCheckoutSession.useMutation();
|
||||
const { mutateAsync: startFreeTrial } =
|
||||
api.stripe.startFreeTrial.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const handleCheckout = async (tier: Tier) => {
|
||||
if (!data) return;
|
||||
const productId =
|
||||
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
|
||||
if (!productId) return;
|
||||
setLoading(`checkout:${tier}`);
|
||||
try {
|
||||
const stripe = await stripePromise;
|
||||
const session = await createCheckoutSession({
|
||||
tier,
|
||||
productId,
|
||||
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
||||
isAnnual: false,
|
||||
});
|
||||
await stripe?.redirectToCheckout({ sessionId: session.sessionId });
|
||||
} catch {
|
||||
toast.error("Error starting checkout");
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrial = async (tier: Tier) => {
|
||||
setLoading(`trial:${tier}`);
|
||||
try {
|
||||
await startFreeTrial({ tier });
|
||||
await utils.project.onboardingStatus.invalidate();
|
||||
toast.success("Your 7-day trial has started");
|
||||
onNext();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting trial",
|
||||
);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Billing
|
||||
</span>
|
||||
<h1
|
||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||
>
|
||||
Start free, upgrade when ready.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Try any plan free for 7 days. No credit card required — add one only
|
||||
if you decide to stay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||
{PLANS.map((plan) => {
|
||||
const productId =
|
||||
plan.tier === "hobby"
|
||||
? data?.hobbyProductId
|
||||
: data?.startupProductId;
|
||||
return (
|
||||
<div
|
||||
key={plan.tier}
|
||||
className="flex flex-col justify-between gap-6 bg-background p-7"
|
||||
>
|
||||
<div>
|
||||
{plan.recommended && (
|
||||
<span className="inline-flex items-center rounded-full bg-primary text-primary-foreground font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1 mb-3">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
<p className="font-medium">{plan.name}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{plan.description}
|
||||
</p>
|
||||
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||
${plan.price.toFixed(2)}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{" "}
|
||||
/mo
|
||||
</span>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{plan.features.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
isLoading={loading === `trial:${plan.tier}`}
|
||||
disabled={loading !== null}
|
||||
onClick={() => handleTrial(plan.tier)}
|
||||
>
|
||||
Start 7-day free trial
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={loading === `checkout:${plan.tier}`}
|
||||
disabled={loading !== null || !productId}
|
||||
onClick={() => handleCheckout(plan.tier)}
|
||||
>
|
||||
Subscribe now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
onNext: (project: { projectId: string; environmentId: string }) => void;
|
||||
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||
* post-checkout welcome modal) that want the app's regular typography. */
|
||||
plainTitle?: boolean;
|
||||
}
|
||||
|
||||
export const ProjectStep = ({ onNext, plainTitle }: Props) => {
|
||||
const titleClassName = plainTitle
|
||||
? "text-xl font-semibold tracking-tight"
|
||||
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||
const { mutateAsync, isPending } = api.project.create.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: { name: "My First Project", description: "" },
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
try {
|
||||
const result = await mutateAsync(data);
|
||||
await utils.project.all.invalidate();
|
||||
toast.success("Project created");
|
||||
onNext({
|
||||
projectId: result.project.projectId,
|
||||
environmentId: result.environment?.environmentId ?? "",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error creating the project",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Workspace
|
||||
</span>
|
||||
<h1 className={titleClassName}>Create your first project.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Projects group your apps, databases, and environments together.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-5 max-w-sm"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My First Project" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What is this project for?"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
className="w-fit px-8 mt-2"
|
||||
>
|
||||
Create project
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,362 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CopyIcon,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
ipAddress: z
|
||||
.string()
|
||||
.min(1, "IP address is required")
|
||||
.refine((value) => !/\s/.test(value), "IP address cannot contain spaces"),
|
||||
});
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
onNext: () => void;
|
||||
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||
* post-checkout welcome modal) that want the app's regular typography. */
|
||||
plainTitle?: boolean;
|
||||
}
|
||||
|
||||
const StatusRow = ({
|
||||
label,
|
||||
ok,
|
||||
pending,
|
||||
}: {
|
||||
label: string;
|
||||
ok?: boolean;
|
||||
pending?: boolean;
|
||||
}) => (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{ok ? (
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-500 shrink-0" />
|
||||
) : pending ? (
|
||||
<Loader2 className="size-4 text-muted-foreground shrink-0 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className={ok ? "" : "text-muted-foreground"}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ServerStep = ({ onNext, plainTitle }: Props) => {
|
||||
const titleClassName = plainTitle
|
||||
? "text-xl font-semibold tracking-tight"
|
||||
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||
|
||||
const { data: sshKeys, refetch: refetchSSHKeys } = api.sshKey.all.useQuery();
|
||||
const generateSSHKey = api.sshKey.generate.useMutation();
|
||||
const createSSHKey = api.sshKey.create.useMutation();
|
||||
const hasCreatedKey = useRef(false);
|
||||
|
||||
const cloudSSHKey = sshKeys?.find(
|
||||
(sshKey) => sshKey.name === "dokploy-onboarding-ssh-key",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const ensureKey = async () => {
|
||||
if (!sshKeys || cloudSSHKey || hasCreatedKey.current) return;
|
||||
hasCreatedKey.current = true;
|
||||
try {
|
||||
const keys = await generateSSHKey.mutateAsync({ type: "rsa" });
|
||||
await createSSHKey.mutateAsync({
|
||||
name: "dokploy-onboarding-ssh-key",
|
||||
description: "Used during onboarding",
|
||||
privateKey: keys.privateKey,
|
||||
publicKey: keys.publicKey,
|
||||
organizationId: "",
|
||||
});
|
||||
await refetchSSHKeys();
|
||||
} catch {
|
||||
hasCreatedKey.current = false;
|
||||
}
|
||||
};
|
||||
ensureKey();
|
||||
}, [sshKeys]);
|
||||
|
||||
const { data: existingServers } = api.server.all.useQuery();
|
||||
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createdServerId && existingServers && existingServers.length > 0) {
|
||||
setCreatedServerId(existingServers[0]!.serverId);
|
||||
}
|
||||
}, [existingServers, createdServerId]);
|
||||
|
||||
const { data: canCreateMoreServers } =
|
||||
api.stripe.canCreateMoreServers.useQuery();
|
||||
const { mutateAsync: createServer, isPending: isCreating } =
|
||||
api.server.create.useMutation();
|
||||
const checkIsReady = (validation?: {
|
||||
docker?: { enabled?: boolean };
|
||||
isDokployNetworkInstalled?: boolean;
|
||||
}) =>
|
||||
!!validation?.docker?.enabled && !!validation?.isDokployNetworkInstalled;
|
||||
|
||||
const {
|
||||
data: validation,
|
||||
refetch: refetchValidation,
|
||||
isFetching: isValidating,
|
||||
} = api.server.validate.useQuery(
|
||||
{ serverId: createdServerId ?? "" },
|
||||
{
|
||||
enabled: !!createdServerId,
|
||||
refetchInterval: (query) =>
|
||||
checkIsReady(query.state.data) ? false : 5000,
|
||||
},
|
||||
);
|
||||
const isReady = checkIsReady(validation);
|
||||
|
||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||
const hasStartedSetup = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (createdServerId && !hasStartedSetup.current && !isReady) {
|
||||
hasStartedSetup.current = true;
|
||||
setIsSettingUp(true);
|
||||
}
|
||||
}, [createdServerId, isReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingUp) return;
|
||||
const timeout = setTimeout(() => {
|
||||
setIsSettingUp(false);
|
||||
toast.error(
|
||||
"Setup is taking too long — check the server is reachable, then try again.",
|
||||
);
|
||||
}, 120_000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isSettingUp]);
|
||||
|
||||
api.server.setupWithLogs.useSubscription(
|
||||
{ serverId: createdServerId ?? "" },
|
||||
{
|
||||
enabled: !!createdServerId && isSettingUp,
|
||||
onData(log) {
|
||||
if (typeof log === "string" && log.includes("Setup Server:")) {
|
||||
setIsSettingUp(false);
|
||||
refetchValidation();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
setIsSettingUp(false);
|
||||
toast.error("Error setting up the server");
|
||||
console.error(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: { name: "My First Server", ipAddress: "" },
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
if (!cloudSSHKey) {
|
||||
toast.error("Still generating your SSH key, try again in a moment");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const server = await createServer({
|
||||
name: data.name,
|
||||
description: "",
|
||||
ipAddress: data.ipAddress.trim(),
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: cloudSSHKey.sshKeyId,
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: false,
|
||||
});
|
||||
setCreatedServerId(server.serverId);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error creating the server",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Infrastructure
|
||||
</span>
|
||||
<h1 className={titleClassName}>Connect a server.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Dokploy deploys to servers you own. Buy one from any VPS provider
|
||||
(Hetzner, DigitalOcean, Hostinger...) and paste its IP below.
|
||||
</p>
|
||||
<div className="flex items-start gap-2.5 rounded-lg border p-3 max-w-md">
|
||||
<InfoIcon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">Requirements</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
To ensure a smooth experience with Dokploy, your server should
|
||||
have at least 1GB of RAM and 30GB of disk space.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!createdServerId ? (
|
||||
<div className="flex flex-col gap-4 max-w-sm">
|
||||
{canCreateMoreServers === false && (
|
||||
<AlertBlock type="warning">
|
||||
You'll need a plan or trial before connecting a server — go back
|
||||
to the "Pick a plan" step.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
1. Run this on your server to authorize Dokploy
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1.5 text-xs">
|
||||
{cloudSSHKey
|
||||
? `echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`
|
||||
: "Generating..."}
|
||||
</code>
|
||||
{cloudSSHKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My First Server" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ipAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>2. IP address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="192.168.1.100" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isCreating}
|
||||
disabled={!cloudSSHKey || canCreateMoreServers === false}
|
||||
className="mt-2"
|
||||
>
|
||||
Connect server
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full max-w-sm">
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-4">
|
||||
{isSettingUp ? (
|
||||
<div className="flex items-center justify-center gap-3 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||
Setting up your server – installing Docker and dependencies…
|
||||
</div>
|
||||
) : isValidating && !validation ? (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking your server...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StatusRow
|
||||
label="Docker installed"
|
||||
ok={validation?.docker?.enabled}
|
||||
pending={!validation?.docker?.enabled && !isReady}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Dokploy network created"
|
||||
ok={validation?.isDokployNetworkInstalled}
|
||||
pending={!validation?.isDokployNetworkInstalled && !isReady}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isReady && !isSettingUp && (
|
||||
<AlertBlock type="info">
|
||||
Setup runs after the server boots and this page checks for it
|
||||
automatically. You can retry setup, or use "Skip for now" above to
|
||||
continue and come back to this later.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
isLoading={isValidating || isSettingUp}
|
||||
onClick={() => {
|
||||
if (!isReady && !isSettingUp) {
|
||||
setIsSettingUp(true);
|
||||
} else {
|
||||
refetchValidation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isReady && !isSettingUp ? "Retry setup" : "Check now"}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!isReady || isSettingUp}
|
||||
onClick={onNext}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { GitBranchIcon, PuzzleIcon, ServerIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
interface Props {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const points = [
|
||||
{
|
||||
icon: GitBranchIcon,
|
||||
title: "Deploy from Git or Docker",
|
||||
description:
|
||||
"Push a repo, a compose file, or a container image and go live.",
|
||||
},
|
||||
{
|
||||
icon: PuzzleIcon,
|
||||
title: "One-click templates",
|
||||
description:
|
||||
"WordPress, databases, and dozens of open source apps, pre-wired.",
|
||||
},
|
||||
{
|
||||
icon: ServerIcon,
|
||||
title: "Your own servers",
|
||||
description: "Runs on infrastructure you control — no vendor lock-in.",
|
||||
},
|
||||
];
|
||||
|
||||
export const WelcomeStep = ({ onNext }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Welcome
|
||||
</span>
|
||||
<h1
|
||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||
>
|
||||
Let's get your first app <em className="not-italic">live</em>.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
It just takes a few steps: pick a plan, connect a server, ship
|
||||
something. You'll have a working URL by the end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="flex flex-col divide-y">
|
||||
{points.map((point, index) => (
|
||||
<div key={point.title} className="flex gap-6 py-5 first:pt-0">
|
||||
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</dt>
|
||||
<dd className="flex flex-col gap-1">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
<point.icon className="size-4 text-muted-foreground" />
|
||||
{point.title}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{point.description}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<Button size="lg" onClick={onNext} className="w-fit px-8">
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
|
||||
import { PenBoxIcon, Plus, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PenBoxIcon, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dropzone } from "@/components/ui/dropzone";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -27,8 +24,6 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { resizeImage } from "@/utils/image-processing";
|
||||
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||
|
||||
const organizationSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
@@ -42,24 +37,10 @@ type OrganizationFormValues = z.infer<typeof organizationSchema>;
|
||||
interface Props {
|
||||
organizationId?: string;
|
||||
children?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AddOrganization({
|
||||
organizationId,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: Props) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const uploadCounter = useRef(0);
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled
|
||||
? controlledOnOpenChange || (() => {})
|
||||
: setInternalOpen;
|
||||
export function AddOrganization({ organizationId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { data: organization } = api.organization.one.useQuery(
|
||||
{
|
||||
@@ -67,7 +48,6 @@ export function AddOrganization({
|
||||
},
|
||||
{
|
||||
enabled: !!organizationId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
const { mutateAsync, isPending } = organizationId
|
||||
@@ -84,18 +64,14 @@ export function AddOrganization({
|
||||
|
||||
useEffect(() => {
|
||||
if (organization) {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
form.reset({
|
||||
name: organization.name,
|
||||
logo: organization.logo || "",
|
||||
});
|
||||
setUploadedFileName(null);
|
||||
}
|
||||
}, [organization, form]);
|
||||
|
||||
const onSubmit = async (values: OrganizationFormValues) => {
|
||||
if (isUploading) return;
|
||||
await mutateAsync({
|
||||
name: values.name,
|
||||
logo: values.logo,
|
||||
@@ -103,7 +79,6 @@ export function AddOrganization({
|
||||
})
|
||||
.then(() => {
|
||||
form.reset();
|
||||
setUploadedFileName(null);
|
||||
toast.success(
|
||||
`Organization ${organizationId ? "updated" : "created"} successfully`,
|
||||
);
|
||||
@@ -123,94 +98,8 @@ export function AddOrganization({
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
const currentUploadId = ++uploadCounter.current;
|
||||
setIsUploading(true);
|
||||
|
||||
const allowedTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/webp",
|
||||
];
|
||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||
const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"];
|
||||
|
||||
if (
|
||||
!allowedTypes.includes(file.type) &&
|
||||
!allowedExtensions.includes(fileExtension || "")
|
||||
) {
|
||||
toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error("Image size must be less than 2MB");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
|
||||
|
||||
if (isSvg) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const sanitizedDataUrl = sanitizeSvg(text);
|
||||
if (currentUploadId !== uploadCounter.current) return;
|
||||
if (!sanitizedDataUrl) {
|
||||
toast.error("Invalid SVG file");
|
||||
return;
|
||||
}
|
||||
form.setValue("logo", sanitizedDataUrl);
|
||||
form.trigger("logo");
|
||||
setUploadedFileName(file.name);
|
||||
} catch (error) {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
toast.error("Error processing SVG");
|
||||
}
|
||||
} finally {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize raster images to max 256x256 and convert to WebP to save space
|
||||
try {
|
||||
const resizedDataUrl = await resizeImage(file, 256);
|
||||
if (currentUploadId !== uploadCounter.current) return;
|
||||
form.setValue("logo", resizedDataUrl);
|
||||
form.trigger("logo");
|
||||
setUploadedFileName(file.name);
|
||||
} catch (error) {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
toast.error("Error processing image");
|
||||
}
|
||||
} finally {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
if (!val) {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
}
|
||||
setOpen(val);
|
||||
}}
|
||||
>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{organizationId ? (
|
||||
<Button
|
||||
@@ -256,10 +145,8 @@ export function AddOrganization({
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="items-center gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel className="text-right">Name</FormLabel>
|
||||
</div>
|
||||
<FormItem className="tems-center gap-4">
|
||||
<FormLabel className="text-right">Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Organization name"
|
||||
@@ -274,78 +161,23 @@ export function AddOrganization({
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={({ field }) => {
|
||||
const isDataUrl = field.value?.startsWith("data:");
|
||||
const displayValue = isDataUrl
|
||||
? uploadedFileName || "Uploaded image"
|
||||
: field.value || "";
|
||||
|
||||
return (
|
||||
<FormItem className="gap-4">
|
||||
<FormLabel className="text-right">
|
||||
Logo URL or Upload
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="col-span-3 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 overflow-hidden">
|
||||
{field.value ? (
|
||||
// biome-ignore lint/performance/noImgElement: user uploaded logo preview
|
||||
<img
|
||||
src={field.value}
|
||||
alt="Logo preview"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Logo className="size-7" />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...field}
|
||||
value={displayValue}
|
||||
readOnly={isDataUrl}
|
||||
onChange={(e) => {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
field.onChange(e);
|
||||
if (isDataUrl) setUploadedFileName(null);
|
||||
}}
|
||||
className="w-full pr-8"
|
||||
/>
|
||||
{field.value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
form.setValue("logo", "");
|
||||
setUploadedFileName(null);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Dropzone
|
||||
dropMessage="Drag & drop a logo or click to upload"
|
||||
accept=".jpg,.jpeg,.png,.svg,.webp,image/jpeg,image/png,image/svg+xml,image/webp"
|
||||
onChange={handleFileUpload}
|
||||
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
|
||||
classNameContent="h-32"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage className="col-span-3 col-start-2" />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem className="gap-4">
|
||||
<FormLabel className="text-right">Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="col-span-3 col-start-2" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" isLoading={isPending || isUploading}>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{organizationId ? "Update organization" : "Create organization"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{viewMode === "detailed" &&
|
||||
template?.tags?.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{template.tags.slice(0, 6).map((tag) => (
|
||||
{template?.tags?.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="green"
|
||||
@@ -444,15 +444,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{template.tags.length > 6 && (
|
||||
<Badge
|
||||
variant="green"
|
||||
className="text-[10px] px-2 py-0"
|
||||
title={template.tags.slice(6).join(", ")}
|
||||
>
|
||||
+{template.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ 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 { VaultImportDialog } from "@/components/shared/vault-import-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -157,17 +156,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
name="env"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<VaultImportDialog
|
||||
projectId={data?.projectId}
|
||||
environmentId={environmentId}
|
||||
currentEnv={field.value ?? ""}
|
||||
onImport={(next) =>
|
||||
form.setValue("env", next, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
completionSource={completionSource}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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 { VaultImportDialog } from "@/components/shared/vault-import-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -153,16 +152,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
name="env"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<VaultImportDialog
|
||||
projectId={projectId}
|
||||
currentEnv={field.value ?? ""}
|
||||
onImport={(next) =>
|
||||
form.setValue("env", next, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
completionSource={completionSource}
|
||||
|
||||
@@ -17,13 +17,11 @@ import {
|
||||
Download,
|
||||
Globe,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
Server,
|
||||
TrendingUpIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -102,12 +100,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const {
|
||||
data: statsLogs,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = api.settings.readStatsLogs.useQuery(
|
||||
const { data: statsLogs } = api.settings.readStatsLogs.useQuery(
|
||||
{
|
||||
sort: sorting[0],
|
||||
page: pagination,
|
||||
@@ -280,18 +273,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="w-full flex gap-4 items-center justify-center h-[55vh] text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading requests...</span>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="w-full flex items-center justify-center h-[55vh]">
|
||||
<AlertBlock type="error" className="w-full">
|
||||
{error?.message}
|
||||
</AlertBlock>
|
||||
</div>
|
||||
) : (
|
||||
{statsLogs?.data.length === 0 && (
|
||||
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
|
||||
<span className="text-muted-foreground text-lg font-medium">
|
||||
No results.
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckIcon,
|
||||
Clock,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Loader2,
|
||||
@@ -94,7 +93,6 @@ export const ShowBilling = () => {
|
||||
const router = useRouter();
|
||||
const { data: servers } = api.server.count.useQuery();
|
||||
const { data: admin } = api.user.get.useQuery();
|
||||
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||
const { data, isPending } = api.stripe.getProducts.useQuery();
|
||||
const { mutateAsync: createCheckoutSession } =
|
||||
api.stripe.createCheckoutSession.useMutation();
|
||||
@@ -105,61 +103,7 @@ export const ShowBilling = () => {
|
||||
api.stripe.upgradeSubscription.useMutation();
|
||||
const { mutateAsync: updateInvoiceNotifications } =
|
||||
api.stripe.updateInvoiceNotifications.useMutation();
|
||||
const { mutateAsync: startFreeTrial } =
|
||||
api.stripe.startFreeTrial.useMutation();
|
||||
const utils = api.useUtils();
|
||||
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
|
||||
const [switchingTrial, setSwitchingTrial] = useState(false);
|
||||
|
||||
const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => {
|
||||
const planName = tier === "startup" ? "Startup" : "Hobby";
|
||||
|
||||
const switchPlan = async () => {
|
||||
await upgradeSubscription({
|
||||
tier,
|
||||
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
||||
isAnnual: false,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
await Promise.all([
|
||||
utils.stripe.getBillingStatus.invalidate(),
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
utils.user.get.invalidate(),
|
||||
utils.server.count.invalidate(),
|
||||
]);
|
||||
};
|
||||
|
||||
setSwitchingTrial(true);
|
||||
await toast
|
||||
.promise(switchPlan(), {
|
||||
loading: `Switching to ${planName}...`,
|
||||
success: `Your trial is now on the ${planName} plan`,
|
||||
error: (error) =>
|
||||
error instanceof Error ? error.message : "Error switching plan",
|
||||
})
|
||||
.unwrap()
|
||||
.catch(() => undefined);
|
||||
setSwitchingTrial(false);
|
||||
};
|
||||
|
||||
const handleStartTrial = async (tier: "hobby" | "startup") => {
|
||||
setTrialTier(tier);
|
||||
try {
|
||||
await startFreeTrial({ tier });
|
||||
await Promise.all([
|
||||
utils.stripe.getBillingStatus.invalidate(),
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
utils.user.get.invalidate(),
|
||||
]);
|
||||
toast.success("Your 7-day trial has started");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting trial",
|
||||
);
|
||||
} finally {
|
||||
setTrialTier(null);
|
||||
}
|
||||
};
|
||||
|
||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||
const [startupServerQuantity, setStartupServerQuantity] = useState(
|
||||
@@ -206,23 +150,12 @@ export const ShowBilling = () => {
|
||||
|
||||
const useNewPricing = data?.hobbyProductId && data?.startupProductId;
|
||||
const products = data?.products.filter((product) => {
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
const interval = product?.default_price?.recurring?.interval;
|
||||
return isAnnual ? interval === "year" : interval === "month";
|
||||
});
|
||||
|
||||
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
|
||||
const canStartTrial =
|
||||
!isEnterpriseCloud &&
|
||||
!!billingStatus &&
|
||||
!billingStatus.plan &&
|
||||
!billingStatus.isOnTrial &&
|
||||
!billingStatus.hasUsedTrial;
|
||||
/** Checkout would create a second subscription alongside a running trial. */
|
||||
const canSubscribe =
|
||||
!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 &&
|
||||
!billingStatus?.isOnTrial;
|
||||
const maxServers = admin?.user.serversQuantity ?? 1;
|
||||
const percentage = ((servers ?? 0) / maxServers) * 100;
|
||||
const safePercentage = Math.min(percentage, 100);
|
||||
@@ -316,99 +249,6 @@ export const ShowBilling = () => {
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full mt-6">
|
||||
{!isEnterpriseCloud &&
|
||||
billingStatus &&
|
||||
(billingStatus.plan || billingStatus.isOnTrial) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
{billingStatus.isOnTrial ? (
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
) : (
|
||||
<CreditCard className="h-5 w-5 text-primary shrink-0" />
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{billingStatus.isOnTrial
|
||||
? "Free trial"
|
||||
: "Current plan"}
|
||||
</span>
|
||||
<Badge className="capitalize" variant="secondary">
|
||||
{billingStatus.plan ?? "Trial"}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{billingStatus.isOnTrial
|
||||
? `${billingStatus.trialDaysRemaining} day${billingStatus.trialDaysRemaining === 1 ? "" : "s"} left${
|
||||
billingStatus.trialEndsAt
|
||||
? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}`
|
||||
: ""
|
||||
}${
|
||||
billingStatus.hasPaymentMethod
|
||||
? " · billing starts automatically"
|
||||
: " · add a card to keep your servers"
|
||||
}`
|
||||
: "You're subscribed and billed automatically."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{billingStatus.isOnTrial &&
|
||||
useNewPricing &&
|
||||
billingStatus.plan && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={switchingTrial}
|
||||
onClick={() =>
|
||||
handleSwitchTrialPlan(
|
||||
billingStatus.plan === "startup"
|
||||
? "hobby"
|
||||
: "startup",
|
||||
)
|
||||
}
|
||||
>
|
||||
{billingStatus.plan === "startup"
|
||||
? "Switch to Hobby"
|
||||
: "Switch to Startup"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={
|
||||
billingStatus.isOnTrial &&
|
||||
!billingStatus.hasPaymentMethod
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
onClick={async () => {
|
||||
const session = await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
{billingStatus.isOnTrial &&
|
||||
!billingStatus.hasPaymentMethod
|
||||
? "Add payment method"
|
||||
: "Manage Subscription"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canStartTrial && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
7-day free trial on any plan
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No credit card required — pick Hobby or Startup below.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<h3 className="text-lg font-medium">Servers Plan</h3>
|
||||
@@ -969,7 +809,7 @@ export const ShowBilling = () => {
|
||||
"Unlimited Deployments",
|
||||
"Unlimited Databases",
|
||||
"Unlimited Applications",
|
||||
"Setup 1 Server",
|
||||
"1 Server Included",
|
||||
"1 Organization",
|
||||
"1 User",
|
||||
"2 Environments",
|
||||
@@ -985,71 +825,68 @@ export const ShowBilling = () => {
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{canSubscribe && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Servers:
|
||||
</span>
|
||||
<Button
|
||||
disabled={hobbyServerQuantity <= 1}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) =>
|
||||
Math.max(1, q - 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={hobbyServerQuantity}
|
||||
onChange={(e) =>
|
||||
setHobbyServerQuantity(
|
||||
Math.max(
|
||||
1,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Servers:
|
||||
</span>
|
||||
<Button
|
||||
disabled={hobbyServerQuantity <= 1}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) => Math.max(1, q - 1))
|
||||
}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={hobbyServerQuantity}
|
||||
onChange={(e) =>
|
||||
setHobbyServerQuantity(
|
||||
Math.max(
|
||||
1,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setHobbyServerQuantity((q) => q + 1)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{canStartTrial && (
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
isLoading={trialTier === "hobby"}
|
||||
disabled={trialTier !== null}
|
||||
onClick={() => handleStartTrial("hobby")}
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
Start 7-day free trial
|
||||
</Button>
|
||||
)}
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={canStartTrial ? "outline" : "default"}
|
||||
onClick={() =>
|
||||
handleCheckout("hobby", data!.hobbyProductId!)
|
||||
}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Get Started
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout("hobby", data!.hobbyProductId!)
|
||||
}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1104,7 +941,7 @@ export const ShowBilling = () => {
|
||||
All the features of Hobby, plus…
|
||||
</li>
|
||||
{[
|
||||
"Setup up to 3 Servers",
|
||||
"3 Servers Included",
|
||||
"3 Organizations",
|
||||
"Unlimited Users",
|
||||
"Unlimited Environments",
|
||||
@@ -1122,83 +959,85 @@ export const ShowBilling = () => {
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{canSubscribe && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={
|
||||
startupServerQuantity <=
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) =>
|
||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={startupServerQuantity}
|
||||
onChange={(e) =>
|
||||
setStartupServerQuantity(
|
||||
Math.max(
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || STARTUP_SERVERS_INCLUDED,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{canStartTrial && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
isLoading={trialTier === "startup"}
|
||||
disabled={trialTier !== null}
|
||||
onClick={() => handleStartTrial("startup")}
|
||||
>
|
||||
Start 7-day free trial
|
||||
</Button>
|
||||
)}
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={canStartTrial ? "outline" : "default"}
|
||||
disabled={
|
||||
startupServerQuantity <=
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
handleCheckout(
|
||||
"startup",
|
||||
data!.startupProductId!,
|
||||
setStartupServerQuantity((q) =>
|
||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
startupServerQuantity < STARTUP_SERVERS_INCLUDED
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={startupServerQuantity}
|
||||
onChange={(e) =>
|
||||
setStartupServerQuantity(
|
||||
Math.max(
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || STARTUP_SERVERS_INCLUDED,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
Get Started
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout(
|
||||
"startup",
|
||||
data!.startupProductId!,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
startupServerQuantity <
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1390,17 +1229,31 @@ export const ShowBilling = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-4 w-full">
|
||||
{canSubscribe && (
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
handleCheckout("legacy", product.id);
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Subscribe
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
handleCheckout("legacy", product.id);
|
||||
}}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ShowBilling } from "@/components/dashboard/settings/billing/show-billing";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const ShowWelcomeDokploy = () => {
|
||||
const { data } = api.user.get.useQuery();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: isCloud, isPending } = api.settings.isCloud.useQuery();
|
||||
|
||||
if (!isCloud || data?.role !== "admin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isPending &&
|
||||
isCloud &&
|
||||
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
|
||||
data?.role === "owner"
|
||||
) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [isCloud, isPending]);
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (data?.role === "owner") {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
localStorage.setItem("hasSeenCloudWelcomeModal", "true"); // Establece el flag al cerrar el modal
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold text-center">
|
||||
Welcome to Dokploy Cloud 🎉
|
||||
</DialogTitle>
|
||||
<p className="text-center text-sm text-muted-foreground mt-2">
|
||||
Unlock powerful features to streamline your deployments and manage
|
||||
projects effortlessly.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-3 text-sm text-primary ">
|
||||
<ShowBilling />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,421 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Cloud, CloudOff, XIcon } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const DNS_RECORD_TYPES = [
|
||||
"A",
|
||||
"AAAA",
|
||||
"CNAME",
|
||||
"MX",
|
||||
"TXT",
|
||||
"NS",
|
||||
"SRV",
|
||||
"CAA",
|
||||
"PTR",
|
||||
] as const;
|
||||
|
||||
type RecordType = (typeof DNS_RECORD_TYPES)[number];
|
||||
|
||||
export const PROXIABLE_TYPES: readonly string[] = ["A", "AAAA", "CNAME"];
|
||||
|
||||
const valueFields: Record<
|
||||
RecordType,
|
||||
{ label: string; placeholder: string; hint?: string }
|
||||
> = {
|
||||
A: { label: "IPv4 address", placeholder: "203.0.113.10" },
|
||||
AAAA: { label: "IPv6 address", placeholder: "2001:db8::1" },
|
||||
CNAME: { label: "Target", placeholder: "app.example.com" },
|
||||
MX: {
|
||||
label: "Mail server",
|
||||
placeholder: "10 mail.example.com",
|
||||
hint: "Start with the priority, then the mail server.",
|
||||
},
|
||||
TXT: { label: "Value", placeholder: "v=spf1 include:_spf.example.com ~all" },
|
||||
NS: { label: "Nameserver", placeholder: "ns1.example.com" },
|
||||
SRV: {
|
||||
label: "Target",
|
||||
placeholder: "1 10 5269 talk.example.com",
|
||||
hint: "Priority, weight, port, then target.",
|
||||
},
|
||||
CAA: {
|
||||
label: "Value",
|
||||
placeholder: '0 issue "letsencrypt.org"',
|
||||
hint: "Flags, tag, then the quoted value.",
|
||||
},
|
||||
PTR: { label: "Target", placeholder: "host.example.com" },
|
||||
};
|
||||
|
||||
const structuredValuePatterns: Partial<Record<RecordType, RegExp>> = {
|
||||
SRV: /^\d+\s+\d+\s+\d+\s+\S+$/,
|
||||
CAA: /^\d+\s+\S+\s+.+$/,
|
||||
};
|
||||
|
||||
const DnsRecordSchema = z
|
||||
.object({
|
||||
type: z.enum(DNS_RECORD_TYPES),
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
content: z.string().min(1, { message: "Content is required" }),
|
||||
ttl: z.string(),
|
||||
proxied: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const values = data.content
|
||||
.split("\n")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (!values.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["content"],
|
||||
message: "Content is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pattern = structuredValuePatterns[data.type];
|
||||
if (pattern && !values.every((value) => pattern.test(value))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["content"],
|
||||
message: `Expected ${valueFields[data.type].placeholder}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
|
||||
|
||||
export interface DnsRecordValue {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
proxied?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
zoneName: string;
|
||||
record: DnsRecordValue | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DnsRecordPanel = ({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
zoneName,
|
||||
record,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const createRecord = api.dnsProvider.createRecord.useMutation();
|
||||
const updateRecord = api.dnsProvider.updateRecord.useMutation();
|
||||
const { mutateAsync, isPending, error, isError } = record
|
||||
? updateRecord
|
||||
: createRecord;
|
||||
|
||||
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const { data: panelPublicIp } = api.server.publicIp.useQuery();
|
||||
const { data: panelStoredIp } = api.settings.getIp.useQuery();
|
||||
|
||||
const panelIp = panelPublicIp || panelStoredIp;
|
||||
const ipSuggestions = [
|
||||
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
|
||||
...(servers ?? []).map((server) => ({
|
||||
ip: server.ipAddress,
|
||||
label: server.name,
|
||||
})),
|
||||
].filter(
|
||||
(suggestion, index, all) =>
|
||||
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
|
||||
);
|
||||
|
||||
const form = useForm<DnsRecordForm>({
|
||||
defaultValues: record
|
||||
? {
|
||||
type: (DNS_RECORD_TYPES.includes(record.type as RecordType)
|
||||
? record.type
|
||||
: "A") as RecordType,
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
|
||||
proxied: record.proxied ?? false,
|
||||
}
|
||||
: { type: "A", name: "", content: "", ttl: "", proxied: false },
|
||||
resolver: zodResolver(DnsRecordSchema),
|
||||
});
|
||||
|
||||
const type = form.watch("type");
|
||||
const proxied = form.watch("proxied");
|
||||
const canProxy =
|
||||
provider?.providerType === "cloudflare" && PROXIABLE_TYPES.includes(type);
|
||||
const supportsMultipleValues = provider?.providerType === "route53";
|
||||
const usesAutomaticTtl = canProxy && proxied;
|
||||
|
||||
const onSubmit = async (data: DnsRecordForm) => {
|
||||
const name = data.name.trim() === "@" ? zoneName : data.name;
|
||||
const isProxied = canProxy && data.proxied;
|
||||
const payload = {
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
type: data.type,
|
||||
name,
|
||||
content: data.content,
|
||||
ttl: !isProxied && data.ttl ? Number(data.ttl) : undefined,
|
||||
...(canProxy && { proxied: data.proxied }),
|
||||
...(record && { recordId: record.id }),
|
||||
};
|
||||
await mutateAsync(payload as any)
|
||||
.then(() => {
|
||||
toast.success(record ? "Record updated" : "Record created");
|
||||
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{record ? "Edit record" : "New record"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{zoneName}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{DNS_RECORD_TYPES.map((recordType) => (
|
||||
<SelectItem key={recordType} value={recordType}>
|
||||
{recordType}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="app.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use <code>@</code> for the root domain.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === "A" && ipSuggestions.length > 0 && (
|
||||
<FormItem>
|
||||
<FormLabel>Fill from server (optional)</FormLabel>
|
||||
<Select
|
||||
onValueChange={(ip) =>
|
||||
form.setValue("content", ip, { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ipSuggestions.map((suggestion) => (
|
||||
<SelectItem key={suggestion.ip} value={suggestion.ip}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>{suggestion.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{suggestion.ip}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{valueFields[type].label}</FormLabel>
|
||||
<FormControl>
|
||||
{supportsMultipleValues ? (
|
||||
<Textarea
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
placeholder={valueFields[type].placeholder}
|
||||
{...field}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={valueFields[type].placeholder}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{(supportsMultipleValues || valueFields[type].hint) && (
|
||||
<FormDescription>
|
||||
{[
|
||||
valueFields[type].hint,
|
||||
supportsMultipleValues &&
|
||||
"One value per line: every line belongs to the same record set.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{canProxy && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proxied"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Proxy status</FormLabel>
|
||||
<fieldset className="relative grid min-w-0 grid-cols-2 rounded-lg border bg-muted/40 p-1">
|
||||
<legend className="sr-only">Proxy status</legend>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-1 left-1 z-0 w-[calc(50%-4px)] rounded-md bg-background shadow-sm ring-1 ring-foreground/10 transition-transform duration-[250ms] ease-[var(--ease-smooth-out)] will-change-transform motion-reduce:transition-none",
|
||||
field.value && "translate-x-full",
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!field.value}
|
||||
onClick={() => field.onChange(false)}
|
||||
className={cn(
|
||||
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
|
||||
field.value
|
||||
? "text-muted-foreground hover:text-foreground"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
<CloudOff className="size-3.5" />
|
||||
DNS only
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={field.value}
|
||||
onClick={() => field.onChange(true)}
|
||||
className={cn(
|
||||
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
|
||||
field.value
|
||||
? "text-[#f6821f]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Cloud className="size-3.5" />
|
||||
Proxied
|
||||
</button>
|
||||
</fieldset>
|
||||
<FormDescription>
|
||||
{field.value
|
||||
? "Traffic runs through Cloudflare and the origin IP stays hidden."
|
||||
: "Cloudflare only answers the DNS query; traffic reaches the origin directly."}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ttl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>TTL (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
className="tabular-nums"
|
||||
disabled={usesAutomaticTtl}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{usesAutomaticTtl && (
|
||||
<FormDescription>
|
||||
Proxied records always use automatic TTL.
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{record ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const recordTypeStyles: Record<string, string> = {
|
||||
A: "bg-blue-500/10 text-blue-700 ring-blue-500/25 dark:text-blue-300",
|
||||
AAAA: "bg-indigo-500/10 text-indigo-700 ring-indigo-500/25 dark:text-indigo-300",
|
||||
CNAME:
|
||||
"bg-violet-500/10 text-violet-700 ring-violet-500/25 dark:text-violet-300",
|
||||
MX: "bg-amber-500/10 text-amber-700 ring-amber-500/25 dark:text-amber-300",
|
||||
TXT: "bg-emerald-500/10 text-emerald-700 ring-emerald-500/25 dark:text-emerald-300",
|
||||
NS: "bg-cyan-500/10 text-cyan-700 ring-cyan-500/25 dark:text-cyan-300",
|
||||
SRV: "bg-rose-500/10 text-rose-700 ring-rose-500/25 dark:text-rose-300",
|
||||
CAA: "bg-teal-500/10 text-teal-700 ring-teal-500/25 dark:text-teal-300",
|
||||
PTR: "bg-orange-500/10 text-orange-700 ring-orange-500/25 dark:text-orange-300",
|
||||
SOA: "bg-slate-500/10 text-slate-700 ring-slate-500/25 dark:text-slate-300",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DnsRecordTypeBadge = ({ type, className }: Props) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 min-w-16 shrink-0 items-center justify-center rounded-md px-2 font-mono text-[11px] font-semibold tracking-wide ring-1 ring-inset",
|
||||
recordTypeStyles[type.toUpperCase()] ??
|
||||
"bg-foreground/5 text-muted-foreground ring-foreground/15",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{type.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
@@ -33,29 +33,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const providerLabels = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
porkbun: "Porkbun",
|
||||
infomaniak: "Infomaniak",
|
||||
ovh: "OVHcloud",
|
||||
} as const;
|
||||
|
||||
const ovhEndpointLabels = {
|
||||
"ovh-eu": "OVHcloud Europe",
|
||||
"ovh-ca": "OVHcloud Canada",
|
||||
"ovh-us": "OVHcloud US",
|
||||
"kimsufi-eu": "Kimsufi Europe",
|
||||
"kimsufi-ca": "Kimsufi Canada",
|
||||
"soyoustart-eu": "So you Start Europe",
|
||||
"soyoustart-ca": "So you Start Canada",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
@@ -67,27 +49,10 @@ const DnsProviderSchema = z.object({
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: "Only letters, numbers, dashes and underscores",
|
||||
}),
|
||||
providerType: z.enum([
|
||||
"cloudflare",
|
||||
"route53",
|
||||
"porkbun",
|
||||
"infomaniak",
|
||||
"ovh",
|
||||
]),
|
||||
providerType: z.enum(["cloudflare", "route53"]),
|
||||
apiToken: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
apiKey: z.string(),
|
||||
secretApiKey: z.string(),
|
||||
endpoint: z.enum(
|
||||
Object.keys(ovhEndpointLabels) as [
|
||||
keyof typeof ovhEndpointLabels,
|
||||
...(keyof typeof ovhEndpointLabels)[],
|
||||
],
|
||||
),
|
||||
applicationKey: z.string(),
|
||||
applicationSecret: z.string(),
|
||||
consumerKey: z.string(),
|
||||
});
|
||||
|
||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||
@@ -98,12 +63,6 @@ const defaultValues: DnsProviderForm = {
|
||||
apiToken: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
apiKey: "",
|
||||
secretApiKey: "",
|
||||
endpoint: "ovh-eu",
|
||||
applicationKey: "",
|
||||
applicationSecret: "",
|
||||
consumerKey: "",
|
||||
};
|
||||
|
||||
const buildConfig = (data: DnsProviderForm) => {
|
||||
@@ -119,25 +78,6 @@ const buildConfig = (data: DnsProviderForm) => {
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
};
|
||||
case "porkbun":
|
||||
return {
|
||||
providerType: "porkbun" as const,
|
||||
apiKey: data.apiKey,
|
||||
secretApiKey: data.secretApiKey,
|
||||
};
|
||||
case "infomaniak":
|
||||
return {
|
||||
providerType: "infomaniak" as const,
|
||||
apiToken: data.apiToken,
|
||||
};
|
||||
case "ovh":
|
||||
return {
|
||||
providerType: "ovh" as const,
|
||||
endpoint: data.endpoint,
|
||||
applicationKey: data.applicationKey,
|
||||
applicationSecret: data.applicationSecret,
|
||||
consumerKey: data.consumerKey,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -195,19 +135,6 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
accessKeyId: provider.config.accessKeyId,
|
||||
secretAccessKey: provider.config.secretAccessKey,
|
||||
}),
|
||||
...(provider.config.providerType === "porkbun" && {
|
||||
apiKey: provider.config.apiKey,
|
||||
secretApiKey: provider.config.secretApiKey,
|
||||
}),
|
||||
...(provider.config.providerType === "infomaniak" && {
|
||||
apiToken: provider.config.apiToken,
|
||||
}),
|
||||
...(provider.config.providerType === "ovh" && {
|
||||
endpoint: provider.config.endpoint,
|
||||
applicationKey: provider.config.applicationKey,
|
||||
applicationSecret: provider.config.applicationSecret,
|
||||
consumerKey: provider.config.consumerKey,
|
||||
}),
|
||||
});
|
||||
} else if (!dnsProviderId) {
|
||||
form.reset(defaultValues);
|
||||
@@ -253,30 +180,22 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
{dnsProviderId ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<PenBoxIcon className="size-4" />
|
||||
<span className="sr-only">Edit provider</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit provider</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<PlusIcon className="size-4" />
|
||||
<DialogTrigger asChild>
|
||||
{dnsProviderId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<PenBoxIcon className="size-4 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="cursor-pointer space-x-3">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -400,154 +319,6 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "porkbun" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretApiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Create API keys at porkbun.com/account/api and make sure
|
||||
API access is enabled for the domains you want Dokploy
|
||||
to manage.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "infomaniak" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Create a token at manager.infomaniak.com with the{" "}
|
||||
<code>domain:read</code>, <code>dns:read</code> and{" "}
|
||||
<code>dns:write</code> scopes.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{providerType === "ovh" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Endpoint</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an endpoint" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(ovhEndpointLabels).map(
|
||||
([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="applicationKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="applicationSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="consumerKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Consumer Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Create the three keys at once on
|
||||
api.ovh.com/createToken, with exactly these five rights:
|
||||
<br />
|
||||
<code>GET /domain/zone</code>
|
||||
<br />
|
||||
<code>GET /domain/zone/*</code>
|
||||
<br />
|
||||
<code>POST /domain/zone/*</code>
|
||||
<br />
|
||||
<code>PUT /domain/zone/*</code>
|
||||
<br />
|
||||
<code>DELETE /domain/zone/*</code>
|
||||
<br />
|
||||
The first one lists your zones and has to be granted on
|
||||
its own: OVH matches rights per exact path, so{" "}
|
||||
<code>/domain/zone/*</code> does not cover it.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DnsRecordSchema = z.object({
|
||||
type: z.enum(["A", "CNAME"]),
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
content: z.string().min(1, { message: "Content is required" }),
|
||||
ttl: z.string(),
|
||||
});
|
||||
|
||||
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
|
||||
|
||||
interface DnsRecordValue {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
zoneName: string;
|
||||
record?: DnsRecordValue;
|
||||
}
|
||||
|
||||
export const HandleDnsRecord = ({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
zoneName,
|
||||
record,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync, isPending, error, isError } = record
|
||||
? api.dnsProvider.updateRecord.useMutation()
|
||||
: api.dnsProvider.createRecord.useMutation();
|
||||
|
||||
const { data: servers } = api.server.all.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const panelIp = panelPublicIp || panelStoredIp;
|
||||
const ipSuggestions = [
|
||||
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
|
||||
...(servers ?? []).map((server) => ({
|
||||
ip: server.ipAddress,
|
||||
label: server.name,
|
||||
})),
|
||||
].filter(
|
||||
(suggestion, index, all) =>
|
||||
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
|
||||
);
|
||||
|
||||
const form = useForm<DnsRecordForm>({
|
||||
defaultValues: record
|
||||
? {
|
||||
type: record.type === "CNAME" ? "CNAME" : "A",
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
|
||||
}
|
||||
: { type: "A", name: "", content: "", ttl: "" },
|
||||
resolver: zodResolver(DnsRecordSchema),
|
||||
});
|
||||
|
||||
const type = form.watch("type");
|
||||
|
||||
const onSubmit = async (data: DnsRecordForm) => {
|
||||
const name = data.name.trim() === "@" ? zoneName : data.name;
|
||||
const payload = {
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
type: data.type,
|
||||
name,
|
||||
content: data.content,
|
||||
ttl: data.ttl ? Number(data.ttl) : undefined,
|
||||
...(record && { recordId: record.id }),
|
||||
};
|
||||
await mutateAsync(payload as any)
|
||||
.then(() => {
|
||||
toast.success(record ? "Record updated" : "Record created");
|
||||
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{record ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 size-6"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm">
|
||||
<PlusIcon className="size-3.5 mr-1.5" />
|
||||
Add Record
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{record ? "Edit Record" : "Add Record"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{record
|
||||
? "Update this DNS record."
|
||||
: "Create a new A or CNAME record in this zone."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="A">A</SelectItem>
|
||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="app.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use <code>@</code> for the root domain.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === "A" && ipSuggestions.length > 0 && (
|
||||
<FormItem>
|
||||
<FormLabel>Fill from server (optional)</FormLabel>
|
||||
<Select
|
||||
onValueChange={(ip) =>
|
||||
form.setValue("content", ip, { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ipSuggestions.map((suggestion) => (
|
||||
<SelectItem key={suggestion.ip} value={suggestion.ip}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>{suggestion.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{suggestion.ip}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{type === "A" ? "IPv4 Address" : "Target"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
type === "A" ? "203.0.113.10" : "app.example.com"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ttl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>TTL (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="Auto" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{record ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { api } from "@/utils/api";
|
||||
import { HandleDnsRecord } from "./handle-dns-record";
|
||||
|
||||
interface ZoneRecordsProps {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
zoneName: string;
|
||||
}
|
||||
|
||||
const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: ZoneRecordsProps) => {
|
||||
const utils = api.useUtils();
|
||||
const { data, isLoading, isError, error } =
|
||||
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { mutateAsync: deleteRecord, isPending: isDeleting } =
|
||||
api.dnsProvider.deleteRecord.useMutation();
|
||||
|
||||
const canWrite = !!permissions?.dnsProvider.update;
|
||||
const canDelete = !!permissions?.dnsProvider.delete;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pl-8 pb-2 pr-3">
|
||||
{isLoading && (
|
||||
<div className="flex flex-row gap-2 items-center text-sm text-muted-foreground py-3">
|
||||
<span>Loading records...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive py-2">{error?.message}</p>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No records found in this zone.
|
||||
</p>
|
||||
)}
|
||||
{data?.map((record) => {
|
||||
const isEditable = record.type === "A" || record.type === "CNAME";
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{record.type}
|
||||
</Badge>
|
||||
<span className="font-medium truncate">{record.name}</span>
|
||||
<span className="text-muted-foreground truncate flex-1">
|
||||
→ {record.content}
|
||||
</span>
|
||||
{canWrite && isEditable && (
|
||||
<HandleDnsRecord
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zoneId}
|
||||
zoneName={zoneName}
|
||||
record={record}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Record"
|
||||
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteRecord({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
recordId: record.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Record deleted");
|
||||
utils.dnsProvider.listRecords.invalidate({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the record");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 size-6"
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canWrite && (
|
||||
<div className="pt-1">
|
||||
<HandleDnsRecord
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zoneId}
|
||||
zoneName={zoneName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export const ShowDnsProviderZones = ({
|
||||
dnsProviderId,
|
||||
providerName,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [expandedZoneId, setExpandedZoneId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, error } =
|
||||
api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen });
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setExpandedZoneId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Globe className="size-3.5 mr-1.5" />
|
||||
View Domains
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domains for {providerName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Zones this provider's API token can manage. Click a zone to see and
|
||||
manage its records.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isLoading && (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground py-6">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive py-2">{error?.message}</p>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No zones found for this token. Make sure it has access to at least
|
||||
one zone.
|
||||
</p>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
|
||||
{data.map((zone) => {
|
||||
const isExpanded = expandedZoneId === zone.id;
|
||||
return (
|
||||
<div key={zone.id} className="rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left"
|
||||
onClick={() =>
|
||||
setExpandedZoneId(isExpanded ? null : zone.id)
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<Globe className="size-3.5 text-muted-foreground shrink-0" />
|
||||
{zone.name}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<ZoneRecords
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zone.id}
|
||||
zoneName={zone.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
import { EyeIcon, Globe, Loader2, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Globe, Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -12,159 +11,131 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleDnsProvider } from "./handle-dns-provider";
|
||||
import { ShowDnsProviderZones } from "./show-dns-provider-zones";
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
porkbun: "Porkbun",
|
||||
infomaniak: "Infomaniak",
|
||||
ovh: "OVHcloud",
|
||||
};
|
||||
|
||||
export const ShowDnsProviders = () => {
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const { mutateAsync } = api.dnsProvider.remove.useMutation();
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.dnsProvider.remove.useMutation();
|
||||
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<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">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
|
||||
<CardHeader className="flex-1 p-0">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Globe className="size-6 text-muted-foreground self-center" />
|
||||
DNS Providers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connect a DNS provider so Dokploy can create the A/CNAME record
|
||||
for a domain instead of you setting it up by hand.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{permissions?.dnsProvider.create && <HandleDnsProvider />}
|
||||
</div>
|
||||
|
||||
<CardContent className="flex min-h-[60vh] flex-col gap-4 border-t py-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Globe className="size-6 text-muted-foreground self-center" />
|
||||
DNS Providers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connect a DNS provider so Dokploy can create the A/CNAME record
|
||||
for a domain instead of you setting it up by hand.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isPending ? (
|
||||
<div className="flex flex-1 flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<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 min-h-[50vh] flex-col items-center justify-center gap-3">
|
||||
<Globe className="size-8 text-muted-foreground" />
|
||||
<span className="font-medium text-muted-foreground">
|
||||
No DNS providers connected
|
||||
</span>
|
||||
<span className="max-w-sm text-center text-sm text-muted-foreground">
|
||||
Add Cloudflare or Route53 credentials to manage domain records
|
||||
without leaving Dokploy.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{data?.map((provider) => {
|
||||
const ProviderIcon = dnsProviderIcons[provider.providerType];
|
||||
const href = `/dashboard/settings/dns/${provider.dnsProviderId}`;
|
||||
return (
|
||||
<li
|
||||
key={provider.dnsProviderId}
|
||||
className="group relative flex items-center gap-3 rounded-lg border bg-background px-4 py-3 transition-colors duration-150 ease-out hover:border-foreground/20 hover:bg-muted/50 focus-within:border-ring"
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={`View domains for ${provider.name}`}
|
||||
className="absolute inset-0 rounded-lg outline-none"
|
||||
/>
|
||||
<ProviderIcon className="size-7 shrink-0" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{provider.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{providerLabels[provider.providerType] ??
|
||||
provider.providerType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 ml-auto flex flex-row items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href={href}>
|
||||
<EyeIcon className="size-4" />
|
||||
<span className="sr-only">View domains</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View domains</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{permissions?.dnsProvider.update && (
|
||||
<HandleDnsProvider
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.dnsProvider.delete && (
|
||||
<Tooltip>
|
||||
<DialogAction
|
||||
title="Delete DNS Provider"
|
||||
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
setRemovingId(provider.dnsProviderId);
|
||||
await mutateAsync({
|
||||
dnsProviderId: provider.dnsProviderId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("DNS provider deleted");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting the DNS provider",
|
||||
);
|
||||
})
|
||||
.finally(() => setRemovingId(null));
|
||||
}}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:bg-red-500/10 hover:text-red-500"
|
||||
isLoading={
|
||||
removingId === provider.dnsProviderId
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">
|
||||
Delete provider
|
||||
<>
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<Globe className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any DNS providers configured
|
||||
</span>
|
||||
{permissions?.dnsProvider.create && <HandleDnsProvider />}
|
||||
</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 =
|
||||
dnsProviderIcons[provider.providerType];
|
||||
return (
|
||||
<div
|
||||
key={provider.dnsProviderId}
|
||||
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>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DialogAction>
|
||||
<TooltipContent>Delete provider</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Badge variant="outline">
|
||||
{providerLabels[provider.providerType] ??
|
||||
provider.providerType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
<ShowDnsProviderZones
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
providerName={provider.name}
|
||||
/>
|
||||
{permissions?.dnsProvider.update && (
|
||||
<HandleDnsProvider
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.dnsProvider.delete && (
|
||||
<DialogAction
|
||||
title="Delete DNS Provider"
|
||||
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
dnsProviderId: provider.dnsProviderId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("DNS provider deleted");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting the DNS 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?.dnsProvider.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleDnsProvider />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
@@ -1,589 +0,0 @@
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpDown,
|
||||
Cloud,
|
||||
CloudOff,
|
||||
ListTree,
|
||||
Loader2,
|
||||
PenBoxIcon,
|
||||
PlusIcon,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
DNS_RECORD_TYPES,
|
||||
DnsRecordPanel,
|
||||
type DnsRecordValue,
|
||||
PROXIABLE_TYPES,
|
||||
} from "./dns-record-panel";
|
||||
import { DnsRecordTypeBadge } from "./dns-record-type-badge";
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [10, 20, 50, 100];
|
||||
|
||||
const SortableHeader = ({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
title: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn("-ml-2.5 text-muted-foreground", className)}
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{title}
|
||||
<ArrowUpDown className="size-3" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<DnsRecordValue | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
]);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
|
||||
const { data: zones } = api.dnsProvider.listZones.useQuery({ dnsProviderId });
|
||||
const { data, isPending, isError, error } =
|
||||
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { mutateAsync: deleteRecord } =
|
||||
api.dnsProvider.deleteRecord.useMutation();
|
||||
|
||||
const zoneName = zones?.find((zone) => zone.id === zoneId)?.name ?? "";
|
||||
const isCloudflare = provider?.providerType === "cloudflare";
|
||||
const canWrite = !!permissions?.dnsProvider.update;
|
||||
const canDelete = !!permissions?.dnsProvider.delete;
|
||||
|
||||
const openEdit = (record: DnsRecordValue) => {
|
||||
setEditing(record);
|
||||
setIsPanelOpen(true);
|
||||
};
|
||||
|
||||
const availableTypes = useMemo(
|
||||
() => [...new Set((data ?? []).map((record) => record.type))].sort(),
|
||||
[data],
|
||||
);
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return (data ?? []).filter((record) => {
|
||||
if (typeFilter !== "all" && record.type !== typeFilter) return false;
|
||||
if (!query) return true;
|
||||
return (
|
||||
record.name.toLowerCase().includes(query) ||
|
||||
record.content.toLowerCase().includes(query) ||
|
||||
record.type.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [data, search, typeFilter]);
|
||||
|
||||
const handleDelete = async (record: DnsRecordValue) => {
|
||||
setDeletingId(record.id);
|
||||
await deleteRecord({ dnsProviderId, zoneId, recordId: record.id })
|
||||
.then(() => {
|
||||
toast.success("Record deleted");
|
||||
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
|
||||
if (editing?.id === record.id) setIsPanelOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the record");
|
||||
})
|
||||
.finally(() => setDeletingId(null));
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<DnsRecordValue>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <SortableHeader column={column} title="Type" />,
|
||||
cell: ({ row }) => <DnsRecordTypeBadge type={row.original.type} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <SortableHeader column={column} title="Name" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className="block max-w-[22ch] truncate font-medium"
|
||||
title={row.original.name}
|
||||
>
|
||||
{row.original.name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Value" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className="block max-w-[32ch] truncate font-mono text-xs text-muted-foreground"
|
||||
title={row.original.content}
|
||||
>
|
||||
{row.original.content}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "ttl",
|
||||
header: ({ column }) => <SortableHeader column={column} title="TTL" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{row.original.ttl === 1 ? "Auto" : row.original.ttl}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...(isCloudflare
|
||||
? [
|
||||
{
|
||||
accessorKey: "proxied",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Proxy" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
if (!PROXIABLE_TYPES.includes(row.original.type)) {
|
||||
return null;
|
||||
}
|
||||
const proxied = !!row.original.proxied;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
{proxied ? (
|
||||
<Cloud className="size-4 text-[#f6821f]" />
|
||||
) : (
|
||||
<CloudOff className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{proxied ? "Proxied" : "DNS only"}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{proxied ? "Proxied" : "DNS only"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
} satisfies ColumnDef<DnsRecordValue>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "actions",
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
const isEditable = (DNS_RECORD_TYPES as readonly string[]).includes(
|
||||
record.type,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{canWrite && isEditable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => openEdit(record)}
|
||||
>
|
||||
<PenBoxIcon className="size-4" />
|
||||
<span className="sr-only">Edit record</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit record</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Tooltip>
|
||||
<DialogAction
|
||||
title="Delete Record"
|
||||
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
|
||||
type="destructive"
|
||||
onClick={() => handleDelete(record)}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:bg-red-500/10 hover:text-red-500"
|
||||
isLoading={deletingId === record.id}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">Delete record</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DialogAction>
|
||||
<TooltipContent>Delete record</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[canWrite, canDelete, deletingId, editing?.id, isCloudflare],
|
||||
);
|
||||
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(filteredRecords.length / pagination.pageSize),
|
||||
);
|
||||
const pageIndex = Math.min(pagination.pageIndex, pageCount - 1);
|
||||
|
||||
const resetToFirstPage = () =>
|
||||
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredRecords,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
sorting,
|
||||
pagination: { pageIndex, pageSize: pagination.pageSize },
|
||||
},
|
||||
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">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
|
||||
<div className="flex flex-1 flex-row items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/dashboard/settings/dns/${dnsProviderId}`}>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span className="sr-only">Back to domains</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<CardHeader className="flex-1 p-0">
|
||||
<CardTitle className="text-xl">
|
||||
{zoneName || "DNS records"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Records managed through {provider?.name ?? "this provider"}.
|
||||
Changes are written straight to the provider.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setIsPanelOpen(true);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add Record
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardContent className="min-h-[60vh] border-t py-8">
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<div className="flex flex-col-reverse gap-4 lg:flex-row lg:items-start">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
{isPending ? (
|
||||
<div className="flex min-h-[45vh] flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<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">
|
||||
<ListTree className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
No records in this zone
|
||||
</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Add an A or CNAME record to point this domain at one of
|
||||
your servers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-52 flex-1">
|
||||
<FocusShortcutInput
|
||||
placeholder="Filter records..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
resetToFirstPage();
|
||||
}}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onValueChange={(value) => {
|
||||
setTypeFilter(value);
|
||||
resetToFirstPage();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
{availableTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
{filteredRecords.length} of {data?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="[&_tr]:border-b">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="bg-muted/40 hover:bg-muted/40"
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
"h-9 px-4 text-xs",
|
||||
header.id === "actions" && "text-right",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const isEditable = (
|
||||
DNS_RECORD_TYPES as readonly string[]
|
||||
).includes(row.original.type);
|
||||
const isSelected =
|
||||
isPanelOpen && editing?.id === row.original.id;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
isSelected ? "selected" : undefined
|
||||
}
|
||||
onClick={
|
||||
canWrite && isEditable
|
||||
? () => openEdit(row.original)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"duration-150 ease-out",
|
||||
canWrite && isEditable && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
onClick={
|
||||
cell.column.id === "actions"
|
||||
? (event) => event.stopPropagation()
|
||||
: undefined
|
||||
}
|
||||
className="px-4 py-2"
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No records match your filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
</span>
|
||||
<Select
|
||||
value={String(pagination.pageSize)}
|
||||
onValueChange={(value) =>
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: Number(value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZES.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
Page {pageIndex + 1} of {pageCount}
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"t-panel-track grid lg:shrink-0 lg:grid-rows-[1fr]",
|
||||
isPanelOpen
|
||||
? "grid-rows-[1fr] lg:grid-cols-[1fr]"
|
||||
: "grid-rows-[0fr] lg:grid-cols-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
data-open={isPanelOpen}
|
||||
className="t-panel-slide-x w-full lg:w-[380px]"
|
||||
>
|
||||
{canWrite && (
|
||||
<DnsRecordPanel
|
||||
key={editing?.id ?? "new"}
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zoneId}
|
||||
zoneName={zoneName}
|
||||
record={editing}
|
||||
onClose={() => setIsPanelOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
import { ArrowLeft, Globe, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
}
|
||||
|
||||
const RecordCount = ({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
}: {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
}) => {
|
||||
const { data, isPending, isError } = api.dnsProvider.listRecords.useQuery({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Loader2 className="animate-spin size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">Records unavailable</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{data.length === 0
|
||||
? "No records"
|
||||
: `${data.length} record${data.length === 1 ? "" : "s"}`}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShowDnsZones = ({ dnsProviderId }: Props) => {
|
||||
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
|
||||
const { data, isPending, isError, error } =
|
||||
api.dnsProvider.listZones.useQuery({ dnsProviderId });
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
|
||||
<div className="flex flex-1 flex-row items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/dashboard/settings/dns">
|
||||
<ArrowLeft className="size-4" />
|
||||
<span className="sr-only">Back to DNS providers</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<CardHeader className="flex-1 p-0">
|
||||
<CardTitle className="text-xl">
|
||||
{provider?.name ?? "Domains"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Domains this provider's credentials can manage. Open one to
|
||||
see and edit its DNS records.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="flex min-h-[60vh] flex-col gap-4 border-t py-8">
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
{isPending ? (
|
||||
<div className="flex flex-1 flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<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">
|
||||
<Globe className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<p className="text-sm font-medium">No domains found</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
These credentials can't reach any zone. Check that the token
|
||||
has access to at least one domain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
|
||||
{data?.map((zone) => (
|
||||
<Link
|
||||
key={zone.id}
|
||||
href={`/dashboard/settings/dns/${dnsProviderId}/${zone.id}`}
|
||||
className="group flex flex-col justify-between gap-6 rounded-xl border bg-background p-4 outline-none transition-colors duration-150 ease-out hover:border-foreground/20 hover:bg-muted/40 focus-visible:border-ring"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground transition-colors duration-150 group-hover:text-foreground">
|
||||
<Globe className="size-4" />
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate pt-2 text-sm font-medium"
|
||||
title={zone.name}
|
||||
>
|
||||
{zone.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-end">
|
||||
<RecordCount
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zone.id}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -446,7 +446,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
serverUrl: notification.gotify?.serverUrl,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "ntfy") {
|
||||
form.reset({
|
||||
@@ -682,7 +681,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
decoration: data.decoration,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
gotifyId: notification?.gotifyId || "",
|
||||
});
|
||||
@@ -700,7 +698,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
priority: data.priority,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
ntfyId: notification?.ntfyId || "",
|
||||
});
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { ExternalLink, Loader2, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const serviceTypeLabel: Record<string, string> = {
|
||||
application: "Application",
|
||||
compose: "Compose",
|
||||
postgres: "Postgres",
|
||||
mysql: "MySQL",
|
||||
mariadb: "MariaDB",
|
||||
mongo: "MongoDB",
|
||||
redis: "Redis",
|
||||
libsql: "LibSQL",
|
||||
};
|
||||
|
||||
export const DeleteServerModal = ({
|
||||
serverId,
|
||||
serverName,
|
||||
children,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const {
|
||||
data: services,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = api.server.getServices.useQuery({ serverId }, { enabled: open });
|
||||
|
||||
const { mutateAsync: deleteApplication } =
|
||||
api.application.delete.useMutation();
|
||||
const { mutateAsync: deleteCompose } = api.compose.delete.useMutation();
|
||||
const { mutateAsync: deletePostgres } = api.postgres.remove.useMutation();
|
||||
const { mutateAsync: deleteMysql } = api.mysql.remove.useMutation();
|
||||
const { mutateAsync: deleteMariadb } = api.mariadb.remove.useMutation();
|
||||
const { mutateAsync: deleteMongo } = api.mongo.remove.useMutation();
|
||||
const { mutateAsync: deleteRedis } = api.redis.remove.useMutation();
|
||||
const { mutateAsync: deleteLibsql } = api.libsql.remove.useMutation();
|
||||
const { mutateAsync: deleteServer, isPending: isDeletingServer } =
|
||||
api.server.remove.useMutation();
|
||||
|
||||
const canDelete = (services?.length ?? 0) === 0;
|
||||
|
||||
const handleDeleteService = async (
|
||||
service: NonNullable<typeof services>[number],
|
||||
) => {
|
||||
setDeletingId(service.id);
|
||||
try {
|
||||
switch (service.type) {
|
||||
case "application":
|
||||
await deleteApplication({ applicationId: service.id });
|
||||
break;
|
||||
case "compose":
|
||||
await deleteCompose({
|
||||
composeId: service.id,
|
||||
deleteVolumes: false,
|
||||
});
|
||||
break;
|
||||
case "postgres":
|
||||
await deletePostgres({ postgresId: service.id });
|
||||
break;
|
||||
case "mysql":
|
||||
await deleteMysql({ mysqlId: service.id });
|
||||
break;
|
||||
case "mariadb":
|
||||
await deleteMariadb({ mariadbId: service.id });
|
||||
break;
|
||||
case "mongo":
|
||||
await deleteMongo({ mongoId: service.id });
|
||||
break;
|
||||
case "redis":
|
||||
await deleteRedis({ redisId: service.id });
|
||||
break;
|
||||
case "libsql":
|
||||
await deleteLibsql({ libsqlId: service.id });
|
||||
break;
|
||||
}
|
||||
toast.success(`${service.name} deleted successfully`);
|
||||
await refetch();
|
||||
utils.server.all.invalidate();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteServer = async () => {
|
||||
try {
|
||||
await deleteServer({ serverId });
|
||||
toast.success(`Server ${serverName} deleted successfully`);
|
||||
setOpen(false);
|
||||
utils.server.all.invalidate();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete "{serverName}" and all associated data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : canDelete ? (
|
||||
<AlertBlock type="info">
|
||||
No services are associated with this server. You can delete it
|
||||
safely.
|
||||
</AlertBlock>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<AlertBlock type="warning">
|
||||
This server has {services?.length} service
|
||||
{services?.length === 1 ? "" : "s"} associated. Delete them before
|
||||
removing the server.
|
||||
</AlertBlock>
|
||||
<div className="flex max-h-72 flex-col gap-2 overflow-y-auto pr-1">
|
||||
{services?.map((service) => (
|
||||
<div
|
||||
key={`${service.type}-${service.id}`}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border p-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{service.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="w-fit text-xs">
|
||||
{serviceTypeLabel[service.type]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Link href={service.url} target="_blank">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<DialogAction
|
||||
title="Delete Service"
|
||||
description={`This will permanently delete "${service.name}" and all its associated data.`}
|
||||
onClick={() => handleDeleteService(service)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={deletingId === service.id}
|
||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
{deletingId === service.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!canDelete || isDeletingServer}
|
||||
onClick={handleDeleteServer}
|
||||
>
|
||||
Delete Server
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Stethoscope } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const ShowHealthModal = ({ serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex gap-4 py-4 w-full">
|
||||
<ShowHealth serverId={serverId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -30,9 +33,9 @@ import {
|
||||
import { api } from "@/utils/api";
|
||||
import { TerminalModal } from "../web-server/terminal-modal";
|
||||
import { ShowServerActions } from "./actions/show-server-actions";
|
||||
import { DeleteServerModal } from "./delete-server-modal";
|
||||
import { HandleServers } from "./handle-servers";
|
||||
import { SetupServer } from "./setup-server";
|
||||
import { ShowHealthModal } from "./show-health-modal";
|
||||
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
||||
|
||||
@@ -40,6 +43,7 @@ export const ShowServers = () => {
|
||||
const router = useRouter();
|
||||
const query = router.query;
|
||||
const { data, refetch, isPending } = api.server.all.useQuery();
|
||||
const { mutateAsync } = api.server.remove.useMutation();
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: canCreateMoreServers } =
|
||||
@@ -107,6 +111,7 @@ export const ShowServers = () => {
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data?.map((server) => {
|
||||
const canDelete = server.totalSum === 0;
|
||||
const isActive = server.serverStatus === "active";
|
||||
const isBuildServer = server.serverType === "build";
|
||||
return (
|
||||
@@ -115,13 +120,10 @@ export const ShowServers = () => {
|
||||
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<CardTitle
|
||||
className="text-lg truncate min-w-0"
|
||||
title={server.name}
|
||||
>
|
||||
<CardTitle className="text-lg wrap-break-word min-w-0">
|
||||
{server.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
@@ -248,30 +250,29 @@ export const ShowServers = () => {
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
{server.sshKeyId &&
|
||||
permissions?.server.terminal && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
{server.sshKeyId && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TerminalModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Terminal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TerminalModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Terminal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -324,28 +325,88 @@ export const ShowServers = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{permissions?.docker.read &&
|
||||
permissions?.server.read &&
|
||||
server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ShowHealthModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Health</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{permissions?.server.delete && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DeleteServerModal
|
||||
serverId={server.serverId}
|
||||
serverName={server.name}
|
||||
<DialogAction
|
||||
disabled={!canDelete}
|
||||
title={
|
||||
canDelete
|
||||
? "Delete Server"
|
||||
: "Server has active services"
|
||||
}
|
||||
description={
|
||||
canDelete ? (
|
||||
"This will delete the server and all associated data"
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
You can not delete this
|
||||
server because it has
|
||||
active services.
|
||||
<AlertBlock type="warning">
|
||||
You have active
|
||||
services associated
|
||||
with this server,
|
||||
please delete them
|
||||
first.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
serverId: server.serverId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
`Server ${server.name} deleted successfully`,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
err.message,
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DeleteServerModal>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete Server</p>
|
||||
<p>
|
||||
{canDelete
|
||||
? "Delete Server"
|
||||
: "Cannot delete - has active services"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { DialogFooter } from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
ipAddress: z.string().min(1, {
|
||||
message: "IP Address is required",
|
||||
}),
|
||||
port: z.number().optional(),
|
||||
username: z.string().optional(),
|
||||
sshKeyId: z.string().min(1, {
|
||||
message: "SSH Key is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
interface Props {
|
||||
stepper: any;
|
||||
}
|
||||
|
||||
export const CreateServer = ({ stepper }: Props) => {
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const [isOpen, _setIsOpen] = useState(false);
|
||||
const { data: canCreateMoreServers, refetch } =
|
||||
api.stripe.canCreateMoreServers.useQuery();
|
||||
const { mutateAsync } = api.server.create.useMutation();
|
||||
const cloudSSHKey = sshKeys?.find(
|
||||
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
|
||||
);
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
description: "Dokploy Cloud Server",
|
||||
name: "My First Server",
|
||||
ipAddress: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: cloudSSHKey?.sshKeyId || "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
description: "Dokploy Cloud Server",
|
||||
name: "My First Server",
|
||||
ipAddress: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: cloudSSHKey?.sshKeyId || "",
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, sshKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [isOpen]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
await mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
ipAddress: data.ipAddress?.trim() || "",
|
||||
port: data.port || 22,
|
||||
username: data.username || "root",
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
serverType: "deploy",
|
||||
})
|
||||
.then(async (_data) => {
|
||||
toast.success("Server Created");
|
||||
stepper.next();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error creating a server");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Card className="bg-background flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 pt-5 px-4">
|
||||
{!canCreateMoreServers && (
|
||||
<AlertBlock type="warning" className="mt-2">
|
||||
You cannot create more servers,{" "}
|
||||
<Link href="/dashboard/settings/billing" className="text-primary">
|
||||
Please upgrade your plan
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardContent className="flex flex-col">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-server"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 ">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Hostinger Server" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="This server is for databases..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshKeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Select a SSH Key</FormLabel>
|
||||
{!cloudSSHKey && (
|
||||
<AlertBlock>
|
||||
Looks like you didn't have the SSH Key yet, you can create
|
||||
one{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/ssh-keys"
|
||||
className="text-primary"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a SSH Key" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{sshKeys?.map((sshKey) => (
|
||||
<SelectItem
|
||||
key={sshKey.sshKeyId}
|
||||
value={sshKey.sshKeyId}
|
||||
>
|
||||
{sshKey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>SSH Keys ({sshKeys?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ipAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IP Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="192.168.1.100" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="22"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange(0);
|
||||
} else {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(number)) {
|
||||
field.onChange(number);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="root" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
disabled={!canCreateMoreServers}
|
||||
form="hook-form-add-server"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, ExternalLinkIcon, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const CreateSSHKey = () => {
|
||||
const { data, refetch } = api.sshKey.all.useQuery();
|
||||
const generateMutation = api.sshKey.generate.useMutation();
|
||||
const { mutateAsync, isPending } = api.sshKey.create.useMutation();
|
||||
const hasCreatedKey = useRef(false);
|
||||
const [selectedOption, setSelectedOption] = useState<"manual" | "provider">(
|
||||
"manual",
|
||||
);
|
||||
|
||||
const cloudSSHKey = data?.find(
|
||||
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const createKey = async () => {
|
||||
if (!data || cloudSSHKey || hasCreatedKey.current || isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasCreatedKey.current = true;
|
||||
|
||||
try {
|
||||
const keys = await generateMutation.mutateAsync({
|
||||
type: "rsa",
|
||||
});
|
||||
await mutateAsync({
|
||||
name: "dokploy-cloud-ssh-key",
|
||||
description: "Used on Dokploy Cloud",
|
||||
privateKey: keys.privateKey,
|
||||
publicKey: keys.publicKey,
|
||||
organizationId: "",
|
||||
});
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error("Error creating SSH key:", error);
|
||||
hasCreatedKey.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
createKey();
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-transparent">
|
||||
<CardContent>
|
||||
<div className="grid w-full gap-4 pt-4">
|
||||
{isPending || !cloudSSHKey ? (
|
||||
<div className="min-h-[25vh] justify-center flex items-center gap-4">
|
||||
<Loader2
|
||||
className="animate-spin text-muted-foreground"
|
||||
size={32}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 text-sm text-muted-foreground">
|
||||
<p className="text-primary text-base font-semibold">
|
||||
Choose how to add SSH Keys to your server:
|
||||
</p>
|
||||
|
||||
{/* Radio button options */}
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={selectedOption}
|
||||
onValueChange={(value) => {
|
||||
setSelectedOption(value as "manual" | "provider");
|
||||
}}
|
||||
className="grid gap-3"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="manual" id="manual" />
|
||||
<Label
|
||||
htmlFor="manual"
|
||||
className="text-primary font-medium cursor-pointer"
|
||||
>
|
||||
Add SSH Key to Server Manually
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="provider" id="provider" />
|
||||
<Label
|
||||
htmlFor="provider"
|
||||
className="text-primary font-medium cursor-pointer"
|
||||
>
|
||||
Add SSH Key when creating server in your provider
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Content based on selected option */}
|
||||
{selectedOption === "manual" && (
|
||||
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
|
||||
<span className="text-base font-semibold text-primary">
|
||||
Manual Setup Instructions
|
||||
</span>
|
||||
<ul className="space-y-2">
|
||||
<li className="items-center flex gap-1">
|
||||
1. Login to your server
|
||||
</li>
|
||||
<li>
|
||||
2. When you are logged in run the following command
|
||||
<div className="flex relative flex-col gap-4 w-full mt-2">
|
||||
<CodeEditor
|
||||
lineWrapping
|
||||
language="properties"
|
||||
value={`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`}
|
||||
readOnly
|
||||
className="font-mono opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li className="mt-1">
|
||||
3. You're done, follow the next step to insert the
|
||||
details of your server.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOption === "provider" && (
|
||||
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
|
||||
<span className="text-base font-semibold text-primary">
|
||||
Provider Setup Instructions
|
||||
</span>
|
||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||
<div className="flex relative flex-col gap-2 overflow-y-auto">
|
||||
<div className="text-sm text-primary flex flex-row gap-2 items-center">
|
||||
Copy Public Key
|
||||
<button
|
||||
type="button"
|
||||
className="right-2 top-8"
|
||||
onClick={() => {
|
||||
copy(
|
||||
cloudSSHKey?.publicKey || "Generate a SSH Key",
|
||||
);
|
||||
toast.success("SSH Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm mt-2">
|
||||
Use this public key when creating a server in your
|
||||
preferred provider (Hostinger, Digital Ocean, Hetzner,
|
||||
etc.)
|
||||
</p>
|
||||
<Link
|
||||
href="https://docs.dokploy.com/docs/core/remote-servers/instructions#requirements"
|
||||
target="_blank"
|
||||
className="text-primary flex flex-row gap-2 mt-2"
|
||||
>
|
||||
View Tutorial <ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
type LogLine,
|
||||
parseLogs,
|
||||
} from "@/components/dashboard/docker/logs/utils";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { EditScript } from "../edit-script";
|
||||
|
||||
export const Setup = () => {
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const [serverId, setServerId] = useState<string>(
|
||||
servers?.[0]?.serverId || "",
|
||||
);
|
||||
const { data: server } = api.server.one.useQuery(
|
||||
{
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
api.server.setupWithLogs.useSubscription(
|
||||
{
|
||||
serverId: serverId,
|
||||
},
|
||||
{
|
||||
enabled: isDeploying,
|
||||
onData(log) {
|
||||
if (!isDrawerOpen) {
|
||||
setIsDrawerOpen(true);
|
||||
}
|
||||
|
||||
if (log === "Deployment completed successfully!") {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
const parsedLogs = parseLogs(log);
|
||||
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
|
||||
},
|
||||
onError(error) {
|
||||
console.error("Deployment logs error:", error);
|
||||
setIsDeploying(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Label>Select the server and click on setup server</Label>
|
||||
<Select onValueChange={setServerId} defaultValue={serverId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{servers?.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Setup Server</CardTitle>
|
||||
<CardDescription>
|
||||
To setup a server, please click on the button below.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 min-h-[25vh] items-center">
|
||||
<div className="flex flex-col gap-4 items-center h-full max-w-xl mx-auto min-h-[25vh] justify-center">
|
||||
<span className="text-sm text-muted-foreground text-center">
|
||||
When your server is ready, you can click on the button below, to
|
||||
directly run the script we use for setup the server or directly
|
||||
modify the script
|
||||
</span>
|
||||
<div className="flex flex-row gap-2">
|
||||
<EditScript serverId={server?.serverId || ""} />
|
||||
<DialogAction
|
||||
title={"Setup Server?"}
|
||||
type="default"
|
||||
description="This will setup the server and all associated data"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
}}
|
||||
>
|
||||
<Button>Setup Server</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
setFilteredLogs([]);
|
||||
setIsDeploying(false);
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Loader2, PcCase, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { StatusRow } from "../gpu-support";
|
||||
|
||||
export const Verify = () => {
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const [serverId, setServerId] = useState<string>(
|
||||
servers?.[0]?.serverId || "",
|
||||
);
|
||||
const { data, refetch, error, isPending, isError } =
|
||||
api.server.validate.useQuery(
|
||||
{ serverId },
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
return (
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Label>Select a server</Label>
|
||||
<Select onValueChange={setServerId} defaultValue={serverId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{servers?.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<PcCase className="size-5" />
|
||||
<CardTitle className="text-xl">Setup Validation</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Check if your server is ready for deployment
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isRefreshing}
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
await refetch();
|
||||
setIsRefreshing(false);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{isError && (
|
||||
<AlertBlock type="error" className="w-full">
|
||||
{error.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4 min-h-[25vh]">
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center text-muted-foreground py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Checking Server configuration</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid w-full gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-1">Status</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Shows the server configuration status
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
label="Docker Installed"
|
||||
isEnabled={data?.docker?.enabled}
|
||||
description={
|
||||
data?.docker?.enabled
|
||||
? `Installed: ${data?.docker?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="RClone Installed"
|
||||
isEnabled={data?.rclone?.enabled}
|
||||
description={
|
||||
data?.rclone?.enabled
|
||||
? `Installed: ${data?.rclone?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Nixpacks Installed"
|
||||
isEnabled={data?.nixpacks?.enabled}
|
||||
description={
|
||||
data?.nixpacks?.enabled
|
||||
? `Installed: ${data?.nixpacks?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Buildpacks Installed"
|
||||
isEnabled={data?.buildpacks?.enabled}
|
||||
description={
|
||||
data?.buildpacks?.enabled
|
||||
? `Installed: ${data?.buildpacks?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Docker Swarm Initialized"
|
||||
isEnabled={data?.isSwarmInstalled}
|
||||
description={
|
||||
data?.isSwarmInstalled
|
||||
? "Initialized"
|
||||
: "Not Initialized"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Dokploy Network Created"
|
||||
isEnabled={data?.isDokployNetworkInstalled}
|
||||
description={
|
||||
data?.isDokployNetworkInstalled
|
||||
? "Created"
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Main Directory Created"
|
||||
isEnabled={data?.isMainDirectoryInstalled}
|
||||
description={
|
||||
data?.isMainDirectoryInstalled
|
||||
? "Created"
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
+415
-235
@@ -1,261 +1,441 @@
|
||||
import { defineStepper } from "@stepperize/react";
|
||||
import {
|
||||
CheckIcon,
|
||||
DatabaseIcon,
|
||||
GitMergeIcon,
|
||||
GlobeIcon,
|
||||
UsersIcon,
|
||||
BookIcon,
|
||||
Code2,
|
||||
Database,
|
||||
GitMerge,
|
||||
Globe,
|
||||
Plug,
|
||||
Puzzle,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ConfettiExplosion from "react-confetti-explosion";
|
||||
import { DeployStep } from "@/components/dashboard/onboarding/steps/deploy-step";
|
||||
import { ProjectStep } from "@/components/dashboard/onboarding/steps/project-step";
|
||||
import { ServerStep } from "@/components/dashboard/onboarding/steps/server-step";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CreateServer } from "./create-server";
|
||||
import { CreateSSHKey } from "./create-ssh-key";
|
||||
import { Setup } from "./setup";
|
||||
import { Verify } from "./verify";
|
||||
|
||||
const { useStepper, steps, Scoped } = defineStepper(
|
||||
{ id: "welcome", title: "Welcome" },
|
||||
{ id: "project", title: "New project" },
|
||||
{ id: "server", title: "Connect server" },
|
||||
{ id: "deploy", title: "Ship something" },
|
||||
{ id: "complete", title: "You're live" },
|
||||
export const { useStepper, steps, Scoped } = defineStepper(
|
||||
{
|
||||
id: "requisites",
|
||||
title: "Requisites",
|
||||
description: "Check your requisites",
|
||||
},
|
||||
{
|
||||
id: "create-ssh-key",
|
||||
title: "SSH Key",
|
||||
description: "Create your ssh key",
|
||||
},
|
||||
{
|
||||
id: "connect-server",
|
||||
title: "Connect",
|
||||
description: "Connect",
|
||||
},
|
||||
{ id: "setup", title: "Setup", description: "Setup your server" },
|
||||
{ id: "verify", title: "Verify", description: "Verify your server" },
|
||||
{ id: "complete", title: "Complete", description: "Checkout complete" },
|
||||
);
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: DatabaseIcon,
|
||||
title: "Databases",
|
||||
description: "Postgres, MySQL, MongoDB, Redis and more, one click away.",
|
||||
},
|
||||
{
|
||||
icon: GlobeIcon,
|
||||
title: "Custom domains",
|
||||
description: "Attach your own domains and get automatic HTTPS.",
|
||||
},
|
||||
{
|
||||
icon: GitMergeIcon,
|
||||
title: "CI/CD",
|
||||
description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.",
|
||||
},
|
||||
{
|
||||
icon: UsersIcon,
|
||||
title: "Team collaboration",
|
||||
description: "Invite teammates with fine-grained permissions.",
|
||||
},
|
||||
];
|
||||
|
||||
const WelcomeIntro = ({ onNext }: { onNext: () => void }) => (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Welcome
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Welcome to Dokploy Cloud
|
||||
</h1>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Thanks for subscribing — you're all set up. Next, connect a server so
|
||||
you can start deploying.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" onClick={onNext} className="w-fit px-8">
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CompleteIntro = ({ onFinish }: { onFinish: () => void }) => {
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
useEffect(() => {
|
||||
setShowConfetti(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="fixed inset-x-0 top-0 flex justify-center pointer-events-none">
|
||||
{showConfetti && (
|
||||
<ConfettiExplosion
|
||||
duration={3000}
|
||||
force={0.35}
|
||||
particleCount={200}
|
||||
width={1200}
|
||||
zIndex={60}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Done
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
You're all set.
|
||||
</h1>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Your server is connected — here's some of what you can do next.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="flex flex-col divide-y">
|
||||
{features.map((feature, index) => (
|
||||
<div key={feature.title} className="flex gap-6 py-4 first:pt-0">
|
||||
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</dt>
|
||||
<dd className="flex flex-col gap-1">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
<feature.icon className="size-4 text-muted-foreground" />
|
||||
{feature.title}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{feature.description}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button size="lg" className="w-fit px-8" onClick={onFinish}>
|
||||
Go to dashboard
|
||||
</Button>
|
||||
<Link
|
||||
href="https://discord.com/invite/2tBnJ3jDJc"
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Need help? Join our Discord →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WelcomeSubscription = () => {
|
||||
const router = useRouter();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const stepper = useStepper();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const router = useRouter();
|
||||
const { push } = router;
|
||||
|
||||
// This flow can also fire for an existing customer upgrading their plan
|
||||
// (any checkout success redirects here), not just a first-time
|
||||
// subscriber — so "New project" only belongs in the flow when they don't
|
||||
// already have one. Mirrors the isCloud-based step filtering in the main
|
||||
// onboarding wizard.
|
||||
const { data: projects } = api.project.all.useQuery();
|
||||
const hasExistingProject = (projects?.length ?? 0) > 0;
|
||||
const visibleStepIds = hasExistingProject
|
||||
? steps.filter((step) => step.id !== "project").map((step) => step.id)
|
||||
: steps.map((step) => step.id);
|
||||
const visibleIndex = visibleStepIds.indexOf(stepper.current.id);
|
||||
const isLast = visibleIndex === visibleStepIds.length - 1;
|
||||
const goToNextVisible = () => {
|
||||
const nextId = visibleStepIds[visibleIndex + 1];
|
||||
if (nextId) stepper.goTo(nextId);
|
||||
};
|
||||
|
||||
const [projectId, setProjectId] = useState<string | undefined>();
|
||||
const [environmentId, setEnvironmentId] = useState<string | undefined>();
|
||||
|
||||
const firstExistingProjectId = projects?.[0]?.projectId;
|
||||
const { data: existingEnvironments } = api.environment.byProjectId.useQuery(
|
||||
{ projectId: firstExistingProjectId ?? "" },
|
||||
{ enabled: !!firstExistingProjectId && !environmentId },
|
||||
);
|
||||
const resolvedEnvironmentId =
|
||||
environmentId ?? existingEnvironments?.[0]?.environmentId;
|
||||
|
||||
const close = (destination: string) => {
|
||||
setIsOpen(false);
|
||||
router.push(destination);
|
||||
};
|
||||
useEffect(() => {
|
||||
const confettiShown = localStorage.getItem("hasShownConfetti");
|
||||
if (!confettiShown) {
|
||||
setShowConfetti(true);
|
||||
localStorage.setItem("hasShownConfetti", "true");
|
||||
}
|
||||
}, [showConfetti]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) close("/dashboard/settings/servers");
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
const { success, ...rest } = router.query;
|
||||
router.replace(
|
||||
{ pathname: router.pathname, query: rest },
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<Scoped>
|
||||
<nav aria-label="Steps" className="flex items-center gap-2 pt-2">
|
||||
{visibleStepIds.map((id, index) => {
|
||||
const isDone = index < visibleIndex;
|
||||
const isCurrent = stepper.current.id === id;
|
||||
return (
|
||||
<Fragment key={id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isDone}
|
||||
onClick={() => stepper.goTo(id)}
|
||||
aria-current={isCurrent ? "step" : undefined}
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full font-mono text-[11px] transition-colors",
|
||||
isCurrent
|
||||
? "border-2 border-primary text-primary font-semibold"
|
||||
: isDone
|
||||
? "bg-primary text-primary-foreground cursor-pointer"
|
||||
: "border border-border text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isDone ? <CheckIcon className="size-3.5" /> : index + 1}
|
||||
</button>
|
||||
{index < visibleStepIds.length - 1 && (
|
||||
<span
|
||||
className={cn(
|
||||
"h-px flex-1",
|
||||
isDone ? "bg-primary" : "bg-border",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</Scoped>
|
||||
|
||||
<div className="py-2">
|
||||
{stepper.switch({
|
||||
welcome: () => <WelcomeIntro onNext={goToNextVisible} />,
|
||||
project: () => (
|
||||
<ProjectStep
|
||||
plainTitle
|
||||
onNext={(project) => {
|
||||
setProjectId(project.projectId);
|
||||
setEnvironmentId(project.environmentId);
|
||||
goToNextVisible();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
server: () => <ServerStep plainTitle onNext={goToNextVisible} />,
|
||||
deploy: () => (
|
||||
<DeployStep
|
||||
plainTitle
|
||||
environmentId={resolvedEnvironmentId}
|
||||
onNext={goToNextVisible}
|
||||
/>
|
||||
),
|
||||
complete: () => (
|
||||
<CompleteIntro onFinish={() => close("/dashboard/home")} />
|
||||
),
|
||||
})}
|
||||
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
||||
{showConfetti ?? "Flaso"}
|
||||
<div className="flex justify-center items-center w-full">
|
||||
{showConfetti && (
|
||||
<ConfettiExplosion
|
||||
duration={3000}
|
||||
force={0.3}
|
||||
particleSize={12}
|
||||
particleCount={300}
|
||||
className="z-9999"
|
||||
zIndex={9999}
|
||||
width={1500}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLast && (
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => close("/dashboard/settings/servers")}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-center">
|
||||
Welcome To Dokploy Cloud 🎉
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center max-w-xl mx-auto">
|
||||
Thank you for choosing Dokploy Cloud! 🚀 We're excited to have you
|
||||
onboard. Before you dive in, you'll need to configure your remote
|
||||
server to unlock all the features we offer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-lg font-semibold">Steps</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Step {stepper.current.index + 1} of {steps.length}
|
||||
</span>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
<Scoped>
|
||||
<nav aria-label="Checkout Steps" className="group my-4">
|
||||
<ol
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-orientation="horizontal"
|
||||
>
|
||||
{stepper.all.map((step, index, array) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<li className="flex items-center gap-4 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
role="tab"
|
||||
variant={
|
||||
index <= stepper.current.index ? "secondary" : "ghost"
|
||||
}
|
||||
aria-current={
|
||||
stepper.current.id === step.id ? "step" : undefined
|
||||
}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={steps.length}
|
||||
aria-selected={stepper.current.id === step.id}
|
||||
className="flex size-10 items-center justify-center rounded-full border-2 border-border"
|
||||
onClick={() => stepper.goTo(step.id)}
|
||||
>
|
||||
{index + 1}
|
||||
</Button>
|
||||
<span className="text-sm font-medium">{step.title}</span>
|
||||
</li>
|
||||
{index < array.length - 1 && (
|
||||
<Separator
|
||||
className={`flex-1 ${
|
||||
index < stepper.current.index
|
||||
? "bg-primary"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
{stepper.switch({
|
||||
requisites: () => (
|
||||
<div className="flex flex-col gap-2 border p-4 rounded-lg">
|
||||
<span className="text-primary text-base font-bold">
|
||||
Before getting started, please follow the steps below to
|
||||
ensure the best experience:
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-primary text-sm font-medium">
|
||||
Supported Distributions:
|
||||
</p>
|
||||
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
|
||||
<li>Ubuntu 24.04 LTS</li>
|
||||
<li>Ubuntu 23.10</li>
|
||||
<li>Ubuntu 22.04 LTS</li>
|
||||
<li>Ubuntu 20.04 LTS</li>
|
||||
<li>Ubuntu 18.04 LTS</li>
|
||||
<li>Debian 12</li>
|
||||
<li>Debian 11</li>
|
||||
<li>Debian 10</li>
|
||||
<li>Fedora 40</li>
|
||||
<li>CentOS 9</li>
|
||||
<li>CentOS 8</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-primary text-sm font-medium">
|
||||
You will need to purchase or rent a Virtual Private Server
|
||||
(VPS) to proceed, we recommend to use one of these
|
||||
providers since has been heavily tested.
|
||||
</p>
|
||||
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97"
|
||||
className="text-link underline"
|
||||
>
|
||||
Hostinger - Get 20% Discount
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href=" https://app.americancloud.com/register?ref=dokploy"
|
||||
className="text-link underline"
|
||||
>
|
||||
American Cloud - Get $20 Credits
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://m.do.co/c/db24efd43f35"
|
||||
className="text-link underline"
|
||||
>
|
||||
DigitalOcean - Get $200 Credits
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://hetzner.cloud/?ref=vou4fhxJ1W2D"
|
||||
className="text-link underline"
|
||||
>
|
||||
Hetzner - Get €20 Credits
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.vultr.com/?ref=9679828"
|
||||
className="text-link underline"
|
||||
>
|
||||
Vultr
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.linode.com/es/pricing/#compute-shared"
|
||||
className="text-link underline"
|
||||
>
|
||||
Linode
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<AlertBlock className="mt-4 px-4">
|
||||
You are free to use whatever provider, but we recommend to
|
||||
use one of the above, to avoid issues.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
"create-ssh-key": () => <CreateSSHKey />,
|
||||
"connect-server": () => <CreateServer stepper={stepper} />,
|
||||
setup: () => <Setup />,
|
||||
verify: () => <Verify />,
|
||||
complete: () => {
|
||||
const features = [
|
||||
{
|
||||
title: "Scalable Deployments",
|
||||
description:
|
||||
"Deploy and scale your applications effortlessly to handle any workload.",
|
||||
icon: <Database className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Automated Backups",
|
||||
description: "Protect your data with automatic backups",
|
||||
icon: <Database className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Open Source Templates",
|
||||
description:
|
||||
"Big list of common open source templates in one-click",
|
||||
icon: <Puzzle className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Custom Domains",
|
||||
description:
|
||||
"Link your own domains to your applications for a professional presence.",
|
||||
icon: <Globe className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "CI/CD Integration",
|
||||
description:
|
||||
"Implement continuous integration and deployment workflows to streamline development.",
|
||||
icon: <GitMerge className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Database Management",
|
||||
description:
|
||||
"Efficiently manage your databases with intuitive tools.",
|
||||
icon: <Database className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Team Collaboration",
|
||||
description:
|
||||
"Collaborate with your team on shared projects with customizable permissions.",
|
||||
icon: <Users className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Multi-language Support",
|
||||
description:
|
||||
"Deploy applications in multiple programming languages to suit your needs.",
|
||||
icon: <Code2 className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "API Access",
|
||||
description:
|
||||
"Integrate and manage your applications via robust and well-documented APIs.",
|
||||
icon: <Plug className="text-primary" />,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">You're All Set!</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Did you know you can deploy any number of applications
|
||||
that your server can handle?
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Here are some of the things you can do with Dokploy
|
||||
Cloud:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="flex flex-col items-start p-4 bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="text-3xl mb-2">{feature.icon}</div>
|
||||
<h3 className="text-lg font-medium mb-1">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<span className="text-base text-primary">
|
||||
Need Help? We are here to help you.
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Join to our Discord server and we will help you.
|
||||
</span>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Button className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0] w-fit">
|
||||
<Link
|
||||
href="https://discord.gg/2tBnJ3jDJc"
|
||||
aria-label="Dokploy on GitHub"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2 text-white"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
className="h-6 w-6 fill-white"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
Join Discord
|
||||
</Link>
|
||||
</Button>
|
||||
<Button className="rounded-full w-fit">
|
||||
<Link
|
||||
href="https://github.com/Dokploy/dokploy"
|
||||
aria-label="Dokploy on GitHub"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2 "
|
||||
>
|
||||
<GithubIcon />
|
||||
Github
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="rounded-full w-fit"
|
||||
variant="outline"
|
||||
>
|
||||
<Link
|
||||
href="https://docs.dokploy.com/docs/core"
|
||||
aria-label="Dokploy Docs"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2 "
|
||||
>
|
||||
<BookIcon size={16} />
|
||||
Docs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</Scoped>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{!stepper.isLast && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
push("/dashboard/settings/servers");
|
||||
}}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 w-full justify-end">
|
||||
<Button
|
||||
onClick={stepper.prev}
|
||||
disabled={stepper.isFirst}
|
||||
variant="secondary"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (stepper.isLast) {
|
||||
setIsOpen(false);
|
||||
push("/dashboard/home");
|
||||
} else {
|
||||
stepper.next();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{stepper.isLast ? "Complete" : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ import { ChangeRole } from "./change-role";
|
||||
export const ShowUsers = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isPending, refetch } = api.user.all.useQuery();
|
||||
const { mutateAsync, isPending: isRemoving } = api.user.remove.useMutation();
|
||||
const { mutateAsync } = api.user.remove.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: hasValidLicense } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
@@ -237,7 +237,6 @@ export const ShowUsers = () => {
|
||||
title="Delete User"
|
||||
description="Are you sure you want to delete this user?"
|
||||
type="destructive"
|
||||
disabled={isRemoving}
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
@@ -270,7 +269,6 @@ export const ShowUsers = () => {
|
||||
title="Unlink User"
|
||||
description="Are you sure you want to unlink this user?"
|
||||
type="destructive"
|
||||
disabled={isRemoving}
|
||||
onClick={async () => {
|
||||
if (!isCloud) {
|
||||
const orgCount =
|
||||
|
||||
@@ -40,11 +40,9 @@ const providerLabels = {
|
||||
hashicorp: "HashiCorp Vault / OpenBao",
|
||||
infisical: "Infisical",
|
||||
aws: "AWS Secrets Manager",
|
||||
"aws-parameter-store": "AWS Parameter Store",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
phase: "Phase",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
@@ -62,11 +60,9 @@ const VaultProviderSchema = z
|
||||
"hashicorp",
|
||||
"infisical",
|
||||
"aws",
|
||||
"aws-parameter-store",
|
||||
"doppler",
|
||||
"azure",
|
||||
"scaleway",
|
||||
"phase",
|
||||
]),
|
||||
url: z.string(),
|
||||
token: z.string(),
|
||||
@@ -81,7 +77,6 @@ const VaultProviderSchema = z
|
||||
region: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
parameterPath: z.string(),
|
||||
serviceToken: z.string(),
|
||||
awsEndpoint: z.string(),
|
||||
vaultUri: z.string(),
|
||||
@@ -94,11 +89,6 @@ const VaultProviderSchema = z
|
||||
scalewayProjectId: z.string(),
|
||||
scalewaySecretKey: z.string(),
|
||||
scalewayApiUrl: z.string(),
|
||||
phaseToken: z.string(),
|
||||
phaseAppId: z.string(),
|
||||
phaseEnv: z.string(),
|
||||
phasePath: z.string(),
|
||||
phaseApiUrl: z.string(),
|
||||
assignments: z.array(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -139,8 +129,7 @@ const VaultProviderSchema = z
|
||||
});
|
||||
}
|
||||
if (
|
||||
(data.providerType === "aws" ||
|
||||
data.providerType === "aws-parameter-store") &&
|
||||
data.providerType === "aws" &&
|
||||
data.awsEndpoint &&
|
||||
!isValidUrl(data.awsEndpoint)
|
||||
) {
|
||||
@@ -150,17 +139,6 @@ const VaultProviderSchema = z
|
||||
path: ["awsEndpoint"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "aws-parameter-store" &&
|
||||
data.parameterPath &&
|
||||
!data.parameterPath.trim().startsWith("/")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Parameter discovery path must start with /",
|
||||
path: ["parameterPath"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "scaleway" &&
|
||||
data.scalewayApiUrl &&
|
||||
@@ -183,17 +161,6 @@ const VaultProviderSchema = z
|
||||
path: ["siteUrl"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "phase" &&
|
||||
data.phaseApiUrl &&
|
||||
!isValidUrl(data.phaseApiUrl)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Enter a valid URL (e.g. https://api.phase.dev)",
|
||||
path: ["phaseApiUrl"],
|
||||
});
|
||||
}
|
||||
|
||||
const required: Partial<
|
||||
Record<ProviderType, [keyof typeof data, string][]>
|
||||
@@ -215,11 +182,6 @@ const VaultProviderSchema = z
|
||||
["accessKeyId", "Access Key ID is required"],
|
||||
["secretAccessKey", "Secret Access Key is required"],
|
||||
],
|
||||
"aws-parameter-store": [
|
||||
["region", "Region is required"],
|
||||
["accessKeyId", "Access Key ID is required"],
|
||||
["secretAccessKey", "Secret Access Key is required"],
|
||||
],
|
||||
doppler: [["serviceToken", "Service Token is required"]],
|
||||
azure: [
|
||||
["vaultUri", "Vault URI is required"],
|
||||
@@ -232,11 +194,6 @@ const VaultProviderSchema = z
|
||||
["scalewayProjectId", "Project ID is required"],
|
||||
["scalewaySecretKey", "Secret Key is required"],
|
||||
],
|
||||
phase: [
|
||||
["phaseToken", "Service Account REST API token is required"],
|
||||
["phaseAppId", "App ID is required"],
|
||||
["phaseEnv", "Environment is required"],
|
||||
],
|
||||
};
|
||||
|
||||
for (const [field, message] of required[data.providerType] ?? []) {
|
||||
@@ -285,7 +242,6 @@ const defaultValues: VaultProviderForm = {
|
||||
region: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
parameterPath: "",
|
||||
serviceToken: "",
|
||||
awsEndpoint: "",
|
||||
vaultUri: "",
|
||||
@@ -298,11 +254,6 @@ const defaultValues: VaultProviderForm = {
|
||||
scalewayProjectId: "",
|
||||
scalewaySecretKey: "",
|
||||
scalewayApiUrl: "https://api.scaleway.com",
|
||||
phaseToken: "",
|
||||
phaseAppId: "",
|
||||
phaseEnv: "",
|
||||
phasePath: "/",
|
||||
phaseApiUrl: "https://api.phase.dev",
|
||||
assignments: [],
|
||||
};
|
||||
|
||||
@@ -334,15 +285,6 @@ const buildConfig = (data: VaultProviderForm) => {
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
endpoint: data.awsEndpoint || undefined,
|
||||
};
|
||||
case "aws-parameter-store":
|
||||
return {
|
||||
providerType: "aws-parameter-store" as const,
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
endpoint: data.awsEndpoint || undefined,
|
||||
parameterPath: data.parameterPath.trim() || undefined,
|
||||
};
|
||||
case "doppler":
|
||||
return {
|
||||
providerType: "doppler" as const,
|
||||
@@ -366,15 +308,6 @@ const buildConfig = (data: VaultProviderForm) => {
|
||||
secretKey: data.scalewaySecretKey,
|
||||
apiUrl: data.scalewayApiUrl || "https://api.scaleway.com",
|
||||
};
|
||||
case "phase":
|
||||
return {
|
||||
providerType: "phase" as const,
|
||||
token: data.phaseToken,
|
||||
appId: data.phaseAppId,
|
||||
env: data.phaseEnv,
|
||||
path: data.phasePath || "/",
|
||||
apiUrl: data.phaseApiUrl || "https://api.phase.dev",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -473,16 +406,12 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
environmentSlug: provider.config.environmentSlug,
|
||||
secretPath: provider.config.secretPath,
|
||||
}),
|
||||
...((provider.config.providerType === "aws" ||
|
||||
provider.config.providerType === "aws-parameter-store") && {
|
||||
...(provider.config.providerType === "aws" && {
|
||||
region: provider.config.region,
|
||||
accessKeyId: provider.config.accessKeyId,
|
||||
secretAccessKey: provider.config.secretAccessKey,
|
||||
awsEndpoint: provider.config.endpoint ?? "",
|
||||
}),
|
||||
...(provider.config.providerType === "aws-parameter-store" && {
|
||||
parameterPath: provider.config.parameterPath ?? "",
|
||||
}),
|
||||
...(provider.config.providerType === "doppler" && {
|
||||
serviceToken: provider.config.serviceToken,
|
||||
dopplerProject: provider.config.project ?? "",
|
||||
@@ -500,13 +429,6 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
scalewaySecretKey: provider.config.secretKey,
|
||||
scalewayApiUrl: provider.config.apiUrl,
|
||||
}),
|
||||
...(provider.config.providerType === "phase" && {
|
||||
phaseToken: provider.config.token,
|
||||
phaseAppId: provider.config.appId,
|
||||
phaseEnv: provider.config.env,
|
||||
phasePath: provider.config.path,
|
||||
phaseApiUrl: provider.config.apiUrl,
|
||||
}),
|
||||
});
|
||||
} else if (!vaultProviderId) {
|
||||
form.reset(defaultValues);
|
||||
@@ -791,8 +713,7 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{(providerType === "aws" ||
|
||||
providerType === "aws-parameter-store") && (
|
||||
{providerType === "aws" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -850,44 +771,12 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{providerType === "aws-parameter-store" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="parameterPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discovery path (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/production/my-app" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Limits parameter browsing and autocomplete to this
|
||||
hierarchy. IAM permissions remain the security
|
||||
boundary.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{providerType === "aws" ? (
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
|
||||
<code>{"${{vault.<name>.secret-name:field}}"}</code> for
|
||||
JSON secrets
|
||||
</FormDescription>
|
||||
) : (
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>./path/to/parameter}}"}</code>.
|
||||
SecureString parameters are decrypted automatically.
|
||||
Resolution requires <code>ssm:GetParameters</code>.
|
||||
Discovery requires <code>ssm:DescribeParameters</code>.
|
||||
Customer-managed KMS keys also require{" "}
|
||||
<code>kms:Decrypt</code>.
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
|
||||
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
|
||||
secrets
|
||||
</FormDescription>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1107,118 +996,8 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "phase" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phaseToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Service Account REST API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use the REST API token from a Service Account — not the
|
||||
CLI/SDK <code>pss_*</code> token. The Phase App must
|
||||
have Server-side Encryption (SSE) enabled.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phaseAppId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>App ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phaseEnv"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Environment</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Production" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phasePath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phaseApiUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://api.phase.dev" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Self-hosted Phase defaults to{" "}
|
||||
<code>{"${HTTP_PROTOCOL}${HOST}/service/public"}</code>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.SECRET_KEY}}"}</code>
|
||||
</FormDescription>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<FormLabel>Access</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() =>
|
||||
setAssignments(
|
||||
assignments.length === orgProjects?.length
|
||||
? []
|
||||
: (orgProjects ?? []).map((project) => ({
|
||||
projectId: project.projectId,
|
||||
environmentIds: [],
|
||||
})),
|
||||
)
|
||||
}
|
||||
>
|
||||
{assignments.length === orgProjects?.length
|
||||
? "Clear all"
|
||||
: "Access all"}
|
||||
</Button>
|
||||
</div>
|
||||
<FormLabel>Access</FormLabel>
|
||||
<FormDescription>
|
||||
This provider can only be referenced from the selected projects.
|
||||
Pick environments to narrow it further — none selected means all
|
||||
|
||||
@@ -18,11 +18,9 @@ const providerLabels: Record<string, string> = {
|
||||
hashicorp: "HashiCorp Vault",
|
||||
infisical: "Infisical",
|
||||
aws: "AWS Secrets Manager",
|
||||
"aws-parameter-store": "AWS Parameter Store",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
phase: "Phase",
|
||||
};
|
||||
|
||||
export const ShowVaultProviders = () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Server,
|
||||
Sparkles,
|
||||
Stars,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
@@ -15,7 +14,6 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -128,13 +126,13 @@ export const UpdateServer = ({
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg" showCloseButton={false}>
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<DialogTitle className="text-2xl font-semibold mr-auto">
|
||||
<DialogContent className="max-w-lg">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<DialogTitle className="text-2xl font-semibold">
|
||||
Web Server Update
|
||||
</DialogTitle>
|
||||
{dokployVersion && (
|
||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 bg-muted">
|
||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{dokployVersion}{" "}
|
||||
@@ -143,12 +141,6 @@ export const UpdateServer = ({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="shrink-0">
|
||||
<X />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
{/* Initial state */}
|
||||
|
||||
@@ -34,89 +34,7 @@ export const Route53Icon = ({ className }: Props) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PorkbunIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 129.3 114.3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill="#ED7778"
|
||||
d="M76,23.6c-18.7,0-33.8,15.1-33.8,33.8S57.3,91.3,76,91.3s33.8-15.1,33.8-33.8S94.7,23.6,76,23.6z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M67.1,43.4c-2.6-1.4-5.5-2.5-8.5-3.2c-0.6,1.3-0.9,2.6-0.9,4.1c0,2.2,0.7,4.2,1.9,5.8 C61.5,47.3,64,44.9,67.1,43.4z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M92.4,50.1c1.2-1.6,1.9-3.6,1.9-5.8c0-1.5-0.3-2.9-0.9-4.1c-3,0.6-5.9,1.7-8.5,3.2 C87.9,44.9,90.5,47.3,92.4,50.1z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M80.5,54.7c-0.6,0-1.1,0.5-1.1,1.1c0,0.2,0.1,0.4,0.2,0.6l0,0c0.4,0.6,1,1,1.7,1.2 c0.2-0.4,0.3-0.9,0.3-1.4c0-0.2,0-0.3,0-0.5C81.5,55.1,81.1,54.7,80.5,54.7z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M75.3,42.5c-9.9,0.4-17.6,8.8-17.6,18.7v10.3c0,1.8,1.5,3.3,3.3,3.3c1.8,0,3.3-1.5,3.3-3.3v-2.7h23.2v2.7 c0,1.8,1.5,3.3,3.3,3.3c1.8,0,3.3-1.5,3.3-3.3V60.7C94.2,50.4,85.7,42.1,75.3,42.5z M85.7,56.9c-0.6,1-1.5,1.7-2.6,2.1 c-0.7,1.4-2.2,2.4-3.9,2.4c-0.2,0-0.4,0-0.5,0c-0.6,0-1.1-0.5-1.1-1.1c0-0.6,0.5-1.1,1.1-1.1v0c0.5,0,1-0.2,1.4-0.4 c-0.6-0.3-1.2-0.7-1.6-1.3c-0.4-0.5-0.6-1-0.6-1.7c0-1.4,1.2-2.6,2.6-2.6c0.9,0,1.6,0.4,2.1,1.1c0.6,0.8,1,1.7,1,2.8 c0,0.1,0,0.2,0,0.4c0.5-0.2,0.9-0.6,1.2-1c0.2-0.3,0.5-0.3,0.8-0.2C85.8,56.2,85.9,56.6,85.7,56.9z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M128,44.6h4.5v2.2c0,1-0.1,1.9-0.1,1.9h0.1c0,0,2.2-4.6,8.5-4.6c6.8,0,11.1,5.4,11.1,13.3 c0,8.1-4.9,13.3-11.5,13.3c-5.6,0-7.8-4.2-7.8-4.2h-0.1c0,0,0.1,0.9,0.1,2.2v11.4H128V44.6z M139.9,66.4c4,0,7.3-3.3,7.3-9.1 c0-5.5-3-9.1-7.2-9.1c-3.8,0-7.3,2.7-7.3,9.1C132.7,61.9,135.2,66.4,139.9,66.4z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M170.9,44c7.6,0,13.7,5.6,13.7,13.2c0,7.7-6.1,13.3-13.7,13.3s-13.7-5.6-13.7-13.3 C157.2,49.6,163.3,44,170.9,44z M170.9,66.3c4.8,0,8.7-3.8,8.7-9.1c0-5.3-3.9-9-8.7-9c-4.8,0-8.7,3.8-8.7,9 C162.2,62.5,166.1,66.3,170.9,66.3z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M191.3,44.6h4.7V49c0,1.1-0.1,1.9-0.1,1.9h0.1c1.2-3.7,4.1-6.6,8-6.6c0.7,0,1.3,0.1,1.3,0.1v4.8 c0,0-0.7-0.1-1.4-0.1c-3.1,0-6,2.2-7.1,6c-0.5,1.5-0.6,3-0.6,4.6v10.4h-4.9V44.6z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M211,34.6h4.9v19.3h3.6l6.9-9.3h5.5l-8.4,11.2v0.1l9.4,14.1h-5.7L219.5,58h-3.7v11.9H211V34.6z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M238.3,34.6h4.9v11.6c0,1.3-0.1,2.2-0.1,2.2h0.1c0,0,2.2-4.3,8.1-4.3c6.8,0,11.1,5.4,11.1,13.3 c0,8.1-4.9,13.3-11.5,13.3c-5.7,0-8-4.4-8-4.4h-0.1c0,0,0.1,0.8,0.1,1.9v1.9h-4.6V34.6z M250.2,66.4c4,0,7.3-3.3,7.3-9.1 c0-5.5-3-9.1-7.2-9.1c-3.8,0-7.3,2.7-7.3,9.1C243,61.9,245.4,66.4,250.2,66.4z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M269,44.6h4.9v15.1c0,3.5,0.7,6.3,4.8,6.3c5.2,0,8.2-4.6,8.2-9.6V44.6h4.9v25.3H287v-3.4 c0-1.1,0.1-1.9,0.1-1.9H287c-1.1,2.5-4.4,5.8-9.3,5.8c-5.7,0-8.7-3-8.7-9.7V44.6z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M300.3,44.6h4.7V48c0,1-0.1,1.9-0.1,1.9h0.1c1-2.2,4-5.8,9.5-5.8c6,0,8.7,3.3,8.7,9.7v16.2h-4.9V54.8 c0-3.6-0.8-6.4-4.8-6.4c-3.9,0-7,2.6-8,6.2c-0.3,1-0.4,2.2-0.4,3.4v11.9h-4.9V44.6z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const InfomaniakIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M2.4 0A2.395 2.395 0 0 0 0 2.4v19.2C0 22.9296 1.0704 24 2.4 24h19.2c1.3296 0 2.4-1.0704 2.4-2.4V2.4C24 1.0704 22.9296 0 21.6 0H10.112v11.7119l3.648-4.128h6l-4.58 4.3506 4.868 8.1296h-5.52l-2.5938-5.0211L10.112 16.8v3.264H5.12V0Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const OvhIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M19.881 10.095l2.563-4.45C23.434 7.389 24 9.404 24 11.555c0 2.88-1.017 5.523-2.71 7.594h-6.62l2.04-3.541h-2.696l3.176-5.513h2.691zm-2.32-5.243L9.333 19.14l.003.009H2.709C1.014 17.077 0 14.435 0 11.555c0-2.152.57-4.17 1.561-5.918L5.855 13.1 10.6 4.852h6.961z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const dnsProviderIcons = {
|
||||
cloudflare: CloudflareIcon,
|
||||
route53: Route53Icon,
|
||||
porkbun: PorkbunIcon,
|
||||
infomaniak: InfomaniakIcon,
|
||||
ovh: OvhIcon,
|
||||
} as const;
|
||||
|
||||
@@ -582,35 +582,11 @@ export const ScalewayIcon = ({ className }: Props) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PhaseIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M12 2L3.5 6.5v11L12 22l8.5-4.5v-11L12 2z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 2v20M3.5 6.5L12 11l8.5-4.5M3.5 17.5L12 13l8.5 4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const vaultProviderIcons = {
|
||||
hashicorp: HashicorpVaultIcon,
|
||||
infisical: InfisicalIcon,
|
||||
aws: AwsIcon,
|
||||
"aws-parameter-store": AwsIcon,
|
||||
doppler: DopplerIcon,
|
||||
azure: AzureIcon,
|
||||
scaleway: ScalewayIcon,
|
||||
phase: PhaseIcon,
|
||||
} as const;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||
import { useHubSpotChat } from "../shared/analytics";
|
||||
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
||||
import Page from "./side";
|
||||
|
||||
interface Props {
|
||||
@@ -12,12 +8,9 @@ interface Props {
|
||||
metaName?: string;
|
||||
}
|
||||
|
||||
export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||
const router = useRouter();
|
||||
export const DashboardLayout = ({ children }: Props) => {
|
||||
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const { data: currentPlan } = api.stripe.getCurrentPlan.useQuery(undefined, {
|
||||
enabled: isCloud === true,
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -26,33 +19,16 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||
});
|
||||
|
||||
const isChatEnabled = isCloud === true && currentPlan === "startup";
|
||||
useHubSpotChat(isChatEnabled);
|
||||
|
||||
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
|
||||
const shouldRedirectToOnboarding =
|
||||
router.pathname !== "/dashboard/home" &&
|
||||
onboardingStatus?.shouldShowOnboarding === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirectToOnboarding) {
|
||||
router.replace("/dashboard/home");
|
||||
}
|
||||
}, [shouldRedirectToOnboarding, router]);
|
||||
|
||||
if (shouldRedirectToOnboarding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{metaName && (
|
||||
<Head>
|
||||
<title>
|
||||
{metaName} | {appName}
|
||||
</title>
|
||||
</Head>
|
||||
)}
|
||||
<Page>{children}</Page>
|
||||
{isChatEnabled && (
|
||||
<>
|
||||
<HubSpotWidget />
|
||||
</>
|
||||
)}
|
||||
|
||||
{haveRootAccess === true && <ImpersonationBar />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -43,7 +43,6 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { TruncateTooltip } from "@/components/shared/truncate-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -101,7 +100,6 @@ import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AppRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { TrialBanner } from "../dashboard/billing/trial-banner";
|
||||
import { AddOrganization } from "../dashboard/organization/handle-organization";
|
||||
import { DialogAction } from "../shared/dialog-action";
|
||||
import { Logo } from "../shared/logo";
|
||||
@@ -618,7 +616,7 @@ function SidebarLogo() {
|
||||
)}
|
||||
>
|
||||
{/* Organization Logo and Selector */}
|
||||
<SidebarMenuItem className={"w-full min-w-0"}>
|
||||
<SidebarMenuItem className={"w-full"}>
|
||||
<Popover
|
||||
open={organizationSelectorOpen}
|
||||
onOpenChange={setOrganizationSelectorOpen}
|
||||
@@ -634,13 +632,14 @@ function SidebarLogo() {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2",
|
||||
"flex items-center gap-2",
|
||||
isCollapsed && "justify-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-6 shrink-0 items-center justify-center rounded-sm border",
|
||||
"flex items-center justify-center rounded-sm border",
|
||||
"size-6",
|
||||
)}
|
||||
>
|
||||
<Logo
|
||||
@@ -653,27 +652,22 @@ function SidebarLogo() {
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start min-w-0 flex-1",
|
||||
"flex flex-col items-start",
|
||||
isCollapsed && "hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0 w-full">
|
||||
<TruncateTooltip
|
||||
text={
|
||||
activeOrganization?.name ?? "Select Organization"
|
||||
}
|
||||
className="text-sm font-medium"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{activeOrganization?.name ?? "Select Organization"}
|
||||
</p>
|
||||
{haveValidLicense && (
|
||||
<Badge variant="blue" className="shrink-0">
|
||||
Enterprise
|
||||
</Badge>
|
||||
<Badge variant="blue">Enterprise</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronsUpDown
|
||||
className={cn("ml-auto shrink-0", isCollapsed && "hidden")}
|
||||
className={cn("ml-auto", isCollapsed && "hidden")}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</PopoverTrigger>
|
||||
@@ -1230,7 +1224,6 @@ export default function Page({ children }: Props) {
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
{isCloud === true && <TrialBanner />}
|
||||
{!includesProjects && (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center justify-between w-full px-4">
|
||||
|
||||
@@ -173,7 +173,7 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
||||
vaultProvider: {
|
||||
label: "Secrets Providers",
|
||||
description:
|
||||
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway, Phase) and where their secrets can be referenced",
|
||||
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced",
|
||||
},
|
||||
dnsProvider: {
|
||||
label: "DNS Providers",
|
||||
@@ -328,10 +328,6 @@ const ACTION_META: Record<
|
||||
label: "Delete",
|
||||
description: "Remove servers from the organization",
|
||||
},
|
||||
terminal: {
|
||||
label: "Terminal",
|
||||
description: "Open an SSH root shell on remote servers",
|
||||
},
|
||||
},
|
||||
registry: {
|
||||
read: { label: "Read", description: "View configured Docker registries" },
|
||||
@@ -573,7 +569,7 @@ const ROLE_PRESETS: {
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
server: ["read", "create", "delete"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "delete"],
|
||||
backup: ["read", "create", "delete", "restore"],
|
||||
|
||||
@@ -83,11 +83,6 @@ const azureMapping: ClaimMapping = {
|
||||
image: "",
|
||||
};
|
||||
|
||||
// id: "sub",
|
||||
// email: "preferred_username",
|
||||
// emailVerified: "email_verified",
|
||||
// name: "name",
|
||||
|
||||
const genericMapping: ClaimMapping = {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
@@ -233,9 +228,10 @@ export function RegisterOidcDialog({
|
||||
mapping: {
|
||||
id: oidc?.mapping?.id ?? baseMapping.id,
|
||||
email: oidc?.mapping?.email ?? baseMapping.email,
|
||||
emailVerified: oidc?.mapping?.emailVerified ?? "",
|
||||
emailVerified:
|
||||
oidc?.mapping?.emailVerified ?? baseMapping.emailVerified,
|
||||
name: oidc?.mapping?.name ?? baseMapping.name,
|
||||
image: oidc?.mapping?.image ?? "",
|
||||
image: oidc?.mapping?.image ?? baseMapping.image,
|
||||
},
|
||||
});
|
||||
}, [data, open, form]);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"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,
|
||||
});
|
||||
|
||||
const faviconHref =
|
||||
config?.faviconUrl ??
|
||||
(resolvedTheme === "dark"
|
||||
? "/icon-dark.svg"
|
||||
: resolvedTheme === "light"
|
||||
? "/icon-light.svg"
|
||||
: "/icon.svg");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{config?.metaTitle && <title>{config.metaTitle}</title>}
|
||||
<link rel="icon" href={faviconHref} key="app-favicon" />
|
||||
</Head>
|
||||
|
||||
{config?.customCss && (
|
||||
<style
|
||||
id="whitelabeling-styles"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: config.customCss,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -47,8 +47,8 @@ const formSchema = z.object({
|
||||
docsUrl: safeUrlField,
|
||||
errorPageTitle: z.string(),
|
||||
errorPageDescription: z.string(),
|
||||
metaTitle: z.string(),
|
||||
footerText: z.string(),
|
||||
ogImageUrl: safeUrlField,
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
@@ -193,8 +193,8 @@ export function WhitelabelingSettings() {
|
||||
docsUrl: "",
|
||||
errorPageTitle: "",
|
||||
errorPageDescription: "",
|
||||
metaTitle: "",
|
||||
footerText: "",
|
||||
ogImageUrl: "",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
@@ -212,8 +212,8 @@ export function WhitelabelingSettings() {
|
||||
docsUrl: data.docsUrl ?? "",
|
||||
errorPageTitle: data.errorPageTitle ?? "",
|
||||
errorPageDescription: data.errorPageDescription ?? "",
|
||||
metaTitle: data.metaTitle ?? "",
|
||||
footerText: data.footerText ?? "",
|
||||
ogImageUrl: data.ogImageUrl ?? "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
@@ -242,8 +242,8 @@ export function WhitelabelingSettings() {
|
||||
docsUrl: values.docsUrl || null,
|
||||
errorPageTitle: values.errorPageTitle || null,
|
||||
errorPageDescription: values.errorPageDescription || null,
|
||||
metaTitle: values.metaTitle || null,
|
||||
footerText: values.footerText || null,
|
||||
ogImageUrl: values.ogImageUrl || null,
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -388,27 +388,6 @@ export function WhitelabelingSettings() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ogImageUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>OG Image URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/og.png"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Open Graph image used for link previews on social media
|
||||
and messaging platforms. Recommended size: 1200x630px.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -462,15 +441,32 @@ export function WhitelabelingSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Links Section */}
|
||||
{/* Metadata & Links Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Links</CardTitle>
|
||||
<CardTitle>Metadata & Links</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the footer text and sidebar links.
|
||||
Customize the page title, footer text, and sidebar links.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metaTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Page Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Dokploy" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Browser tab title. Defaults to "Dokploy" if empty.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="footerText"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Script from "next/script";
|
||||
|
||||
export const HubSpotWidget = () => {
|
||||
return (
|
||||
<Script
|
||||
id="hs-script-loader"
|
||||
type="text/javascript"
|
||||
src="//js-eu1.hs-scripts.com/147033433.js"
|
||||
strategy="lazyOnload"
|
||||
async
|
||||
defer
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { OpenPanelComponent } from "@openpanel/nextjs";
|
||||
import { useRouter } from "next/router";
|
||||
import Script from "next/script";
|
||||
import { useEffect } from "react";
|
||||
@@ -6,10 +5,9 @@ import { GTM_ID, pushToDataLayer } from "@/lib/analytics";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
/**
|
||||
* Loads Google Tag Manager, the HubSpot marketing tag, and OpenPanel
|
||||
* product analytics on the cloud version only, and translates tracking
|
||||
* query params appended by the auth/billing flows (?signup=...,
|
||||
* ?subscription=new) into dataLayer events.
|
||||
* 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();
|
||||
@@ -42,14 +40,6 @@ export const Analytics = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OpenPanelComponent
|
||||
apiUrl="https://openpanel.dokploy.com/api"
|
||||
clientId="bf5a178b-7f28-4461-bf47-d63feff15922"
|
||||
trackScreenViews={true}
|
||||
trackOutgoingLinks={true}
|
||||
trackAttributes={true}
|
||||
globalProperties={{ site: "app" }}
|
||||
/>
|
||||
<Script id="analytics-init" strategy="afterInteractive">
|
||||
{`
|
||||
window.hsConversationsSettings = { loadImmediately: false };
|
||||
@@ -92,11 +82,5 @@ export const useHubSpotChat = (enabled: boolean) => {
|
||||
loadWidget,
|
||||
];
|
||||
}
|
||||
return () => {
|
||||
window.hsConversationsOnReady = window.hsConversationsOnReady?.filter(
|
||||
(callback) => callback !== loadWidget,
|
||||
);
|
||||
window.HubSpotConversations?.widget.remove();
|
||||
};
|
||||
}, [enabled]);
|
||||
};
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const TruncateTooltip = ({ text, className, ...props }: Props) => {
|
||||
const textRef = useRef<HTMLParagraphElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = textRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const checkTruncation = () => {
|
||||
const truncated = element.scrollWidth > element.clientWidth;
|
||||
setIsTruncated(truncated);
|
||||
if (!truncated) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkTruncation();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
checkTruncation();
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
const content = (
|
||||
<p ref={textRef} className={cn("truncate", className)} {...props}>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip
|
||||
delayDuration={0}
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Only allow opening if it's actually truncated
|
||||
if (isTruncated) {
|
||||
setIsOpen(open);
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md max-w-[280px] break-words animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
>
|
||||
<p>{text}</p>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,307 +0,0 @@
|
||||
import { DownloadIcon, Loader2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
currentEnv: string;
|
||||
onImport: (nextEnv: string) => void;
|
||||
}
|
||||
|
||||
const normalizeSecretName = (secretName: string) => {
|
||||
const normalized = secretName
|
||||
.replace(/[^a-zA-Z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toUpperCase();
|
||||
return /^[0-9]/.test(normalized) ? `_${normalized}` : normalized || "SECRET";
|
||||
};
|
||||
|
||||
const parseEnvKeys = (env: string) => {
|
||||
const keys = new Set<string>();
|
||||
for (const line of env.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
|
||||
keys.add(trimmed.slice(0, trimmed.indexOf("=")).trim());
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
const setEnvValue = (env: string, key: string, ref: string) => {
|
||||
const lines = env.split("\n");
|
||||
const line = `${key}=${ref}`;
|
||||
const index = lines.findIndex((l) => {
|
||||
const trimmed = l.trim();
|
||||
return (
|
||||
trimmed &&
|
||||
!trimmed.startsWith("#") &&
|
||||
trimmed.includes("=") &&
|
||||
trimmed.slice(0, trimmed.indexOf("=")).trim() === key
|
||||
);
|
||||
});
|
||||
if (index >= 0) {
|
||||
lines[index] = line;
|
||||
return lines.join("\n");
|
||||
}
|
||||
const withTrailingNewline = env.length > 0 && !env.endsWith("\n") ? "\n" : "";
|
||||
return `${env}${withTrailingNewline}${line}\n`;
|
||||
};
|
||||
|
||||
export const VaultImportDialog = ({
|
||||
projectId,
|
||||
environmentId,
|
||||
currentEnv,
|
||||
onImport,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [vaultProviderId, setVaultProviderId] = useState<string | undefined>();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [keyOverrides, setKeyOverrides] = useState<Record<string, string>>({});
|
||||
|
||||
const { data: allProviders } = api.vaultProvider.all.useQuery();
|
||||
const providers = useMemo(
|
||||
() =>
|
||||
(allProviders ?? []).filter((provider) =>
|
||||
provider.assignments?.some(
|
||||
(assignment) =>
|
||||
assignment.projectId === projectId &&
|
||||
(assignment.environmentIds.length === 0 ||
|
||||
!environmentId ||
|
||||
assignment.environmentIds.includes(environmentId)),
|
||||
),
|
||||
),
|
||||
[allProviders, projectId, environmentId],
|
||||
);
|
||||
|
||||
const activeProviderId = vaultProviderId ?? providers[0]?.vaultProviderId;
|
||||
const activeProvider = providers.find(
|
||||
(p) => p.vaultProviderId === activeProviderId,
|
||||
);
|
||||
|
||||
const { data: secretNames, isLoading } =
|
||||
api.vaultProvider.listSecretNames.useQuery(
|
||||
{
|
||||
vaultProviderId: activeProviderId!,
|
||||
projectId: projectId!,
|
||||
environmentId,
|
||||
},
|
||||
{ enabled: isOpen && !!activeProviderId && !!projectId },
|
||||
);
|
||||
|
||||
const existingKeys = useMemo(() => parseEnvKeys(currentEnv), [currentEnv]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
(secretNames ?? []).map((secretName) => {
|
||||
const defaultKey = normalizeSecretName(secretName);
|
||||
const key = keyOverrides[secretName] ?? defaultKey;
|
||||
return {
|
||||
secretName,
|
||||
key,
|
||||
conflict: existingKeys.has(key),
|
||||
};
|
||||
}),
|
||||
[secretNames, keyOverrides, existingKeys],
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
setSelected(new Set());
|
||||
setKeyOverrides({});
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setVaultProviderId(undefined);
|
||||
resetState();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRow = (secretName: string, checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(secretName);
|
||||
else next.delete(secretName);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
const selectableNonConflicting = rows.filter((r) => !r.conflict);
|
||||
const allSelected =
|
||||
selectableNonConflicting.length > 0 &&
|
||||
selectableNonConflicting.every((r) => selected.has(r.secretName));
|
||||
setSelected(
|
||||
allSelected
|
||||
? new Set()
|
||||
: new Set(selectableNonConflicting.map((r) => r.secretName)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
if (!activeProvider) return;
|
||||
let next = currentEnv;
|
||||
for (const row of rows) {
|
||||
if (!selected.has(row.secretName)) continue;
|
||||
const ref = `\${{vault.${activeProvider.name}.${row.secretName}}}`;
|
||||
next = setEnvValue(next, row.key, ref);
|
||||
}
|
||||
onImport(next);
|
||||
setIsOpen(false);
|
||||
setVaultProviderId(undefined);
|
||||
resetState();
|
||||
};
|
||||
|
||||
if (!projectId || providers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<DownloadIcon className="mr-2 size-4" />
|
||||
Import from Vault
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import secrets from vault</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select the secrets to import as{" "}
|
||||
<code>{"${{vault.<provider>.<secret>}}"}</code> references. Secrets
|
||||
already defined below are skipped unless you check them explicitly.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Vault provider</Label>
|
||||
<Select
|
||||
value={activeProviderId}
|
||||
onValueChange={(value) => {
|
||||
setVaultProviderId(value);
|
||||
resetState();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem
|
||||
key={provider.vaultProviderId}
|
||||
value={provider.vaultProviderId}
|
||||
>
|
||||
{provider.name} ({provider.providerType})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading secrets...
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
No secrets found for this provider.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
rows.some((r) => !r.conflict) &&
|
||||
rows
|
||||
.filter((r) => !r.conflict)
|
||||
.every((r) => selected.has(r.secretName))
|
||||
}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
Select all
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{selected.size} selected
|
||||
</span>
|
||||
</div>
|
||||
<ScrollArea className="h-80 rounded-md border">
|
||||
<div className="flex flex-col divide-y">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.secretName}
|
||||
className="flex items-center gap-3 p-3"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.has(row.secretName)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleRow(row.secretName, checked === true)
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate font-mono text-muted-foreground text-xs">
|
||||
{row.secretName}
|
||||
</span>
|
||||
<Input
|
||||
value={row.key}
|
||||
onChange={(e) =>
|
||||
setKeyOverrides((prev) => ({
|
||||
...prev,
|
||||
[row.secretName]: e.target.value
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9_]/g, "_"),
|
||||
}))
|
||||
}
|
||||
className="h-8 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
{row.conflict && (
|
||||
<Badge variant="secondary">already exists</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={selected.size === 0}
|
||||
>
|
||||
Import {selected.size > 0 ? selected.size : ""} secret
|
||||
{selected.size === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -10,24 +10,13 @@ interface DropzoneProps
|
||||
"value" | "onChange"
|
||||
> {
|
||||
classNameWrapper?: string;
|
||||
classNameContent?: string;
|
||||
className?: string;
|
||||
dropMessage: string;
|
||||
onChange: (acceptedFiles: FileList | null) => void;
|
||||
}
|
||||
|
||||
export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
classNameWrapper,
|
||||
classNameContent,
|
||||
dropMessage,
|
||||
onChange,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
({ className, classNameWrapper, dropMessage, onChange, ...props }, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// Function to handle drag over event
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -62,10 +51,7 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
||||
)}
|
||||
>
|
||||
<CardContent
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96",
|
||||
classNameContent,
|
||||
)}
|
||||
className="flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleButtonClick}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import { type CSSProperties, type ReactNode, useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
@@ -24,8 +23,6 @@ interface Props {
|
||||
description: ReactNode;
|
||||
placeholder: string;
|
||||
completionSource?: CompletionSource;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
}
|
||||
|
||||
export const Secrets = (props: Props) => {
|
||||
@@ -40,27 +37,17 @@ export const Secrets = (props: Props) => {
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<VaultImportDialog
|
||||
projectId={props.projectId}
|
||||
environmentId={props.environmentId}
|
||||
currentEnv={form.watch(props.name) ?? ""}
|
||||
onImport={(next) =>
|
||||
form.setValue(props.name, next, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
aria-label="Toggle bold"
|
||||
pressed={isVisible}
|
||||
onPressedChange={setIsVisible}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Toggle>
|
||||
</div>
|
||||
<Toggle
|
||||
aria-label="Toggle bold"
|
||||
pressed={isVisible}
|
||||
onPressedChange={setIsVisible}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Toggle>
|
||||
</CardHeader>
|
||||
<CardContent className="w-full space-y-4 p-0">
|
||||
<FormField
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "network" ADD COLUMN "dockerId" text;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Grant the new "server.terminal" permission to existing custom roles that already have
|
||||
-- "server.read", which is the permission that surfaces the terminal in the UI today.
|
||||
-- Roles without a "server" entry are deliberately left alone: they never saw the terminal in the
|
||||
-- UI, so from now on they are denied at the websocket too.
|
||||
UPDATE "organization_role" AS r
|
||||
SET "permission" = jsonb_set(
|
||||
r."permission"::jsonb,
|
||||
'{server}',
|
||||
(r."permission"::jsonb->'server') || '["terminal"]'::jsonb
|
||||
)::text
|
||||
WHERE jsonb_typeof(r."permission"::jsonb->'server') = 'array'
|
||||
AND r."permission"::jsonb->'server' @> '["read"]'::jsonb
|
||||
AND NOT r."permission"::jsonb->'server' @> '["terminal"]'::jsonb;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'phase';
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'porkbun';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user