mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-16 05:35:30 +00:00
Compare commits
119 Commits
issue-5151
...
v0.30.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 27dab3f4bf | |||
| c225188ed4 | |||
| 6239073e2a | |||
| a42614004f | |||
| e1a432d5c8 | |||
| 02760212f7 | |||
| 0478f34be5 | |||
| 8b5c14ca74 | |||
| 50acb791cd | |||
| 3a6e18be79 | |||
| 23b15801d5 | |||
| 78fe5e3a33 | |||
| 8cd2ba80f2 | |||
| 8639605b03 | |||
| 1443500b9d | |||
| 9c444ebca3 | |||
| e7eb20d92f | |||
| f1a4f4317a | |||
| 1ab4a8a70a | |||
| 53a6e926d6 | |||
| 43178b6442 | |||
| a194e1e27b | |||
| 50479f20ff | |||
| 4453c55086 | |||
| 3c95912001 | |||
| 5624dff5c1 | |||
| d7ea9cf9bc | |||
| 68a05551d0 | |||
| 57c221de6f | |||
| 48e0b747b6 | |||
| 4e38925119 | |||
| 91bcf88df9 | |||
| a63157cd51 | |||
| 47308070a8 | |||
| a1c57011a4 | |||
| ec8e645758 | |||
| e8f4a1715c | |||
| 880bb55272 | |||
| a36739eb07 | |||
| 1ff9294009 | |||
| 142082fe89 | |||
| 5345c00ff3 | |||
| d181384833 | |||
| 1f5f5576f1 | |||
| d6ec4bd3d1 | |||
| 7e9b77e93f | |||
| 8db39a8d15 | |||
| 714bf03244 | |||
| dada3049a8 | |||
| 0971e1952a | |||
| e0a9b83a39 | |||
| ccfc44815e | |||
| 3f6dfbaaa7 | |||
| e79239a100 | |||
| 9e3d6fdf2c | |||
| cabfe2e81e | |||
| b0ab9c77fb | |||
| 2f7f50aec5 | |||
| 532de2c59e | |||
| 542eced075 | |||
| 4b2de31b1c | |||
| 4b7a6295fc | |||
| a4e29172f4 | |||
| 22450abc4b | |||
| 976161647f | |||
| bfc180dfef | |||
| 628b14d07a | |||
| 0efe7feaf5 | |||
| 474081a264 | |||
| 1053743a1c | |||
| 355b856185 | |||
| a1cc2778f3 | |||
| fee0071223 | |||
| 3334edef73 | |||
| cf0e8d236c | |||
| 9d9b41c1e5 | |||
| bdf33750a3 | |||
| a66684a784 | |||
| abc2439ec4 | |||
| 5b6a8bd1ef | |||
| d26f70642d | |||
| e2d2dbb00a | |||
| e4012d857d | |||
| ab62080e9d | |||
| 3a8fad57e6 | |||
| 203cddbeef | |||
| 032e804114 | |||
| ca3da4dc5d | |||
| 87b914996f | |||
| 8f3230e2ed | |||
| cebd380856 | |||
| a822db5967 | |||
| 71816d0853 | |||
| 85ed75a5c6 | |||
| f46cd4601c | |||
| 2a585933e9 | |||
| 62e6a4c017 | |||
| 4710c28db5 | |||
| 63efbffcfd | |||
| 523e8f4e4f | |||
| b67519ebd5 | |||
| 452b7aa2f2 | |||
| c6f81ce60d | |||
| 74d2d41961 | |||
| bbde5ebbc3 | |||
| 38e855bc5e | |||
| 130be40b49 | |||
| db2eb4f60a | |||
| 9c4ad14c70 | |||
| e3f4310755 | |||
| 80859dc5b6 | |||
| 479e8bb551 | |||
| 529f54e2a5 | |||
| 8144e1a2fe | |||
| d11735d4cd | |||
| 679dbf98cb | |||
| 6204415dc5 | |||
| 09ea766fb5 | |||
| 24cc455168 |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
## 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.
|
||||
+9
-1
@@ -120,12 +120,20 @@ pnpm run docker:push
|
||||
|
||||
## Password Reset
|
||||
|
||||
In the case you lost your password, you can reset it using the following command
|
||||
In the case you lost your password, you can reset the owner's password 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
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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,6 +32,9 @@ 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
|
||||
@@ -46,6 +49,9 @@ const inputEncoding: Record<string, string> = {
|
||||
APOSTROPHE: "it's a test",
|
||||
UNICODE: "héllo wörld 日本語 🚀",
|
||||
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
|
||||
APP_URL: "https://example.com",
|
||||
ASSET_URL: '"${APP_URL}"',
|
||||
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
||||
};
|
||||
|
||||
describe("getCreateEnvFileCommand", () => {
|
||||
|
||||
@@ -50,6 +50,15 @@ 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,6 +16,30 @@ 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,6 +87,224 @@ 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
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -142,13 +142,15 @@ describe("route53Client.listRecords", () => {
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
|
||||
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.upsertRecord", () => {
|
||||
it("sends a single UPSERT change", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
it("sends a single UPSERT change when nothing exists yet", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
const result = await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
@@ -158,7 +160,7 @@ describe("route53Client.upsertRecord", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "A:app.example.com" });
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(command.input.HostedZoneId).toBe("Z123");
|
||||
expect(command.input.ChangeBatch.Changes).toEqual([
|
||||
{
|
||||
@@ -172,6 +174,82 @@ 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", () => {
|
||||
@@ -222,6 +300,25 @@ 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
@@ -20,6 +20,7 @@ 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();
|
||||
@@ -616,3 +617,129 @@ 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,6 +126,13 @@ 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,6 +105,7 @@ 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);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (role: string) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
accessedServers: [] as string[],
|
||||
canCreateProjects: false,
|
||||
canDeleteProjects: false,
|
||||
canCreateServices: false,
|
||||
canDeleteServices: false,
|
||||
canCreateEnvironments: false,
|
||||
canDeleteEnvironments: false,
|
||||
canAccessToTraefikFiles: false,
|
||||
canAccessToDocker: false,
|
||||
canAccessToAPI: false,
|
||||
canAccessToSSHKeys: false,
|
||||
canAccessToGitProviders: false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn = mockMemberData("deployer");
|
||||
let rolesToReturn: { permission: string }[] = [];
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve(rolesToReturn)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
const { checkPermission, resolvePermissions } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
const withPermissions = (permissions: Record<string, string[]>) => {
|
||||
rolesToReturn = [{ permission: JSON.stringify(permissions) }];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
memberToReturn = mockMemberData("deployer");
|
||||
rolesToReturn = [];
|
||||
});
|
||||
|
||||
describe("server.terminal on custom roles", () => {
|
||||
it("a role with server.read alone cannot open a terminal", async () => {
|
||||
withPermissions({ server: ["read"] });
|
||||
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.read).toBe(true);
|
||||
expect(perms.server.terminal).toBe(false);
|
||||
});
|
||||
|
||||
it("a role with server.terminal can open a terminal", async () => {
|
||||
withPermissions({ server: ["read", "terminal"] });
|
||||
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.terminal).toBe(true);
|
||||
});
|
||||
|
||||
it("owner and admin keep terminal access", async () => {
|
||||
for (const role of ["owner", "admin"]) {
|
||||
memberToReturn = mockMemberData(role);
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.terminal).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,21 @@ const serviceExists = async (name: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Swarm keeps converging a service for a bit after it's created (scheduling
|
||||
// tasks, resolving endpoints), which bumps Version.Index on its own. Calling
|
||||
// setupMonitoring again before that settles races that internal bump, so wait
|
||||
// for two consecutive reads to agree before treating the service as stable.
|
||||
const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastIndex: string | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const inspect = await docker.getService(name).inspect();
|
||||
if (inspect.Version.Index === lastIndex) return;
|
||||
lastIndex = inspect.Version.Index;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
};
|
||||
|
||||
const swarmTaskNames = async () => {
|
||||
const list = await docker.listContainers({ all: true });
|
||||
return list
|
||||
@@ -193,6 +208,7 @@ describe.skipIf(hasRealMonitoring())(
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execAsyncRemote: vi.fn(),
|
||||
writeFileRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
writeFileRemote: mocks.writeFileRemote,
|
||||
};
|
||||
});
|
||||
|
||||
describe("writeAppTraefikConfig", () => {
|
||||
let cwd: string;
|
||||
let dynamicPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-"));
|
||||
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
|
||||
fs.mkdirSync(dynamicPath, { recursive: true });
|
||||
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Regression test for #5189: Traefik's file provider rejects a standalone
|
||||
// `routers: {}` / `services: {}` map and aborts its watcher for every
|
||||
// dynamic config once it hits one, so an app with no domains must never
|
||||
// get an on-disk config file at all.
|
||||
it("removes the file instead of writing empty routers/services", async () => {
|
||||
const appName = "no-domain-app";
|
||||
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||
fs.writeFileSync(configPath, "stale content", "utf8");
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{ http: { routers: {}, services: {} } },
|
||||
appName,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the file when routers/services are present", async () => {
|
||||
const appName = "with-domain-app";
|
||||
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
[`${appName}-router-1`]: {
|
||||
rule: "Host(`x`)",
|
||||
service: `${appName}-service`,
|
||||
},
|
||||
},
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
appName,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes the remote file instead of writing empty routers/services", async () => {
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{ http: { routers: {}, services: {} } },
|
||||
"no-domain-app",
|
||||
"server-id",
|
||||
);
|
||||
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
|
||||
expect(command).toMatch(/^rm -f /);
|
||||
expect(command).toContain("no-domain-app.yml");
|
||||
});
|
||||
|
||||
it("writes the remote file when routers/services are present", async () => {
|
||||
mocks.writeFileRemote.mockResolvedValue(undefined);
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
"with-domain-app-router-1": {
|
||||
rule: "Host(`x`)",
|
||||
service: "with-domain-app-service",
|
||||
},
|
||||
},
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
"with-domain-app",
|
||||
"server-id",
|
||||
);
|
||||
|
||||
expect(mocks.writeFileRemote).toHaveBeenCalledOnce();
|
||||
const [serverId, remotePath, content] =
|
||||
mocks.writeFileRemote.mock.calls[0] ?? [];
|
||||
expect(serverId).toBe("server-id");
|
||||
expect(remotePath).toContain("with-domain-app.yml");
|
||||
expect(content).toContain("with-domain-app-router-1");
|
||||
});
|
||||
});
|
||||
@@ -95,10 +95,35 @@ 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);
|
||||
// role lookup must not be needed for the remote path
|
||||
// the remote path must never fall through to the owner/admin local branch
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,7 +342,7 @@ export const ShowDeployments = ({
|
||||
)}
|
||||
{/* Hash (from description) - shown in compact form */}
|
||||
{deployment.description?.trim() && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
<span className="wrap-anywhere text-xs text-muted-foreground font-mono">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -191,8 +191,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
try {
|
||||
const result = await validateDomain({
|
||||
domain: host,
|
||||
serverIp:
|
||||
application?.server?.ipAddress?.toString() || ip?.toString() || "",
|
||||
serverId: application?.serverId ?? undefined,
|
||||
});
|
||||
|
||||
setValidationStates((prev) => ({
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -197,6 +198,18 @@ 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,6 +151,8 @@ 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-row justify-between items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm: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">
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ShowDockerLogsStack = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName && option === "swarm",
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -67,6 +68,7 @@ export const ShowDockerLogsStack = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName && option === "native",
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -52,14 +53,15 @@ export const ShowDockerLogsCompose = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName,
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data?.length > 0) {
|
||||
setContainerId(data[0]?.containerId);
|
||||
}
|
||||
setContainerId((currentContainerId) =>
|
||||
resolveContainerSelection(currentContainerId, data),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -352,11 +352,11 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
className="h-9 w-full sm:w-auto"
|
||||
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"
|
||||
className="h-9 w-full sm:w-auto"
|
||||
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 sm:w-auto w-full"
|
||||
className="h-9 w-full sm:w-auto"
|
||||
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-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||
className="h-[50vh] sm: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,7 +64,9 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
|
||||
const tooltip = (color: string, timestamp: string | null) => {
|
||||
const square = (
|
||||
<div className={cn("w-2 h-full shrink-0 rounded-[3px]", color)} />
|
||||
<div
|
||||
className={cn("w-2 min-h-4 h-full flex-shrink-0 rounded-[3px]", color)}
|
||||
/>
|
||||
);
|
||||
return timestamp ? (
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||
@@ -88,7 +90,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
|
||||
"font-mono text-xs flex flex-col sm:flex-row gap-1 sm:gap-3 py-2 sm:py-0.5 group",
|
||||
type === "error"
|
||||
? "bg-red-500/10 hover:bg-red-500/15"
|
||||
: type === "warning"
|
||||
@@ -104,7 +106,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-full sm:w-40 shrink-0">
|
||||
<span className="select-none pl-2 text-muted-foreground w-auto sm:w-40 flex-shrink-0">
|
||||
{formattedTime}
|
||||
</span>
|
||||
)}
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -167,6 +168,8 @@ 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);
|
||||
@@ -196,7 +199,9 @@ export const ContainerFreeMonitoring = ({
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
console.log(e.reason);
|
||||
if (e.reason) {
|
||||
toast.error(e.reason);
|
||||
}
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
|
||||
@@ -19,6 +19,11 @@ 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",
|
||||
@@ -38,8 +43,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
<CardHeader className="border-b py-5">
|
||||
<CardTitle>Network</CardTitle>
|
||||
<CardDescription>
|
||||
Network Traffic: ↑ {latestData.networkOut} KB/s ↓{" "}
|
||||
{latestData.networkIn} KB/s
|
||||
Network Total: ↑ {formatNetworkGB(latestData.networkOut)} GB ↓{" "}
|
||||
{formatNetworkGB(latestData.networkIn)} GB (since Boot)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
@@ -83,7 +88,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatTimestamp(value)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value} KB/s`} />
|
||||
<YAxis tickFormatter={(value) => `${formatNetworkGB(value)} GB`} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
@@ -105,8 +110,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
Network
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
↑ {data.networkOut} KB/s
|
||||
<br />↓ {data.networkIn} KB/s
|
||||
↑ {formatNetworkGB(data.networkOut)} GB
|
||||
<br />↓ {formatNetworkGB(data.networkIn)} GB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ 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) => {
|
||||
@@ -102,6 +103,20 @@ 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}
|
||||
@@ -177,6 +192,45 @@ 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 />
|
||||
|
||||
@@ -48,6 +48,7 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
},
|
||||
{
|
||||
enabled: !!organizationId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
const { mutateAsync, isPending } = organizationId
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -156,7 +157,17 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
name="env"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<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>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
completionSource={completionSource}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -152,7 +153,16 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
name="env"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<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>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
completionSource={completionSource}
|
||||
|
||||
@@ -17,11 +17,13 @@ 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 {
|
||||
@@ -100,7 +102,12 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data: statsLogs } = api.settings.readStatsLogs.useQuery(
|
||||
const {
|
||||
data: statsLogs,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = api.settings.readStatsLogs.useQuery(
|
||||
{
|
||||
sort: sorting[0],
|
||||
page: pagination,
|
||||
@@ -273,7 +280,18 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{statsLogs?.data.length === 0 && (
|
||||
{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>
|
||||
) : (
|
||||
<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.
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
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,11 +33,17 @@ 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;
|
||||
@@ -49,10 +55,12 @@ const DnsProviderSchema = z.object({
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: "Only letters, numbers, dashes and underscores",
|
||||
}),
|
||||
providerType: z.enum(["cloudflare", "route53"]),
|
||||
providerType: z.enum(["cloudflare", "route53", "porkbun"]),
|
||||
apiToken: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
apiKey: z.string(),
|
||||
secretApiKey: z.string(),
|
||||
});
|
||||
|
||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||
@@ -63,6 +71,8 @@ const defaultValues: DnsProviderForm = {
|
||||
apiToken: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
apiKey: "",
|
||||
secretApiKey: "",
|
||||
};
|
||||
|
||||
const buildConfig = (data: DnsProviderForm) => {
|
||||
@@ -78,6 +88,12 @@ const buildConfig = (data: DnsProviderForm) => {
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
};
|
||||
case "porkbun":
|
||||
return {
|
||||
providerType: "porkbun" as const,
|
||||
apiKey: data.apiKey,
|
||||
secretApiKey: data.secretApiKey,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,6 +151,10 @@ 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);
|
||||
@@ -180,22 +200,30 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<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" />
|
||||
{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" />
|
||||
Add Provider
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -319,6 +347,42 @@ 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"
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,218 +0,0 @@
|
||||
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,8 +1,9 @@
|
||||
import { Globe, Loader2, Trash2 } from "lucide-react";
|
||||
import { EyeIcon, Globe, Loader2, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "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,
|
||||
@@ -11,9 +12,13 @@ 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",
|
||||
@@ -21,121 +26,142 @@ const providerLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const ShowDnsProviders = () => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.dnsProvider.remove.useMutation();
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const { mutateAsync } = api.dnsProvider.remove.useMutation();
|
||||
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="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">
|
||||
<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">
|
||||
<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">
|
||||
{isPending ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<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-[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>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
<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 />
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
|
||||
<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
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DialogAction>
|
||||
<TooltipContent>Delete provider</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
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,6 +446,7 @@ 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({
|
||||
@@ -681,6 +682,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
decoration: data.decoration,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
gotifyId: notification?.gotifyId || "",
|
||||
});
|
||||
@@ -698,6 +700,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
priority: data.priority,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
ntfyId: notification?.ntfyId || "",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -12,9 +12,6 @@ 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 {
|
||||
@@ -33,6 +30,7 @@ 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";
|
||||
@@ -43,7 +41,6 @@ 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 } =
|
||||
@@ -111,7 +108,6 @@ 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 (
|
||||
@@ -250,29 +246,30 @@ export const ShowServers = () => {
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
{server.sshKeyId && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
{server.sshKeyId &&
|
||||
permissions?.server.terminal && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TerminalModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Terminal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -349,64 +346,22 @@ export const ShowServers = () => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<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,
|
||||
);
|
||||
});
|
||||
}}
|
||||
<DeleteServerModal
|
||||
serverId={server.serverId}
|
||||
serverName={server.name}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
|
||||
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</DeleteServerModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{canDelete
|
||||
? "Delete Server"
|
||||
: "Cannot delete - has active services"}
|
||||
</p>
|
||||
<p>Delete Server</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -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 } = api.user.remove.useMutation();
|
||||
const { mutateAsync, isPending: isRemoving } = api.user.remove.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: hasValidLicense } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
@@ -237,6 +237,7 @@ 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,
|
||||
@@ -269,6 +270,7 @@ 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,6 +43,7 @@ const providerLabels = {
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
phase: "Phase",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
@@ -63,6 +64,7 @@ const VaultProviderSchema = z
|
||||
"doppler",
|
||||
"azure",
|
||||
"scaleway",
|
||||
"phase",
|
||||
]),
|
||||
url: z.string(),
|
||||
token: z.string(),
|
||||
@@ -89,6 +91,11 @@ 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(),
|
||||
@@ -161,6 +168,17 @@ 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][]>
|
||||
@@ -194,6 +212,11 @@ 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] ?? []) {
|
||||
@@ -254,6 +277,11 @@ const defaultValues: VaultProviderForm = {
|
||||
scalewayProjectId: "",
|
||||
scalewaySecretKey: "",
|
||||
scalewayApiUrl: "https://api.scaleway.com",
|
||||
phaseToken: "",
|
||||
phaseAppId: "",
|
||||
phaseEnv: "",
|
||||
phasePath: "/",
|
||||
phaseApiUrl: "https://api.phase.dev",
|
||||
assignments: [],
|
||||
};
|
||||
|
||||
@@ -308,6 +336,15 @@ 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",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -429,6 +466,13 @@ 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);
|
||||
@@ -996,8 +1040,118 @@ 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">
|
||||
<FormLabel>Access</FormLabel>
|
||||
<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>
|
||||
<FormDescription>
|
||||
This provider can only be referenced from the selected projects.
|
||||
Pick environments to narrow it further — none selected means all
|
||||
|
||||
@@ -21,6 +21,7 @@ const providerLabels: Record<string, string> = {
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
phase: "Phase",
|
||||
};
|
||||
|
||||
export const ShowVaultProviders = () => {
|
||||
|
||||
@@ -34,7 +34,65 @@ 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,6 +582,28 @@ 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,
|
||||
@@ -589,4 +611,5 @@ export const vaultProviderIcons = {
|
||||
doppler: DopplerIcon,
|
||||
azure: AzureIcon,
|
||||
scaleway: ScalewayIcon,
|
||||
phase: PhaseIcon,
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Head from "next/head";
|
||||
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";
|
||||
@@ -8,9 +10,11 @@ interface Props {
|
||||
metaName?: string;
|
||||
}
|
||||
|
||||
export const DashboardLayout = ({ children }: Props) => {
|
||||
export const DashboardLayout = ({ children, metaName }: 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,
|
||||
@@ -22,6 +26,13 @@ export const DashboardLayout = ({ children }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{metaName && (
|
||||
<Head>
|
||||
<title>
|
||||
{metaName} | {appName}
|
||||
</title>
|
||||
</Head>
|
||||
)}
|
||||
<Page>{children}</Page>
|
||||
{isChatEnabled && (
|
||||
<>
|
||||
|
||||
@@ -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) and where their secrets can be referenced",
|
||||
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway, Phase) and where their secrets can be referenced",
|
||||
},
|
||||
dnsProvider: {
|
||||
label: "DNS Providers",
|
||||
@@ -328,6 +328,10 @@ 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" },
|
||||
@@ -569,7 +573,7 @@ const ROLE_PRESETS: {
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "delete"],
|
||||
backup: ["read", "create", "delete", "restore"],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OpenPanelComponent } from "@openpanel/nextjs";
|
||||
import { useRouter } from "next/router";
|
||||
import Script from "next/script";
|
||||
import { useEffect } from "react";
|
||||
@@ -5,9 +6,10 @@ import { GTM_ID, pushToDataLayer } from "@/lib/analytics";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
/**
|
||||
* Loads Google Tag Manager and the HubSpot marketing tag on the cloud
|
||||
* version only, and translates tracking query params appended by the
|
||||
* auth/billing flows (?signup=..., ?subscription=new) into dataLayer events.
|
||||
* 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.
|
||||
*/
|
||||
export const Analytics = () => {
|
||||
const router = useRouter();
|
||||
@@ -40,6 +42,14 @@ 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 };
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -23,6 +24,8 @@ interface Props {
|
||||
description: ReactNode;
|
||||
placeholder: string;
|
||||
completionSource?: CompletionSource;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
}
|
||||
|
||||
export const Secrets = (props: Props) => {
|
||||
@@ -37,17 +40,27 @@ export const Secrets = (props: Props) => {
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
</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>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="w-full space-y-4 p-0">
|
||||
<FormField
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "network" ADD COLUMN "dockerId" text;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'phase';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'porkbun';
|
||||
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
@@ -1303,6 +1303,34 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.30.2",
|
||||
"version": "v0.30.4",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -67,6 +67,7 @@
|
||||
"@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,4 +1,8 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import {
|
||||
OPENAPI_MAX_JSON_BODY_SIZE,
|
||||
OPENAPI_MAX_UPLOAD_SIZE,
|
||||
validateRequest,
|
||||
} from "@dokploy/server";
|
||||
import { createOpenApiNextHandler } from "@dokploy/trpc-openapi";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
@@ -12,10 +16,31 @@ 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 }) => {
|
||||
@@ -28,3 +53,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,11 +8,7 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowDnsProviders />
|
||||
</div>
|
||||
);
|
||||
return <ShowDnsProviders />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,30 +1,54 @@
|
||||
import { findOwner, generateRandomPassword } from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { account } from "@dokploy/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { account, user } from "@dokploy/server/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const email = process.argv[2]?.trim().toLowerCase();
|
||||
|
||||
const randomPassword = await generateRandomPassword();
|
||||
|
||||
const result = await findOwner();
|
||||
let userId: string;
|
||||
|
||||
if (email) {
|
||||
const foundUser = await db.query.user.findFirst({
|
||||
where: eq(user.email, email),
|
||||
});
|
||||
|
||||
if (!foundUser) {
|
||||
console.log(`User not found for email: ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
userId = foundUser.id;
|
||||
} else {
|
||||
const owner = await findOwner();
|
||||
userId = owner.userId;
|
||||
}
|
||||
|
||||
const update = await db
|
||||
.update(account)
|
||||
.set({
|
||||
password: randomPassword.hashedPassword,
|
||||
})
|
||||
.where(eq(account.userId, result.userId));
|
||||
.where(
|
||||
and(eq(account.userId, userId), eq(account.providerId, "credential")),
|
||||
);
|
||||
|
||||
if (update) {
|
||||
if (update.count > 0) {
|
||||
console.log("Password reset successful");
|
||||
console.log("New password: ", randomPassword.randomPassword);
|
||||
} else {
|
||||
console.log("Password reset failed");
|
||||
console.log(
|
||||
"Password reset failed: no credential account found for this user",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.log("Error resetting password", error);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
assertGitProviderAccess,
|
||||
canViewGitProviderSecrets,
|
||||
createBitbucket,
|
||||
findBitbucketById,
|
||||
getAccessibleGitProviderIds,
|
||||
@@ -55,6 +56,17 @@ export const bitbucketRouter = createTRPCRouter({
|
||||
.query(async ({ input, ctx }) => {
|
||||
const bitbucket = await findBitbucketById(input.bitbucketId);
|
||||
await assertGitProviderAccess(ctx.session, bitbucket.gitProvider);
|
||||
|
||||
if (
|
||||
!(await canViewGitProviderSecrets(ctx.session, bitbucket.gitProvider))
|
||||
) {
|
||||
return {
|
||||
...bitbucket,
|
||||
appPassword: null,
|
||||
apiToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
return bitbucket;
|
||||
}),
|
||||
bitbucketProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
addDomainToCompose,
|
||||
clearOldDeployments,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
updateCompose,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
|
||||
import {
|
||||
@@ -547,7 +549,12 @@ export const composeRouter = createTRPCRouter({
|
||||
service: ["create"],
|
||||
});
|
||||
const compose = await findComposeById(input.composeId);
|
||||
const command = createCommand(compose);
|
||||
const { COMPOSE_PATH } = paths(!!compose.serverId);
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
const command = createCommand(
|
||||
compose,
|
||||
compose.mounts.length > 0 ? projectPath : undefined,
|
||||
);
|
||||
return `docker ${command}`;
|
||||
}),
|
||||
refreshToken: protectedProcedure
|
||||
|
||||
@@ -162,6 +162,7 @@ export const dnsProviderRouter = createTRPCRouter({
|
||||
name: input.name,
|
||||
content: input.content,
|
||||
ttl: input.ttl,
|
||||
proxied: input.proxied,
|
||||
});
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
@@ -188,6 +189,7 @@ export const dnsProviderRouter = createTRPCRouter({
|
||||
name: input.name,
|
||||
content: input.content,
|
||||
ttl: input.ttl,
|
||||
proxied: input.proxied,
|
||||
},
|
||||
);
|
||||
await audit(ctx, {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findPreviewDeploymentById,
|
||||
findServerById,
|
||||
generateTraefikMeDomain,
|
||||
getServerIpCandidates,
|
||||
getWebServerSettings,
|
||||
manageDomain,
|
||||
removeDomain,
|
||||
@@ -248,10 +249,21 @@ export const domainRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
domain: z.string(),
|
||||
serverIp: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return validateDomain(input.domain, input.serverIp);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this server",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const expectedIps = await getServerIpCandidates(input.serverId);
|
||||
return validateDomain(input.domain, expectedIps);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
assertGitProviderAccess,
|
||||
canViewGitProviderSecrets,
|
||||
createGitea,
|
||||
findGiteaById,
|
||||
getAccessibleGitProviderIds,
|
||||
@@ -59,6 +60,16 @@ export const giteaRouter = createTRPCRouter({
|
||||
.query(async ({ input, ctx }) => {
|
||||
const gitea = await findGiteaById(input.giteaId);
|
||||
await assertGitProviderAccess(ctx.session, gitea.gitProvider);
|
||||
|
||||
if (!(await canViewGitProviderSecrets(ctx.session, gitea.gitProvider))) {
|
||||
return {
|
||||
...gitea,
|
||||
clientSecret: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
return gitea;
|
||||
}),
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
assertGitProviderAccess,
|
||||
canViewGitProviderSecrets,
|
||||
findGithubById,
|
||||
getAccessibleGitProviderIds,
|
||||
getGithubBranches,
|
||||
@@ -28,6 +29,16 @@ export const githubRouter = createTRPCRouter({
|
||||
.query(async ({ input, ctx }) => {
|
||||
const github = await findGithubById(input.githubId);
|
||||
await assertGitProviderAccess(ctx.session, github.gitProvider);
|
||||
|
||||
if (!(await canViewGitProviderSecrets(ctx.session, github.gitProvider))) {
|
||||
return {
|
||||
...github,
|
||||
githubClientSecret: null,
|
||||
githubPrivateKey: null,
|
||||
githubWebhookSecret: null,
|
||||
};
|
||||
}
|
||||
|
||||
return github;
|
||||
}),
|
||||
getGithubRepositories: protectedProcedure
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
assertGitProviderAccess,
|
||||
canViewGitProviderSecrets,
|
||||
createGitlab,
|
||||
findGitlabById,
|
||||
getAccessibleGitProviderIds,
|
||||
@@ -57,6 +58,16 @@ export const gitlabRouter = createTRPCRouter({
|
||||
.query(async ({ input, ctx }) => {
|
||||
const gitlab = await findGitlabById(input.gitlabId);
|
||||
await assertGitProviderAccess(ctx.session, gitlab.gitProvider);
|
||||
|
||||
if (!(await canViewGitProviderSecrets(ctx.session, gitlab.gitProvider))) {
|
||||
return {
|
||||
...gitlab,
|
||||
secret: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
return gitlab;
|
||||
}),
|
||||
gitlabProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
inspectNetwork,
|
||||
recreateNetwork,
|
||||
removeNetwork,
|
||||
resyncNetwork,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
@@ -129,6 +130,29 @@ export const networkRouter = createTRPCRouter({
|
||||
return recreated;
|
||||
}),
|
||||
|
||||
resync: protectedProcedure
|
||||
.input(apiFindOneNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const network = await findNetworkById(input.networkId);
|
||||
if (network.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
const resynced = await resyncNetwork(
|
||||
input.networkId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "network",
|
||||
resourceId: resynced.networkId,
|
||||
resourceName: resynced.name,
|
||||
});
|
||||
return resynced;
|
||||
}),
|
||||
|
||||
remove: protectedProcedure
|
||||
.input(apiRemoveNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { asc, desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
|
||||
import { removeJob, schedule } from "@/server/utils/backup";
|
||||
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const scheduleRouter = createTRPCRouter({
|
||||
@@ -130,7 +130,7 @@ export const scheduleRouter = createTRPCRouter({
|
||||
|
||||
if (IS_CLOUD) {
|
||||
if (updatedSchedule?.enabled) {
|
||||
schedule({
|
||||
await updateJob({
|
||||
scheduleId: updatedSchedule.scheduleId,
|
||||
type: "schedule",
|
||||
cronSchedule: updatedSchedule.cronExpression,
|
||||
@@ -141,6 +141,7 @@ export const scheduleRouter = createTRPCRouter({
|
||||
cronSchedule: updatedSchedule.cronExpression,
|
||||
scheduleId: updatedSchedule.scheduleId,
|
||||
type: "schedule",
|
||||
timezone: updatedSchedule.timezone,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -185,6 +186,7 @@ export const scheduleRouter = createTRPCRouter({
|
||||
cronSchedule: scheduleItem.cronExpression,
|
||||
scheduleId: scheduleItem.scheduleId,
|
||||
type: "schedule",
|
||||
timezone: scheduleItem.timezone,
|
||||
});
|
||||
} else {
|
||||
removeScheduleJob(scheduleItem.scheduleId);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findUserById,
|
||||
getAccessibleServerIds,
|
||||
getPublicIpWithFallback,
|
||||
getServicesByServerId,
|
||||
haveActiveServices,
|
||||
IS_CLOUD,
|
||||
redactServerSshKey,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
updateServerById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { findMemberByUserId } from "@dokploy/server/services/permission";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
@@ -115,6 +117,41 @@ export const serverRouter = createTRPCRouter({
|
||||
const isBuildServer = server.serverType === "build";
|
||||
return defaultCommand(isBuildServer);
|
||||
}),
|
||||
getServices: withPermission("server", "read")
|
||||
.input(apiFindOneServer)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const currentServer = await findServerById(input.serverId);
|
||||
if (currentServer.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this server",
|
||||
});
|
||||
}
|
||||
|
||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||
if (!accessibleIds.has(input.serverId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this server",
|
||||
});
|
||||
}
|
||||
|
||||
const services = await getServicesByServerId(input.serverId);
|
||||
|
||||
const isPrivileged =
|
||||
ctx.user.role === "owner" || ctx.user.role === "admin";
|
||||
if (isPrivileged) {
|
||||
return services;
|
||||
}
|
||||
|
||||
const { accessedServices } = await findMemberByUserId(
|
||||
ctx.user.id,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return services.filter((service) =>
|
||||
accessedServices.includes(service.id),
|
||||
);
|
||||
}),
|
||||
all: withPermission("server", "read").query(async ({ ctx }) => {
|
||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||
|
||||
|
||||
@@ -80,33 +80,42 @@ export const stripeRouter = createTRPCRouter({
|
||||
let currentPlan: CurrentPlan = "legacy";
|
||||
let isAnnualCurrent = false;
|
||||
let currentPriceAmount: number | null = null;
|
||||
const activeSub = subscriptions.data[0];
|
||||
if (activeSub) {
|
||||
const priceIds = activeSub.items.data.map(
|
||||
(item) => (item.price as Stripe.Price).id,
|
||||
if (subscriptions.data.length > 0) {
|
||||
const matchedSub = subscriptions.data.find((sub) =>
|
||||
sub.items.data.some(
|
||||
(item) =>
|
||||
(item.price as Stripe.Price).id === STARTUP_BASE_PRICE_MONTHLY_ID ||
|
||||
(item.price as Stripe.Price).id === STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||
),
|
||||
);
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) =>
|
||||
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
|
||||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
const hobbySub = subscriptions.data.find((sub) =>
|
||||
sub.items.data.some(
|
||||
(item) =>
|
||||
(item.price as Stripe.Price).id === HOBBY_PRICE_MONTHLY_ID ||
|
||||
(item.price as Stripe.Price).id === HOBBY_PRICE_ANNUAL_ID,
|
||||
),
|
||||
);
|
||||
const legacySub = subscriptions.data.find((sub) =>
|
||||
sub.items.data.some((item) =>
|
||||
LEGACY_PRICE_IDS.includes((item.price as Stripe.Price).id),
|
||||
),
|
||||
);
|
||||
|
||||
const activeSub = matchedSub ?? hobbySub ?? legacySub;
|
||||
if (matchedSub) {
|
||||
currentPlan = "startup";
|
||||
} else if (
|
||||
priceIds.some(
|
||||
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
} else if (hobbySub) {
|
||||
currentPlan = "hobby";
|
||||
} else if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
||||
} else if (legacySub) {
|
||||
currentPlan = "legacy";
|
||||
}
|
||||
const firstPrice = activeSub.items.data[0]?.price as
|
||||
|
||||
const firstPrice = activeSub?.items.data[0]?.price as
|
||||
| Stripe.Price
|
||||
| undefined;
|
||||
isAnnualCurrent = firstPrice?.recurring?.interval === "year";
|
||||
const totalCents = activeSub.items.data.reduce((sum, item) => {
|
||||
|
||||
const totalCents = (activeSub?.items.data ?? []).reduce((sum, item) => {
|
||||
const price = item.price as Stripe.Price;
|
||||
const amount = price.unit_amount ?? 0;
|
||||
const qty = item.quantity ?? 1;
|
||||
|
||||
@@ -27,11 +27,10 @@ export const getCurrentPlanForUser = async (
|
||||
status: "active",
|
||||
expand: ["data.items.data.price"],
|
||||
});
|
||||
const activeSub = subscriptions.data[0];
|
||||
if (!activeSub) return null;
|
||||
if (subscriptions.data.length === 0) return null;
|
||||
|
||||
const priceIds = activeSub.items.data.map(
|
||||
(item) => (item.price as Stripe.Price).id,
|
||||
const priceIds = subscriptions.data.flatMap((sub) =>
|
||||
sub.items.data.map((item) => (item.price as Stripe.Price).id),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -62,7 +62,8 @@ export const canAccessDockerOverWss = async (
|
||||
|
||||
// Authorizes the host/server SSH terminal opened over a WebSocket. The local
|
||||
// host terminal is a root shell on the control-plane host, so it is restricted
|
||||
// to owner/admin. A remote server terminal is gated on server access.
|
||||
// to owner/admin. A remote server terminal needs server access plus
|
||||
// server.terminal.
|
||||
export const canAccessTerminalOverWss = async (
|
||||
user: WssUser,
|
||||
session: WssSession,
|
||||
@@ -75,7 +76,11 @@ export const canAccessTerminalOverWss = async (
|
||||
userId: user.id,
|
||||
activeOrganizationId: session.activeOrganizationId,
|
||||
});
|
||||
return accessible.has(serverId);
|
||||
if (!accessible.has(serverId)) return false;
|
||||
|
||||
return await hasPermission(buildCtx(user, session.activeOrganizationId), {
|
||||
server: ["terminal"],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -182,6 +182,10 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
||||
ptyProcess.onData((data) => {
|
||||
ws.send(data);
|
||||
});
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
ws.send(`\nContainer closed with code: ${exitCode}\n`);
|
||||
ws.close();
|
||||
});
|
||||
ws.on("close", () => {
|
||||
ptyProcess.kill();
|
||||
});
|
||||
|
||||
@@ -8,9 +8,57 @@ import {
|
||||
recordAdvancedStats,
|
||||
validateRequest,
|
||||
} from "@dokploy/server";
|
||||
import { quote } from "shell-quote";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { canAccessDockerOverWss } from "./authorize";
|
||||
|
||||
type AppType = "application" | "stack" | "docker-compose";
|
||||
|
||||
// Swarm task names are "<service>.<slot>.<taskId>"; the manager's local
|
||||
// `docker ps` only sees containers scheduled on this host, so a task running
|
||||
// on another node looks identical to a stopped one. `docker service ps` is
|
||||
// swarm-aggregated and answers from the manager regardless of which node the
|
||||
// task landed on, so we use it here to tell the two cases apart.
|
||||
const findRemoteSwarmNode = async (
|
||||
appName: string,
|
||||
appType: AppType,
|
||||
): Promise<string | null> => {
|
||||
if (appType === "docker-compose") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `docker service ps --format {{.Name}}` only prints "<service>.<slot>",
|
||||
// never the taskId, so we match on the task ID instead — the last segment
|
||||
// of the stack task name — rather than trying to reconstruct the full name.
|
||||
const [serviceName, taskId] =
|
||||
appType === "stack"
|
||||
? [appName.split(".").slice(0, -2).join("."), appName.split(".").pop()]
|
||||
: [appName, null];
|
||||
|
||||
if (!serviceName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`docker service ps ${quote([serviceName])} --filter "desired-state=running" --no-trunc --format '{"ID":"{{.ID}}","Node":"{{.Node}}","CurrentState":"{{.CurrentState}}"}'`,
|
||||
);
|
||||
|
||||
for (const line of stdout.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const task = JSON.parse(line);
|
||||
const isMatch = taskId ? task.ID === taskId : true;
|
||||
if (isMatch && task.CurrentState?.startsWith("Running")) {
|
||||
return task.Node;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not a swarm service (or the swarm CLI isn't available) — fall back to "not running".
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const setupDockerStatsMonitoringSocketServer = (
|
||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||
) => {
|
||||
@@ -98,7 +146,13 @@ export const setupDockerStatsMonitoringSocketServer = (
|
||||
|
||||
const container = containers[0];
|
||||
if (!container || container?.State !== "running") {
|
||||
ws.close(4000, "Container not running");
|
||||
const remoteNode = await findRemoteSwarmNode(appName, appType);
|
||||
ws.close(
|
||||
4000,
|
||||
remoteNode
|
||||
? `Container running on remote node "${remoteNode}"`.slice(0, 123)
|
||||
: "Container not running",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { stdout, stderr } = await execAsync(
|
||||
|
||||
@@ -531,3 +531,98 @@ body[data-scroll-locked] [data-slot="sidebar-inset"] {
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--duration-quick: 150ms;
|
||||
--duration-fast: 250ms;
|
||||
--duration-medium: 350ms;
|
||||
--duration-slow: 400ms;
|
||||
--ease-smooth-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--distance-base: 8px;
|
||||
--blur-medium: 3px;
|
||||
--panel-open-dur: 400ms;
|
||||
--panel-close-dur: 350ms;
|
||||
--panel-translate-x: 32px;
|
||||
--panel-blur: 2px;
|
||||
--panel-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.t-panel-slide-x {
|
||||
transform: translateX(var(--panel-translate-x));
|
||||
opacity: 0;
|
||||
filter: blur(var(--panel-blur));
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform var(--panel-close-dur) var(--panel-ease),
|
||||
opacity var(--panel-close-dur) var(--panel-ease),
|
||||
filter var(--panel-close-dur) var(--panel-ease);
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
.t-panel-slide-x[data-open="true"] {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
transform var(--panel-open-dur) var(--panel-ease),
|
||||
opacity var(--panel-open-dur) var(--panel-ease),
|
||||
filter var(--panel-open-dur) var(--panel-ease);
|
||||
}
|
||||
|
||||
.t-panel-track {
|
||||
transition:
|
||||
grid-template-columns var(--panel-open-dur) var(--panel-ease),
|
||||
grid-template-rows var(--panel-open-dur) var(--panel-ease);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.t-panel-slide-x,
|
||||
.t-panel-track {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.t-page-enter {
|
||||
animation: t-page-enter var(--duration-fast) var(--ease-smooth-out) both;
|
||||
}
|
||||
|
||||
.t-page-enter[data-direction="back"] {
|
||||
animation-name: t-page-enter-back;
|
||||
}
|
||||
|
||||
@keyframes t-page-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(var(--distance-base));
|
||||
filter: blur(var(--blur-medium));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes t-page-enter-back {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(calc(var(--distance-base) * -1));
|
||||
filter: blur(var(--blur-medium));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.t-page-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
|
||||
scheduleId: data.scheduleId,
|
||||
type: "schedule",
|
||||
cronSchedule: job.pattern || "",
|
||||
timezone: job.tz || data.timezone,
|
||||
});
|
||||
} else if (data.type === "volume-backup") {
|
||||
result = await removeJob({
|
||||
|
||||
@@ -66,9 +66,10 @@ export const removeJob = async (data: QueueJob) => {
|
||||
return result;
|
||||
}
|
||||
if (data.type === "schedule") {
|
||||
const { scheduleId, cronSchedule } = data;
|
||||
const { scheduleId, cronSchedule, timezone } = data;
|
||||
const result = await jobQueue.removeRepeatable(scheduleId, {
|
||||
pattern: cronSchedule,
|
||||
tz: timezone || "UTC",
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,28 @@ export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
|
||||
|
||||
export const CLEANUP_CRON_JOB = "50 23 * * *";
|
||||
|
||||
// Body size limits for the OpenAPI catch-all route (pages/api/[...trpc].ts).
|
||||
const parseByteSize = (envVar: string, fallback: number): number => {
|
||||
const raw = process.env[envVar];
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
console.warn(`Invalid ${envVar}="${raw}", using default ${fallback}`);
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const OPENAPI_MAX_JSON_BODY_SIZE = parseByteSize(
|
||||
"OPENAPI_MAX_JSON_BODY_SIZE",
|
||||
10 * 1024 * 1024, // 10mb
|
||||
);
|
||||
|
||||
export const OPENAPI_MAX_UPLOAD_SIZE = parseByteSize(
|
||||
"OPENAPI_MAX_UPLOAD_SIZE",
|
||||
1024 * 1024 * 1024, // 1gb
|
||||
);
|
||||
|
||||
type DockerSocketCandidate = {
|
||||
label: string;
|
||||
path: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { organization } from "./account";
|
||||
export const dnsProviderType = pgEnum("DnsProviderType", [
|
||||
"cloudflare",
|
||||
"route53",
|
||||
"porkbun",
|
||||
]);
|
||||
|
||||
export const cloudflareDnsConfigSchema = z.object({
|
||||
@@ -20,9 +21,16 @@ export const route53DnsConfigSchema = z.object({
|
||||
secretAccessKey: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const porkbunDnsConfigSchema = z.object({
|
||||
providerType: z.literal("porkbun"),
|
||||
apiKey: z.string().trim().min(1),
|
||||
secretApiKey: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
cloudflareDnsConfigSchema,
|
||||
route53DnsConfigSchema,
|
||||
porkbunDnsConfigSchema,
|
||||
]);
|
||||
|
||||
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
|
||||
@@ -96,11 +104,28 @@ export const apiListDnsRecords = z.object({
|
||||
zoneId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const dnsRecordTypes = [
|
||||
"A",
|
||||
"AAAA",
|
||||
"CNAME",
|
||||
"MX",
|
||||
"TXT",
|
||||
"NS",
|
||||
"SRV",
|
||||
"CAA",
|
||||
"PTR",
|
||||
] as const;
|
||||
|
||||
export type DnsRecordType = (typeof dnsRecordTypes)[number];
|
||||
|
||||
export const proxiableDnsRecordTypes = ["A", "AAAA", "CNAME"] as const;
|
||||
|
||||
const dnsRecordFieldsSchema = z.object({
|
||||
type: z.enum(["A", "CNAME"]),
|
||||
type: z.enum(dnsRecordTypes),
|
||||
name: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
ttl: z.number().int().positive().optional(),
|
||||
proxied: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateDnsRecord = dnsRecordFieldsSchema.extend({
|
||||
|
||||
@@ -21,6 +21,7 @@ export const network = pgTable("network", {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
dockerId: text("dockerId"),
|
||||
driver: networkDriver("driver").notNull().default("bridge"),
|
||||
internal: boolean("internal").notNull().default(false),
|
||||
attachable: boolean("attachable").notNull().default(false),
|
||||
|
||||
@@ -433,6 +433,7 @@ export const apiCreateGotify = notificationsSchema
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
dockerCleanup: true,
|
||||
serverThreshold: true,
|
||||
})
|
||||
.extend({
|
||||
serverUrl: z.string().min(1),
|
||||
@@ -468,6 +469,7 @@ export const apiCreateNtfy = notificationsSchema
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
dockerCleanup: true,
|
||||
serverThreshold: true,
|
||||
})
|
||||
.extend({
|
||||
serverUrl: z.string().min(1),
|
||||
|
||||
@@ -11,6 +11,7 @@ export const vaultProviderType = pgEnum("VaultProviderType", [
|
||||
"doppler",
|
||||
"azure",
|
||||
"scaleway",
|
||||
"phase",
|
||||
]);
|
||||
|
||||
export const hashicorpVaultConfigSchema = z.object({
|
||||
@@ -62,6 +63,15 @@ export const scalewayVaultConfigSchema = z.object({
|
||||
apiUrl: z.string().url().default("https://api.scaleway.com"),
|
||||
});
|
||||
|
||||
export const phaseVaultConfigSchema = z.object({
|
||||
providerType: z.literal("phase"),
|
||||
token: z.string().min(1),
|
||||
appId: z.string().min(1),
|
||||
env: z.string().min(1),
|
||||
path: z.string().default("/"),
|
||||
apiUrl: z.string().url().default("https://api.phase.dev"),
|
||||
});
|
||||
|
||||
export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
hashicorpVaultConfigSchema,
|
||||
infisicalVaultConfigSchema,
|
||||
@@ -69,6 +79,7 @@ export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
dopplerVaultConfigSchema,
|
||||
azureVaultConfigSchema,
|
||||
scalewayVaultConfigSchema,
|
||||
phaseVaultConfigSchema,
|
||||
]);
|
||||
|
||||
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
|
||||
|
||||
@@ -35,7 +35,7 @@ export const statements = {
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "update", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
@@ -104,7 +104,7 @@ export const ownerRole = ac.newRole({
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "update", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
@@ -143,7 +143,7 @@ export const adminRole = ac.newRole({
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "update", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
|
||||
@@ -125,10 +125,24 @@ export const findComposeById = async (composeId: string) => {
|
||||
deployments: true,
|
||||
mounts: true,
|
||||
domains: true,
|
||||
github: true,
|
||||
gitlab: true,
|
||||
bitbucket: true,
|
||||
gitea: true,
|
||||
github: {
|
||||
columns: {
|
||||
githubClientSecret: false,
|
||||
githubPrivateKey: false,
|
||||
githubWebhookSecret: false,
|
||||
},
|
||||
},
|
||||
gitlab: {
|
||||
columns: { secret: false, accessToken: false, refreshToken: false },
|
||||
},
|
||||
bitbucket: { columns: { appPassword: false, apiToken: false } },
|
||||
gitea: {
|
||||
columns: {
|
||||
clientSecret: false,
|
||||
accessToken: false,
|
||||
refreshToken: false,
|
||||
},
|
||||
},
|
||||
server: true,
|
||||
backups: {
|
||||
with: {
|
||||
|
||||
@@ -17,6 +17,7 @@ export const DNS_SECRET_MASK = "********";
|
||||
const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
|
||||
cloudflare: ["apiToken"],
|
||||
route53: ["secretAccessKey"],
|
||||
porkbun: ["secretApiKey"],
|
||||
};
|
||||
|
||||
export const maskDnsProviderConfig = (
|
||||
|
||||
@@ -3,7 +3,9 @@ import { promisify } from "node:util";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||
import { generateRandomDomain } from "@dokploy/server/templates";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
|
||||
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
@@ -154,7 +156,7 @@ const resolveDns = promisify(dns.resolve4);
|
||||
|
||||
export const validateDomain = async (
|
||||
domain: string,
|
||||
expectedIp?: string,
|
||||
expectedIps?: string[],
|
||||
): Promise<{
|
||||
isValid: boolean;
|
||||
resolvedIp?: string;
|
||||
@@ -186,13 +188,13 @@ export const validateDomain = async (
|
||||
};
|
||||
}
|
||||
|
||||
// If we have an expected IP, validate against it
|
||||
if (expectedIp) {
|
||||
if (expectedIps && expectedIps.length > 0) {
|
||||
const isValid = resolvedIps.some((ip) => expectedIps.includes(ip));
|
||||
return {
|
||||
isValid: resolvedIps.includes(expectedIp),
|
||||
isValid,
|
||||
resolvedIp: resolvedIps.join(", "),
|
||||
error: !resolvedIps.includes(expectedIp)
|
||||
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIp}`
|
||||
error: !isValid
|
||||
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIps.join(" or ")}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -210,3 +212,47 @@ export const validateDomain = async (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getServerIpCandidates = async (
|
||||
serverId?: string | null,
|
||||
): Promise<string[]> => {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
if (serverId) {
|
||||
const server = await findServerById(serverId);
|
||||
if (server.ipAddress) {
|
||||
candidates.add(server.ipAddress);
|
||||
}
|
||||
|
||||
const publicIp = await withTimeout(
|
||||
execAsyncRemote(
|
||||
serverId,
|
||||
"curl -s -m 5 https://ifconfig.me || curl -s -m 5 https://icanhazip.com",
|
||||
),
|
||||
7000,
|
||||
);
|
||||
const detectedIp = publicIp?.stdout?.trim();
|
||||
if (detectedIp) {
|
||||
candidates.add(detectedIp);
|
||||
}
|
||||
} else {
|
||||
const settings = await getWebServerSettings();
|
||||
if (settings?.serverIp) {
|
||||
candidates.add(settings.serverIp);
|
||||
}
|
||||
|
||||
const publicIp = await withTimeout(getPublicIpWithFallback(), 7000);
|
||||
if (publicIp) {
|
||||
candidates.add(publicIp);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
};
|
||||
|
||||
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
|
||||
]).catch(() => null);
|
||||
};
|
||||
|
||||
@@ -124,8 +124,12 @@ export const getAccessibleGitProviderIds = async (session: {
|
||||
* Authorizes read access to a specific git provider for the current session.
|
||||
* Throws if the provider belongs to a different organization (cross-org IDOR)
|
||||
* or if the caller is not entitled to it within the active organization.
|
||||
* Must be called before returning any git-provider record that carries secrets
|
||||
* (OAuth tokens, app private keys, webhook secrets).
|
||||
*
|
||||
* This only proves the caller may *use* the provider (e.g. pick it as a repo
|
||||
* source when creating a deploy) - it does NOT mean they may see its raw
|
||||
* credentials. Being able to use a shared provider and being able to read its
|
||||
* OAuth tokens / client secrets / private keys are different privileges; gate
|
||||
* the latter with canViewGitProviderSecrets before returning secret fields.
|
||||
*/
|
||||
export const assertGitProviderAccess = async (
|
||||
session: { userId: string; activeOrganizationId: string },
|
||||
@@ -146,3 +150,24 @@ export const assertGitProviderAccess = async (
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Being allowed to use a shared provider (assertGitProviderAccess) must not
|
||||
// imply being allowed to read its raw OAuth tokens / client secrets / private
|
||||
// keys. Only the provider's owner or an org owner/admin gets those back.
|
||||
export const canViewGitProviderSecrets = async (
|
||||
session: { userId: string; activeOrganizationId: string },
|
||||
provider: { userId: string; organizationId: string },
|
||||
): Promise<boolean> => {
|
||||
if (provider.organizationId !== session.activeOrganizationId) return false;
|
||||
if (provider.userId === session.userId) return true;
|
||||
|
||||
const memberRecord = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.userId, session.userId),
|
||||
eq(member.organizationId, session.activeOrganizationId),
|
||||
),
|
||||
columns: { role: true },
|
||||
});
|
||||
|
||||
return memberRecord?.role === "owner" || memberRecord?.role === "admin";
|
||||
};
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { buildLibsql } from "@dokploy/server/utils/databases/libsql";
|
||||
import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import {
|
||||
pullImage,
|
||||
waitForSwarmServiceConvergence,
|
||||
} from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
@@ -149,6 +152,7 @@ export const deployLibsql = async (
|
||||
}
|
||||
|
||||
await buildLibsql(libsql);
|
||||
await waitForSwarmServiceConvergence(libsql.appName, libsql.serverId);
|
||||
await updateLibsqlById(libsqlId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { buildMariadb } from "@dokploy/server/utils/databases/mariadb";
|
||||
import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import {
|
||||
pullImage,
|
||||
waitForSwarmServiceConvergence,
|
||||
} from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
@@ -154,6 +157,7 @@ export const deployMariadb = async (
|
||||
}
|
||||
|
||||
await buildMariadb(mariadb);
|
||||
await waitForSwarmServiceConvergence(mariadb.appName, mariadb.serverId);
|
||||
await updateMariadbById(mariadbId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { buildMongo } from "@dokploy/server/utils/databases/mongo";
|
||||
import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import {
|
||||
pullImage,
|
||||
waitForSwarmServiceConvergence,
|
||||
} from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
@@ -169,6 +172,7 @@ export const deployMongo = async (
|
||||
}
|
||||
|
||||
await buildMongo(mongo);
|
||||
await waitForSwarmServiceConvergence(mongo.appName, mongo.serverId);
|
||||
await updateMongoById(mongoId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user