Compare commits

..

1 Commits

Author SHA1 Message Date
autofix-ci[bot] 3055c3351b [autofix.ci] apply automated fixes 2026-08-25 07:03:39 +00:00
176 changed files with 2289 additions and 53819 deletions
+2 -5
View File
@@ -1,16 +1,13 @@
{
"worktree": {
"baseRef": "fresh"
"baseRef": "fresh",
"symlinkDirectories": ["node_modules"]
},
"hooks": {
"PostToolUse": [
{
"matcher": "EnterWorktree",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
+1 -17
View File
@@ -4,23 +4,7 @@ description: Implement a GitHub issue with reproduction and verification
allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
---
The issue number is passed as $1.
## Instance
No instance is running yet — start your own, isolated to this worktree:
1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
until it answers (usually ~10-15s).
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
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
to relaunch with `DOKPLOY_BASE_URL` exported first.
The issue number is passed as $1. An isolated instance is running at $DOKPLOY_BASE_URL.
## Tools
+13
View File
@@ -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"]
}
}
}
-6
View File
@@ -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.
+1 -9
View File
@@ -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 run reset-password
```
To reset the password of a specific user instead, pass their email as an argument
```bash
pnpm 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
-10
View File
@@ -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`.
-1
View File
@@ -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,9 +46,6 @@ 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}"',
};
describe("getCreateEnvFileCommand", () => {
@@ -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
-211
View File
@@ -1,211 +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 instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] }))
.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("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.",
);
});
});
+4 -101
View File
@@ -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: [] })
-127
View File
@@ -20,7 +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 { phaseClient } from "@dokploy/server/utils/vault/phase";
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
const mockFetch = vi.fn();
@@ -617,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);
}
});
});
@@ -1,252 +0,0 @@
import { execSync } from "node:child_process";
import { docker } from "@dokploy/server/constants";
import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 120000;
const SERVICE_NAME = "dokploy-monitoring";
const TEST_IMAGE = "busybox:latest";
// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to
// the local daemon, so the legacy-container cleanup is exercised for real.
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn().mockResolvedValue({
serverId: "test-server",
serverType: "deploy",
sshKeyId: null, // -> getRemoteDocker returns the local docker instance
metricsConfig: {
server: {
type: "Remote",
port: 4500,
token: "test-token",
urlCallback: "http://localhost/callback",
cronJob: "0 0 * * *",
retentionDays: 2,
refreshRate: 60,
thresholds: { cpu: 0, memory: 0 },
},
containers: { refreshRate: 60, services: { include: [], exclude: [] } },
},
}),
}));
vi.mock("@dokploy/server/services/settings", () => ({
getDokployImageTag: vi.fn(() => "latest"),
}));
vi.mock("@dokploy/server/utils/docker/utils", () => ({
pullImage: vi.fn().mockResolvedValue(undefined),
pullRemoteImage: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
}));
const containerExists = async (name: string) => {
try {
await docker.getContainer(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
const serviceExists = async (name: string) => {
try {
await docker.getService(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
// 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
.flatMap((c) => c.Names.map((n) => n.replace(/^\//, "")))
.filter((n) => n.startsWith(`${SERVICE_NAME}.`));
};
const cleanup = async () => {
try {
await docker.getService(SERVICE_NAME).remove();
} catch {}
try {
await docker.getContainer(SERVICE_NAME).remove({ force: true });
} catch {}
for (const name of await swarmTaskNames()) {
try {
await docker.getContainer(name).remove({ force: true });
} catch {}
}
};
// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop.
const createLegacyZombie = async () => {
const container = await docker.createContainer({
name: SERVICE_NAME,
Image: TEST_IMAGE,
Cmd: [
"sh",
"-c",
"echo 'Error starting metrics cleanup system: empty spec string'; exit 1",
],
HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" },
});
await container.start();
};
const pullTestImage = async () => {
try {
await docker.getImage(TEST_IMAGE).inspect();
return;
} catch {}
await new Promise<void>((resolve, reject) =>
docker.pull(TEST_IMAGE, (err: any, stream: any) =>
err
? reject(err)
: docker.modem.followProgress(stream, (e: any) =>
e ? reject(e) : resolve(),
),
),
);
};
// The code under test hardcodes the production name, so this suite cannot
// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host.
const hasRealMonitoring = () => {
const query = (cmd: string) => {
try {
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return "";
}
};
return (
query(
`docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`,
) !== "" ||
query(
`docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`,
) !== ""
);
};
describe.skipIf(hasRealMonitoring())(
"setupMonitoring - legacy container cleanup (real docker)",
() => {
beforeEach(async () => {
await pullTestImage();
await cleanup();
}, REAL_TEST_TIMEOUT);
afterAll(async () => {
await cleanup();
}, REAL_TEST_TIMEOUT);
it(
"removes the legacy standalone container left behind by the swarm migration",
async () => {
await createLegacyZombie();
expect(await containerExists(SERVICE_NAME)).toBe(true);
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await serviceExists(SERVICE_NAME)).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"removes the legacy container without touching swarm tasks, which are named dokploy-monitoring.<slot>.<id>",
async () => {
const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`;
const task = await docker.createContainer({
name: taskName,
Image: TEST_IMAGE,
Cmd: ["sleep", "3600"],
});
await task.start();
await createLegacyZombie();
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await containerExists(taskName)).toBe(true);
const inspect = await docker.getContainer(taskName).inspect();
expect(inspect.State.Running).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"is idempotent when no legacy container exists",
async () => {
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);
},
REAL_TEST_TIMEOUT,
);
it(
"deploys the service even when removing the legacy container fails",
async () => {
const failingDocker = {
getContainer: () => ({
remove: async () => {
const error: any = new Error("device or resource busy");
error.statusCode = 500;
throw error;
},
}),
getService: docker.getService.bind(docker),
createService: docker.createService.bind(docker),
};
const remoteDocker = await import(
"@dokploy/server/utils/servers/remote-docker"
);
const spy = vi
.spyOn(remoteDocker, "getRemoteDocker")
.mockResolvedValue(failingDocker as any);
try {
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
expect(await serviceExists(SERVICE_NAME)).toBe(true);
} finally {
spy.mockRestore();
}
},
REAL_TEST_TIMEOUT,
);
},
);
@@ -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");
});
});
+1 -26
View File
@@ -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();
});
});
@@ -94,32 +94,4 @@ describe("readValidDirectory (path traversal)", () => {
),
).toBe(true);
});
it("returns true for SvelteKit routes with + prefix and @ symbols", () => {
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+page.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+layout.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+server.ts`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+error.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`,
),
).toBe(true);
});
});
@@ -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>
)}
@@ -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
@@ -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,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,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,295 +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,213 +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!,
);
interface Props {
onNext: () => void;
}
export const PlanStep = ({ onNext }: Props) => {
const [loadingTier, setLoadingTier] = useState<
"hobby" | "startup" | "trial" | 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: "hobby" | "startup") => {
if (!data) return;
const productId =
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
if (!productId) return;
setLoadingTier(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");
setLoadingTier(null);
}
};
const handleTrial = async () => {
setLoadingTier("trial");
try {
await startFreeTrial();
await utils.project.onboardingStatus.invalidate();
toast.success("Your 14-day trial has started");
onNext();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error starting trial",
);
} finally {
setLoadingTier(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">
No credit card for the trial — add one only if you decide to stay.
</p>
</div>
<div className="flex flex-col rounded-2xl bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-6 p-7">
<div>
<span className="inline-flex items-center rounded-full bg-white text-zinc-950 dark:bg-zinc-950 dark:text-white font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1">
Recommended
</span>
<p className={`${displayFont.className} text-2xl mt-3`}>
14-day free trial
</p>
<p className="text-sm text-zinc-400 dark:text-zinc-600 mt-1 max-w-xs">
No card required — cancel anytime.
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
"1 server included",
"Unlimited apps & databases",
"Community support",
].map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-zinc-300 dark:text-zinc-700"
>
<CheckIcon className="size-3.5 text-zinc-500 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<Button
size="lg"
className="bg-white text-zinc-950 hover:bg-zinc-200 dark:bg-zinc-950 dark:text-white dark:hover:bg-zinc-800 w-fit shrink-0 px-6"
isLoading={loadingTier === "trial"}
disabled={loadingTier !== null}
onClick={handleTrial}
>
Start trial
<ArrowRightIcon className="size-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
<div className="flex flex-col justify-between gap-6 bg-background p-7">
<div>
<p className="font-medium">Hobby</p>
<p className="text-sm text-muted-foreground mt-1">
For individual developers
</p>
<p className="text-3xl font-semibold mt-4 tabular-nums">
${calculatePriceHobby(1, false).toFixed(2)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/mo
</span>
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
"1 server included",
"Unlimited apps & databases",
"2 environments",
"Community support",
].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>
<Button
variant="outline"
isLoading={loadingTier === "hobby"}
disabled={loadingTier !== null || !data?.hobbyProductId}
onClick={() => handleCheckout("hobby")}
>
Subscribe
</Button>
</div>
<div className="flex flex-col justify-between gap-6 bg-background p-7">
<div>
<p className="font-medium">Startup</p>
<p className="text-sm text-muted-foreground mt-1">
For small to mid-size teams
</p>
<p className="text-3xl font-semibold mt-4 tabular-nums">
$
{calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed(
2,
)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/mo
</span>
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
`${STARTUP_SERVERS_INCLUDED} servers included`,
"Unlimited users & environments",
"Basic RBAC + 2FA",
"Email & chat support",
].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>
<Button
variant="outline"
isLoading={loadingTier === "startup"}
disabled={loadingTier !== null || !data?.startupProductId}
onClick={() => handleCheckout("startup")}
>
Subscribe
</Button>
</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,346 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { CheckCircle2, CopyIcon, 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>
{!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 usually takes a minute after the server boots — this checks
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">
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>
);
};
@@ -48,7 +48,6 @@ export function AddOrganization({ organizationId }: Props) {
},
{
enabled: !!organizationId,
refetchOnWindowFocus: false,
},
);
const { mutateAsync, isPending } = organizationId
@@ -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,26 +103,8 @@ export const ShowBilling = () => {
api.stripe.upgradeSubscription.useMutation();
const { mutateAsync: updateInvoiceNotifications } =
api.stripe.updateInvoiceNotifications.useMutation();
const { mutateAsync: startFreeTrial, isPending: isStartingTrial } =
api.stripe.startFreeTrial.useMutation();
const utils = api.useUtils();
const handleStartTrial = async () => {
try {
await startFreeTrial();
await Promise.all([
utils.stripe.getBillingStatus.invalidate(),
utils.stripe.getProducts.invalidate(),
utils.user.get.invalidate(),
]);
toast.success("Your 14-day trial has started");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error starting trial",
);
}
};
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
const [startupServerQuantity, setStartupServerQuantity] = useState(
STARTUP_SERVERS_INCLUDED,
@@ -269,94 +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()}`
: ""
}`
: "You're subscribed and billed automatically."}
</span>
</div>
</div>
{billingStatus.isOnTrial &&
admin?.user.stripeCustomerId && (
<Button
size="sm"
variant="outline"
onClick={async () => {
const session = await createCustomerPortalSession();
window.open(session.url);
}}
>
Add payment method
</Button>
)}
</div>
)}
{!isEnterpriseCloud &&
billingStatus &&
!billingStatus.plan &&
!billingStatus.isOnTrial &&
!billingStatus.hasUsedTrial && (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-primary shrink-0" />
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
14-day free trial
</span>
<span className="text-sm text-muted-foreground">
No credit card required — cancel anytime.
</span>
<ul className="flex flex-col gap-1 mt-1">
{[
"1 server included",
"Unlimited apps & databases",
"Community support",
].map((feature) => (
<li
key={feature}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckIcon className="size-3.5 shrink-0" />
{feature}
</li>
))}
</ul>
</div>
</div>
<Button
size="sm"
isLoading={isStartingTrial}
onClick={handleStartTrial}
>
Start trial
</Button>
</div>
)}
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
<div className="space-y-2 flex flex-col">
<h3 className="text-lg font-medium">Servers Plan</h3>
@@ -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,17 +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",
} as const;
type ProviderType = keyof typeof providerLabels;
@@ -55,12 +49,10 @@ const DnsProviderSchema = z.object({
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum(["cloudflare", "route53", "porkbun"]),
providerType: z.enum(["cloudflare", "route53"]),
apiToken: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
apiKey: z.string(),
secretApiKey: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
@@ -71,8 +63,6 @@ const defaultValues: DnsProviderForm = {
apiToken: "",
accessKeyId: "",
secretAccessKey: "",
apiKey: "",
secretApiKey: "",
};
const buildConfig = (data: DnsProviderForm) => {
@@ -88,12 +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,
};
}
};
@@ -151,10 +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,
}),
});
} else if (!dnsProviderId) {
form.reset(defaultValues);
@@ -200,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>
@@ -347,42 +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>
)}
/>
</>
)}
<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,13 +11,9 @@ 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",
@@ -26,142 +21,121 @@ const providerLabels: Record<string, string> = {
};
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 (
@@ -245,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>
@@ -321,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>
);
};
@@ -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 =
@@ -43,7 +43,6 @@ const providerLabels = {
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
phase: "Phase",
} as const;
type ProviderType = keyof typeof providerLabels;
@@ -64,7 +63,6 @@ const VaultProviderSchema = z
"doppler",
"azure",
"scaleway",
"phase",
]),
url: z.string(),
token: z.string(),
@@ -91,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(),
@@ -168,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][]>
@@ -212,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] ?? []) {
@@ -277,11 +254,6 @@ const defaultValues: VaultProviderForm = {
scalewayProjectId: "",
scalewaySecretKey: "",
scalewayApiUrl: "https://api.scaleway.com",
phaseToken: "",
phaseAppId: "",
phaseEnv: "",
phasePath: "/",
phaseApiUrl: "https://api.phase.dev",
assignments: [],
};
@@ -336,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",
};
}
};
@@ -466,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);
@@ -1040,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
@@ -21,7 +21,6 @@ const providerLabels: Record<string, string> = {
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
phase: "Phase",
};
export const ShowVaultProviders = () => {
@@ -45,8 +45,8 @@ export const UpdateServer = ({
const [isUpdateAvailable, setIsUpdateAvailable] = useState(
!!updateData?.updateAvailable,
);
const utils = api.useUtils();
const [isPending, setIsPending] = useState(false);
const { mutateAsync: getUpdateData, isPending } =
api.settings.getUpdateData.useMutation();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
const [latestVersion, setLatestVersion] = useState(
@@ -55,9 +55,8 @@ export const UpdateServer = ({
const [isOpenInternal, setIsOpenInternal] = useState(false);
const handleCheckUpdates = async () => {
setIsPending(true);
try {
const updateData = await utils.settings.getUpdateData.fetch();
const updateData = await getUpdateData();
const versionToUpdate = updateData.latestVersion || "";
setHasCheckedUpdate(true);
setIsUpdateAvailable(updateData.updateAvailable);
@@ -77,8 +76,6 @@ export const UpdateServer = ({
toast.error(
"An error occurred while checking for updates, please try again.",
);
} finally {
setIsPending(false);
}
};
@@ -34,65 +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 dnsProviderIcons = {
cloudflare: CloudflareIcon,
route53: Route53Icon,
porkbun: PorkbunIcon,
} as const;
@@ -582,28 +582,6 @@ 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,
@@ -611,5 +589,4 @@ export const vaultProviderIcons = {
doppler: DopplerIcon,
azure: AzureIcon,
scaleway: ScalewayIcon,
phase: PhaseIcon,
} as const;
@@ -1,8 +1,4 @@
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 { HubSpotWidget } from "../shared/HubSpotWidget";
import Page from "./side";
@@ -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,
@@ -27,30 +20,8 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
const isChatEnabled = isCloud === true && currentPlan === "startup";
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 && (
<>
@@ -19,7 +19,8 @@ export const UpdateServerButton = () => {
updateAvailable: false,
});
const { data: isCloud } = api.settings.isCloud.useQuery();
const utils = api.useUtils();
const { mutateAsync: getUpdateData } =
api.settings.getUpdateData.useMutation();
const [isOpen, setIsOpen] = useState(false);
const checkUpdatesIntervalRef = useRef<null | NodeJS.Timeout>(null);
@@ -47,7 +48,7 @@ export const UpdateServerButton = () => {
return;
}
const fetchedUpdateData = await utils.settings.getUpdateData.fetch();
const fetchedUpdateData = await getUpdateData();
if (fetchedUpdateData?.updateAvailable) {
// Stop interval when update is available
@@ -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"],
+3 -13
View File
@@ -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 };
@@ -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>
);
};
+11 -24
View File
@@ -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';
@@ -1,7 +0,0 @@
ALTER TABLE "user" ADD COLUMN "onboardingCompletedAt" timestamp;--> statement-breakpoint
-- Backfill only: existing users shouldn't see the onboarding wizard just
-- because this column is new. Not a column-level DEFAULT — that would also
-- apply to rows inserted after this migration, and newly created users
-- (self-hosted setup, cloud signups) need onboardingCompletedAt to stay NULL
-- so the wizard still shows for them.
UPDATE "user" SET "onboardingCompletedAt" = now() WHERE "onboardingCompletedAt" IS NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-35
View File
@@ -1303,41 +1303,6 @@
"when": 1786526512677,
"tag": "0185_needy_kingpin",
"breakpoints": true
},
{
"idx": 186,
"version": "7",
"when": 1787813846067,
"tag": "0186_tearful_dragon_man",
"breakpoints": true
},
{
"idx": 187,
"version": "7",
"when": 1787937580323,
"tag": "0187_grant_terminal_permission_to_read_roles",
"breakpoints": true
},
{
"idx": 188,
"version": "7",
"when": 1788249519837,
"tag": "0188_volatile_piledriver",
"breakpoints": true
},
{
"idx": 189,
"version": "7",
"when": 1788251861403,
"tag": "0189_wooden_nextwave",
"breakpoints": true
},
{
"idx": 190,
"version": "7",
"when": 1788300390648,
"tag": "0190_nappy_anita_blake",
"breakpoints": true
}
]
}
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.30.5",
"version": "v0.30.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -37,8 +37,7 @@
"docker:push:canary": "./docker/push.sh canary",
"version": "echo $(node -p \"require('./package.json').version\")",
"test": "vitest --config __test__/vitest.config.ts",
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts",
"reset-onboarding": "tsx -r dotenv/config scripts/reset-onboarding.ts"
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.44",
@@ -68,7 +67,6 @@
"@hookform/resolvers": "^5.2.2",
"@octokit/auth-app": "^6.1.3",
"@octokit/webhooks": "^13.9.0",
"@openpanel/nextjs": "^1.5.1",
"@react-email/components": "^1.0.12",
"@stepperize/react": "4.0.1",
"@stripe/stripe-js": "4.8.0",
+1 -32
View File
@@ -1,8 +1,4 @@
import {
OPENAPI_MAX_JSON_BODY_SIZE,
OPENAPI_MAX_UPLOAD_SIZE,
validateRequest,
} from "@dokploy/server";
import { validateRequest } from "@dokploy/server";
import { createOpenApiNextHandler } from "@dokploy/trpc-openapi";
import type { NextApiRequest, NextApiResponse } from "next";
import { appRouter } from "@/server/api/root";
@@ -16,31 +12,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return;
}
// getMultipartBody doesn't accept maxBodySize, so we cap it here instead.
const contentLength = Number(req.headers["content-length"]);
const isMultipart = req.headers["content-type"]?.startsWith(
"multipart/form-data",
);
if (isMultipart && !Number.isFinite(contentLength)) {
res.status(411).json({ message: "Content-Length required" });
return;
}
const limit = isMultipart
? OPENAPI_MAX_UPLOAD_SIZE
: OPENAPI_MAX_JSON_BODY_SIZE;
if (Number.isFinite(contentLength) && contentLength > limit) {
res.status(413).json({ message: "Payload too large" });
return;
}
// @ts-ignore
return createOpenApiNextHandler({
router: appRouter,
createContext: createTRPCContext,
maxBodySize: OPENAPI_MAX_JSON_BODY_SIZE,
onError:
process.env.NODE_ENV === "development"
? ({ path, error }: { path: string | undefined; error: Error }) => {
@@ -53,9 +28,3 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
};
export default handler;
export const config = {
api: {
bodyParser: false,
},
};
@@ -2,7 +2,6 @@ import {
type Bitbucket,
getBitbucketHeaders,
IS_CLOUD,
IS_DEMO,
shouldDeploy,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -39,10 +38,6 @@ export default async function handler(
) {
const { refreshToken } = req.query;
try {
if (IS_DEMO) {
res.status(403).json({ message: "This is a read-only demo instance." });
return;
}
if (req.headers["x-github-event"] === "ping") {
res.status(200).json({ message: "Ping received, webhook is active" });
return;
@@ -1,4 +1,4 @@
import { IS_CLOUD, IS_DEMO, shouldDeploy } from "@dokploy/server";
import { IS_CLOUD, shouldDeploy } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -21,10 +21,6 @@ export default async function handler(
) {
const { refreshToken } = req.query;
try {
if (IS_DEMO) {
res.status(403).json({ message: "This is a read-only demo instance." });
return;
}
if (req.headers["x-github-event"] === "ping") {
res.status(200).json({ message: "Ping received, webhook is active" });
return;
-6
View File
@@ -6,7 +6,6 @@ import {
findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId,
IS_CLOUD,
IS_DEMO,
removePreviewDeployment,
shouldDeploy,
} from "@dokploy/server";
@@ -31,11 +30,6 @@ export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (IS_DEMO) {
res.status(403).json({ message: "This is a read-only demo instance." });
return;
}
const signature = req.headers["x-hub-signature-256"];
if (!signature) {
res.status(401).json({ message: "Missing signature header" });
-36
View File
@@ -2,47 +2,12 @@ import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import { useEffect, useState } from "react";
import superjson from "superjson";
import { ShowHome } from "@/components/dashboard/home/show-home";
import {
clearOnboardingActive,
isOnboardingActive,
markOnboardingActive,
} from "@/components/dashboard/onboarding/onboarding-lock";
import { OnboardingWizard } from "@/components/dashboard/onboarding/onboarding-wizard";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const Home = () => {
const { data } = api.project.onboardingStatus.useQuery();
const utils = api.useUtils();
const { mutateAsync: completeOnboarding } =
api.project.completeOnboarding.useMutation();
const [showWizard, setShowWizard] = useState<boolean | null>(null);
useEffect(() => {
if (showWizard === null && data) {
const show = isOnboardingActive() || data.shouldShowOnboarding;
setShowWizard(show);
if (show) markOnboardingActive();
}
}, [data, showWizard]);
if (showWizard) {
return (
<OnboardingWizard
onClose={async () => {
await completeOnboarding();
await utils.project.onboardingStatus.invalidate();
clearOnboardingActive();
setShowWizard(false);
}}
/>
);
}
return <ShowHome />;
};
@@ -79,7 +44,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
await helpers.settings.isCloud.prefetch();
await helpers.user.get.prefetch();
await helpers.project.onboardingStatus.prefetch();
return {
props: {
+18 -1
View File
@@ -1,14 +1,31 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import dynamic from "next/dynamic";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowProjects } from "@/components/dashboard/projects/show";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const ShowWelcomeDokploy = dynamic(
() =>
import("@/components/dashboard/settings/billing/show-welcome-dokploy").then(
(mod) => mod.ShowWelcomeDokploy,
),
{ ssr: false },
);
const Dashboard = () => {
return <ShowProjects />;
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
<>
{isCloud && <ShowWelcomeDokploy />}
<ShowProjects />
</>
);
};
export default Dashboard;
@@ -8,7 +8,11 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return <ShowDnsProviders />;
return (
<div className="flex flex-col gap-4 w-full">
<ShowDnsProviders />
</div>
);
};
export default Page;
@@ -1,57 +0,0 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowDnsZones } from "@/components/dashboard/settings/dns/show-dns-zones";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
interface Props {
dnsProviderId: string;
}
const Page = ({ dnsProviderId }: Props) => {
return <ShowDnsZones dnsProviderId={dnsProviderId} />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ dnsProviderId: string }>,
) {
const { req, res, params } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member" || !params?.dnsProviderId) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
dnsProviderId: params.dnsProviderId,
},
};
}
@@ -1,64 +0,0 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowDnsRecords } from "@/components/dashboard/settings/dns/show-dns-records";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
interface Props {
dnsProviderId: string;
zoneId: string;
}
const Page = ({ dnsProviderId, zoneId }: Props) => {
return <ShowDnsRecords dnsProviderId={dnsProviderId} zoneId={zoneId} />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ dnsProviderId: string; zoneId: string }>,
) {
const { req, res, params } = ctx;
const { user, session } = await validateRequest(req);
if (
!user ||
user.role === "member" ||
!params?.dnsProviderId ||
!params?.zoneId
) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
dnsProviderId: params.dnsProviderId,
zoneId: params.zoneId,
},
};
}

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