mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3638c7b35b | |||
| a0162ab566 | |||
| 314a31dc27 | |||
| 46c87a22b0 | |||
| 89c0e9ef67 | |||
| 8583b3c1a5 | |||
| 69a5615544 | |||
| a1230098c9 | |||
| eacb1440e0 | |||
| 7e38861985 | |||
| 056b6983db | |||
| f6b0700ea5 | |||
| 4325605578 | |||
| 5d11a045cc | |||
| d92de5a4a0 | |||
| 81051ca965 | |||
| 7b0bcee652 | |||
| 11e93dde38 | |||
| cbb3450b93 | |||
| a058d1f5c7 | |||
| 3835a6fe68 | |||
| 325c2a4180 | |||
| 51cb7d2287 | |||
| 081cf3fb80 | |||
| 2be9e1e3df | |||
| 3b0bdd8f74 | |||
| 6e79270423 | |||
| 9999f0a18e | |||
| 268ec1f397 | |||
| 018e56a7b4 | |||
| bb73c6e6dd | |||
| 15bc4b5dc5 | |||
| 30e8d9b86e | |||
| 5df820a740 | |||
| ba5af51347 | |||
| 6e1d020769 | |||
| deebf0f107 | |||
| 7047e4f199 | |||
| 069939e8f9 | |||
| b7e5fadf53 | |||
| 260af232e7 | |||
| 425d478a0b | |||
| 268fd7d771 | |||
| 4ba7972ffa | |||
| 2739690758 | |||
| d768adb6ad | |||
| 24d0cff060 | |||
| 29fcc74ca5 | |||
| 8f0702c4da | |||
| ec74035c70 | |||
| aee15efac7 | |||
| 97885ee44a | |||
| 73e4fdd757 | |||
| 0fc75840d2 | |||
| 79c127b03b | |||
| f2148040a3 | |||
| 8a37626636 | |||
| adcbd6fd8e | |||
| c578c18500 | |||
| 94ddbf8ba5 | |||
| 61c2b73f08 | |||
| 736a77e112 | |||
| f4bcc2e8a8 | |||
| 99e483c1a1 | |||
| 22c8a1658a | |||
| f0f0db4c7f | |||
| 8b868c66d6 | |||
| 366e44b75a | |||
| cb2db0d30a | |||
| 7ba3853bab | |||
| 8def9e933e | |||
| e9b51667e2 | |||
| 25370cac30 | |||
| 52c7db1f66 | |||
| 6d65a36aac | |||
| cbec72ed80 | |||
| 15fe3b21c9 | |||
| 92c5879438 | |||
| a94f8b96d3 | |||
| ae95485b74 | |||
| 86ea311714 | |||
| 7da96810a8 | |||
| 5f508163e5 | |||
| fc75b847b5 | |||
| b1abcb9e06 | |||
| d542972522 | |||
| e7c38d4c54 | |||
| a8f941b5d9 | |||
| c68fac55fd | |||
| efcad7bbf5 | |||
| a0566cdbd7 | |||
| 69598821ed |
+4
-1
@@ -43,4 +43,7 @@ yarn-error.log*
|
||||
*.pem
|
||||
|
||||
|
||||
.db
|
||||
.db
|
||||
|
||||
.playwright-*
|
||||
.credentials
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { Compose, ComposeSpecification } from "@dokploy/server";
|
||||
import {
|
||||
applyServiceNetworks,
|
||||
declareUsedNetworksInRoot,
|
||||
resolveServiceNetworks,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { beforeEach, expect, test, type vi } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const findManyMock = db.query.network.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
findManyMock.mockReset();
|
||||
findManyMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
const baseCompose = {
|
||||
serverId: null,
|
||||
isolatedDeployment: false,
|
||||
} as unknown as Compose;
|
||||
|
||||
const withServiceNetworks = (
|
||||
serviceNetworks: Compose["serviceNetworks"],
|
||||
): Compose => ({ ...baseCompose, serviceNetworks });
|
||||
|
||||
test("applyServiceNetworks: no-op when serviceNetworks is empty", async () => {
|
||||
const result = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
`) as ComposeSpecification;
|
||||
|
||||
const injected = await applyServiceNetworks(result, withServiceNetworks([]));
|
||||
|
||||
expect(injected.size).toBe(0);
|
||||
expect(result.services?.web?.networks).toBeUndefined();
|
||||
expect(findManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("applyServiceNetworks: injects assigned network by networkId", async () => {
|
||||
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
|
||||
|
||||
const result = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
`) as ComposeSpecification;
|
||||
|
||||
const injected = await applyServiceNetworks(
|
||||
result,
|
||||
withServiceNetworks([
|
||||
{
|
||||
serviceName: "web",
|
||||
networkIds: ["net-1"],
|
||||
detachDokployNetwork: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(injected.has("shared-net")).toBe(true);
|
||||
expect(result.services?.web?.networks).toContain("shared-net");
|
||||
});
|
||||
|
||||
test("applyServiceNetworks: detach removes dokploy-network and default", async () => {
|
||||
const result = parse(`
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
networks:
|
||||
- dokploy-network
|
||||
- default
|
||||
`) as ComposeSpecification;
|
||||
|
||||
const injected = await applyServiceNetworks(
|
||||
result,
|
||||
withServiceNetworks([
|
||||
{ serviceName: "db", networkIds: [], detachDokployNetwork: true },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(injected.size).toBe(0);
|
||||
expect(result.services?.db?.networks).not.toContain("dokploy-network");
|
||||
expect(result.services?.db?.networks).not.toContain("default");
|
||||
});
|
||||
|
||||
test("applyServiceNetworks: unknown networkId is skipped", async () => {
|
||||
findManyMock.mockResolvedValue([]);
|
||||
|
||||
const result = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
`) as ComposeSpecification;
|
||||
|
||||
const injected = await applyServiceNetworks(
|
||||
result,
|
||||
withServiceNetworks([
|
||||
{
|
||||
serviceName: "web",
|
||||
networkIds: ["missing"],
|
||||
detachDokployNetwork: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(injected.size).toBe(0);
|
||||
});
|
||||
|
||||
test("applyServiceNetworks: skips services that don't exist in the compose", async () => {
|
||||
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
|
||||
|
||||
const result = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
`) as ComposeSpecification;
|
||||
|
||||
const injected = await applyServiceNetworks(
|
||||
result,
|
||||
withServiceNetworks([
|
||||
{
|
||||
serviceName: "ghost",
|
||||
networkIds: ["net-1"],
|
||||
detachDokployNetwork: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(injected.size).toBe(0);
|
||||
expect(result.services?.web?.networks).toBeUndefined();
|
||||
});
|
||||
|
||||
test("declareUsedNetworksInRoot: declares dokploy-network only when used", () => {
|
||||
const used = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
networks:
|
||||
- dokploy-network
|
||||
`) as ComposeSpecification;
|
||||
declareUsedNetworksInRoot(used, new Set());
|
||||
expect(used.networks).toHaveProperty("dokploy-network");
|
||||
|
||||
const unused = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
networks:
|
||||
- default
|
||||
`) as ComposeSpecification;
|
||||
declareUsedNetworksInRoot(unused, new Set());
|
||||
expect(unused.networks ?? {}).not.toHaveProperty("dokploy-network");
|
||||
});
|
||||
|
||||
test("declareUsedNetworksInRoot: declares injected networks that are used", () => {
|
||||
const result = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
networks:
|
||||
- shared-net
|
||||
`) as ComposeSpecification;
|
||||
|
||||
declareUsedNetworksInRoot(result, new Set(["shared-net", "unused-net"]));
|
||||
|
||||
expect(result.networks).toHaveProperty("shared-net");
|
||||
expect(result.networks ?? {}).not.toHaveProperty("unused-net");
|
||||
});
|
||||
|
||||
test("resolveServiceNetworks: returns dokploy-network by default", async () => {
|
||||
const resolved = await resolveServiceNetworks({});
|
||||
expect(resolved).toEqual([{ Target: "dokploy-network" }]);
|
||||
expect(findManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("resolveServiceNetworks: omits dokploy-network when detached", async () => {
|
||||
const resolved = await resolveServiceNetworks({ detachDokployNetwork: true });
|
||||
expect(resolved).toEqual([]);
|
||||
});
|
||||
|
||||
test("resolveServiceNetworks: appends overlay networks by networkId", async () => {
|
||||
findManyMock.mockResolvedValue([{ name: "overlay-a" }]);
|
||||
|
||||
const resolved = await resolveServiceNetworks({ networkIds: ["net-a"] });
|
||||
|
||||
expect(resolved).toEqual([
|
||||
{ Target: "dokploy-network" },
|
||||
{ Target: "overlay-a" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolveServiceNetworks: networkSwarm override takes precedence", async () => {
|
||||
const override = [{ Target: "custom-net" }];
|
||||
const resolved = await resolveServiceNetworks({ networkSwarm: override });
|
||||
|
||||
expect(resolved).toBe(override);
|
||||
expect(findManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||
import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const createApplication = (
|
||||
overrides: Partial<ApplicationNested> = {},
|
||||
): ApplicationNested =>
|
||||
({
|
||||
appName: "test-app",
|
||||
buildType: "railpack",
|
||||
sourceType: "git",
|
||||
buildPath: "/",
|
||||
railpackVersion: "0.15.4",
|
||||
env: "TEST_VAR=one",
|
||||
cleanCache: false,
|
||||
environment: {
|
||||
project: {
|
||||
env: "",
|
||||
},
|
||||
env: "",
|
||||
},
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
|
||||
const getSecretsHash = (command: string) => {
|
||||
const match = command.match(/secrets-hash=([a-f0-9]{64})/);
|
||||
if (!match?.[1]) {
|
||||
throw new Error("secrets-hash build arg was not found");
|
||||
}
|
||||
|
||||
return match[1];
|
||||
};
|
||||
|
||||
describe("getRailpackCommand", () => {
|
||||
it("includes secrets-hash without clean cache", () => {
|
||||
const command = getRailpackCommand(createApplication());
|
||||
|
||||
expect(command).toContain("--build-arg secrets-hash=");
|
||||
expect(command).not.toContain("cache-key=");
|
||||
});
|
||||
|
||||
it("includes cache-key only when clean cache is enabled", () => {
|
||||
const command = getRailpackCommand(
|
||||
createApplication({
|
||||
cleanCache: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(command).toContain("--build-arg secrets-hash=");
|
||||
expect(command).toContain("--build-arg cache-key=");
|
||||
});
|
||||
|
||||
it("changes secrets-hash when an environment value changes", () => {
|
||||
const firstCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: "TEST_VAR=one",
|
||||
}),
|
||||
);
|
||||
const secondCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: "TEST_VAR=two",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getSecretsHash(firstCommand)).not.toEqual(
|
||||
getSecretsHash(secondCommand),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes secrets-hash when referenced project or environment values change", () => {
|
||||
const firstCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: [
|
||||
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
|
||||
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
|
||||
].join("\n"),
|
||||
environment: {
|
||||
project: {
|
||||
env: "SHARED_VALUE=one",
|
||||
},
|
||||
env: "SHARED_VALUE=alpha",
|
||||
},
|
||||
} as Partial<ApplicationNested>),
|
||||
);
|
||||
const secondCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: [
|
||||
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
|
||||
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
|
||||
].join("\n"),
|
||||
environment: {
|
||||
project: {
|
||||
env: "SHARED_VALUE=two",
|
||||
},
|
||||
env: "SHARED_VALUE=beta",
|
||||
},
|
||||
} as Partial<ApplicationNested>),
|
||||
);
|
||||
|
||||
expect(getSecretsHash(firstCommand)).not.toEqual(
|
||||
getSecretsHash(secondCommand),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,8 @@ const baseApp: ApplicationNested = {
|
||||
railpackVersion: "0.15.4",
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
networkIds: [],
|
||||
detachDokployNetwork: false,
|
||||
createEnvFile: true,
|
||||
bitbucketRepositorySlug: "",
|
||||
herokuVersion: "",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=staging
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
|
||||
`;
|
||||
|
||||
const environmentEnv = `
|
||||
NODE_ENV=production
|
||||
POSTGRES_HOST=postgres.internal
|
||||
POSTGRES_PORT=5432
|
||||
REDIS_URL=redis://redis.internal:6379
|
||||
`;
|
||||
|
||||
const serviceEnv = `
|
||||
NODE_ENV=\${{environment.NODE_ENV}}
|
||||
REDIS_URL=\${{environment.REDIS_URL}}
|
||||
PORT=3000
|
||||
`;
|
||||
|
||||
/**
|
||||
* A rollback replays the snapshot stored in `rollbacks.fullContext`, which keeps
|
||||
* the service env, the environment env and the project env captured at deploy time.
|
||||
*/
|
||||
const fullContext = {
|
||||
env: serviceEnv,
|
||||
environment: {
|
||||
env: environmentEnv,
|
||||
project: {
|
||||
env: projectEnv,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("prepareEnvironmentVariables for application rollback", () => {
|
||||
it("resolves environment variables from the rollback snapshot", () => {
|
||||
const result = prepareEnvironmentVariables(
|
||||
fullContext.env,
|
||||
fullContext.environment.project.env,
|
||||
fullContext.environment.env,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"NODE_ENV=production",
|
||||
"REDIS_URL=redis://redis.internal:6379",
|
||||
"PORT=3000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves project and environment variables together on rollback", () => {
|
||||
const rollbackEnv = `
|
||||
DATABASE_URL=\${{project.DATABASE_URL}}
|
||||
POSTGRES_URL=postgres://\${{environment.POSTGRES_HOST}}:\${{environment.POSTGRES_PORT}}/app
|
||||
ENVIRONMENT=\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = prepareEnvironmentVariables(
|
||||
rollbackEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
|
||||
"POSTGRES_URL=postgres://postgres.internal:5432/app",
|
||||
"ENVIRONMENT=staging",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when the environment env of the snapshot is not passed", () => {
|
||||
expect(() =>
|
||||
prepareEnvironmentVariables(fullContext.env, projectEnv),
|
||||
).toThrow("Invalid environment variable: environment.NODE_ENV");
|
||||
});
|
||||
|
||||
it("maintains precedence: service > environment > project on rollback", () => {
|
||||
const conflictingProjectEnv = `
|
||||
NODE_ENV=project
|
||||
API_URL=https://project.api.com
|
||||
`;
|
||||
|
||||
const conflictingEnvironmentEnv = `
|
||||
NODE_ENV=environment
|
||||
API_URL=https://environment.api.com
|
||||
`;
|
||||
|
||||
const rollbackEnv = `
|
||||
NODE_ENV=service
|
||||
PROJECT_API_URL=\${{project.API_URL}}
|
||||
ENVIRONMENT_API_URL=\${{environment.API_URL}}
|
||||
SELF_REFERENCE=\${{NODE_ENV}}
|
||||
`;
|
||||
|
||||
const result = prepareEnvironmentVariables(
|
||||
rollbackEnv,
|
||||
conflictingProjectEnv,
|
||||
conflictingEnvironmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"NODE_ENV=service",
|
||||
"PROJECT_API_URL=https://project.api.com",
|
||||
"ENVIRONMENT_API_URL=https://environment.api.com",
|
||||
"SELF_REFERENCE=service",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,28 @@ describe("processLogs", () => {
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should not throw when filtering by hostname and an entry has no RequestHost", () => {
|
||||
const entryWithoutRequestHost = sampleLogEntry.replace(
|
||||
/"RequestHost":"[^"]*",/,
|
||||
"",
|
||||
);
|
||||
|
||||
const mixedEntries = `${sampleLogEntry}\n${entryWithoutRequestHost}`;
|
||||
|
||||
expect(() =>
|
||||
parseRawConfig(mixedEntries, undefined, undefined, "traefik.me"),
|
||||
).not.toThrow();
|
||||
|
||||
const result = parseRawConfig(
|
||||
mixedEntries,
|
||||
undefined,
|
||||
undefined,
|
||||
"traefik.me",
|
||||
);
|
||||
expect(result.totalCount).toBe(1);
|
||||
expect(result.data[0]?.RequestHost).toBe("s222-umami-c381af.traefik.me");
|
||||
});
|
||||
|
||||
it("should filter out Dokploy dashboard requests", () => {
|
||||
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ const baseApp: ApplicationNested = {
|
||||
rollbackActive: false,
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
networkIds: [],
|
||||
detachDokployNetwork: false,
|
||||
createEnvFile: true,
|
||||
bitbucketRepositorySlug: "",
|
||||
herokuVersion: "",
|
||||
|
||||
@@ -185,7 +185,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className="max-w-[50vw]">
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
Template Information
|
||||
@@ -199,7 +199,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-y-auto pr-1">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code2 className="h-5 w-5 text-primary" />
|
||||
@@ -207,12 +207,14 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
Docker Compose
|
||||
</h3>
|
||||
</div>
|
||||
<CodeEditor
|
||||
language="yaml"
|
||||
value={templateInfo?.compose || ""}
|
||||
className="font-mono"
|
||||
readOnly
|
||||
/>
|
||||
<div className="max-h-[45vh] overflow-auto rounded-md border">
|
||||
<CodeEditor
|
||||
language="yaml"
|
||||
value={templateInfo?.compose || ""}
|
||||
className="font-mono"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -63,6 +63,9 @@ export const ShowDeployments = ({
|
||||
const [activeLog, setActiveLog] = useState<
|
||||
RouterOutputs["deployment"]["all"][number] | null
|
||||
>(null);
|
||||
const [removingDeploymentIds, setRemovingDeploymentIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const { data: deployments, isPending: isLoadingDeployments } =
|
||||
api.deployment.allByType.useQuery(
|
||||
{
|
||||
@@ -81,7 +84,7 @@ export const ShowDeployments = ({
|
||||
api.rollback.rollback.useMutation();
|
||||
const { mutateAsync: killProcess, isPending: isKillingProcess } =
|
||||
api.deployment.killProcess.useMutation();
|
||||
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
|
||||
const { mutateAsync: removeDeployment } =
|
||||
api.deployment.removeDeployment.useMutation();
|
||||
|
||||
// Cancel deployment mutations
|
||||
@@ -408,6 +411,11 @@ export const ShowDeployments = ({
|
||||
description="Are you sure you want to delete this deployment? This action cannot be undone."
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setRemovingDeploymentIds((deploymentIds) => {
|
||||
const nextDeploymentIds = new Set(deploymentIds);
|
||||
nextDeploymentIds.add(deployment.deploymentId);
|
||||
return nextDeploymentIds;
|
||||
});
|
||||
try {
|
||||
await removeDeployment({
|
||||
deploymentId: deployment.deploymentId,
|
||||
@@ -415,13 +423,25 @@ export const ShowDeployments = ({
|
||||
toast.success("Deployment deleted successfully");
|
||||
} catch (error) {
|
||||
toast.error("Error deleting deployment");
|
||||
} finally {
|
||||
setRemovingDeploymentIds((deploymentIds) => {
|
||||
const nextDeploymentIds = new Set(
|
||||
deploymentIds,
|
||||
);
|
||||
nextDeploymentIds.delete(
|
||||
deployment.deploymentId,
|
||||
);
|
||||
return nextDeploymentIds;
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isRemovingDeployment}
|
||||
isLoading={removingDeploymentIds.has(
|
||||
deployment.deploymentId,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
<Trash2 className="size-4" />
|
||||
|
||||
@@ -209,7 +209,9 @@ export const createColumns = ({
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{validationState?.error ? (
|
||||
{validationState?.isValid && validationState?.message ? (
|
||||
<p>{validationState.message}</p>
|
||||
) : validationState?.error ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium text-red-500">Error:</p>
|
||||
<p>{validationState.error}</p>
|
||||
|
||||
@@ -626,7 +626,10 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{validationState?.error ? (
|
||||
{validationState?.isValid &&
|
||||
validationState?.message ? (
|
||||
<p>{validationState.message}</p>
|
||||
) : validationState?.error ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium text-red-500">
|
||||
Error:
|
||||
|
||||
@@ -60,14 +60,16 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
const currentBuildArgs = form.watch("buildArgs");
|
||||
const currentBuildSecrets = form.watch("buildSecrets");
|
||||
const currentCreateEnvFile = form.watch("createEnvFile");
|
||||
const { isDirty } = form.formState;
|
||||
const hasChanges =
|
||||
currentEnv !== (data?.env || "") ||
|
||||
currentBuildArgs !== (data?.buildArgs || "") ||
|
||||
currentBuildSecrets !== (data?.buildSecrets || "") ||
|
||||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
|
||||
|
||||
// Skip reset while editing so background refetches don't wipe edits
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
if (data && !isDirty) {
|
||||
form.reset({
|
||||
env: data.env || "",
|
||||
buildArgs: data.buildArgs || "",
|
||||
@@ -75,7 +77,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
createEnvFile: data.createEnvFile ?? true,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
}, [data, isDirty, form]);
|
||||
|
||||
const onSubmit = async (formData: EnvironmentSchema) => {
|
||||
mutateAsync({
|
||||
@@ -87,6 +89,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Environments Added");
|
||||
form.reset(formData);
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
+9
-5
@@ -447,14 +447,18 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -502,14 +506,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -242,14 +242,18 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -298,14 +302,14 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -477,14 +477,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="size-3 cursor-pointer hover:text-destructive" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -531,14 +535,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+15
-7
@@ -438,8 +438,12 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -487,14 +491,18 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="size-3 cursor-pointer hover:text-destructive" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -543,14 +551,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -467,14 +467,18 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="size-3 cursor-pointer hover:text-destructive" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -521,14 +525,14 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
|
||||
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,7 +16,8 @@ import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface ShowIconSettingsProps {
|
||||
applicationId: string;
|
||||
serviceId: string;
|
||||
serviceType: "application" | "compose";
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
@@ -26,7 +27,8 @@ const svgToDataUrl = (icon: BundledIcon): string => {
|
||||
};
|
||||
|
||||
export const ShowIconSettings = ({
|
||||
applicationId,
|
||||
serviceId,
|
||||
serviceType,
|
||||
icon,
|
||||
}: ShowIconSettingsProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -48,6 +50,17 @@ export const ShowIconSettings = ({
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: updateApplication } =
|
||||
api.application.update.useMutation();
|
||||
const { mutateAsync: updateCompose } = api.compose.update.useMutation();
|
||||
|
||||
const updateIcon = async (newIcon: string | null) => {
|
||||
if (serviceType === "compose") {
|
||||
await updateCompose({ composeId: serviceId, icon: newIcon });
|
||||
await utils.compose.one.invalidate({ composeId: serviceId });
|
||||
} else {
|
||||
await updateApplication({ applicationId: serviceId, icon: newIcon });
|
||||
await utils.application.one.invalidate({ applicationId: serviceId });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -59,12 +72,8 @@ export const ShowIconSettings = ({
|
||||
const handleIconSelect = async (selectedIcon: BundledIcon) => {
|
||||
try {
|
||||
const dataUrl = svgToDataUrl(selectedIcon);
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
icon: dataUrl,
|
||||
});
|
||||
await updateIcon(dataUrl);
|
||||
toast.success("Icon saved successfully");
|
||||
await utils.application.one.invalidate({ applicationId });
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error("Error saving icon");
|
||||
@@ -73,12 +82,8 @@ export const ShowIconSettings = ({
|
||||
|
||||
const handleRemoveIcon = async () => {
|
||||
try {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
icon: null,
|
||||
});
|
||||
await updateIcon(null);
|
||||
toast.success("Icon removed");
|
||||
await utils.application.one.invalidate({ applicationId });
|
||||
} catch (_error) {
|
||||
toast.error("Error removing icon");
|
||||
}
|
||||
@@ -130,12 +135,8 @@ export const ShowIconSettings = ({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
icon: sanitizedDataUrl,
|
||||
});
|
||||
await updateIcon(sanitizedDataUrl);
|
||||
toast.success("Icon saved!");
|
||||
await utils.application.one.invalidate({ applicationId });
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error("Error saving icon");
|
||||
@@ -147,12 +148,8 @@ export const ShowIconSettings = ({
|
||||
reader.onload = async (event) => {
|
||||
const result = event.target?.result as string;
|
||||
try {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
icon: result,
|
||||
});
|
||||
await updateIcon(result);
|
||||
toast.success("Icon saved!");
|
||||
await utils.application.one.invalidate({ applicationId });
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error("Error saving icon");
|
||||
@@ -172,9 +169,11 @@ export const ShowIconSettings = ({
|
||||
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
|
||||
<img
|
||||
src={icon}
|
||||
alt="Application icon"
|
||||
alt="Service icon"
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
) : serviceType === "compose" ? (
|
||||
<CircuitBoard className="h-6 w-6 text-muted-foreground" />
|
||||
) : (
|
||||
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
@@ -58,8 +58,12 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
const handleRunManually = async (scheduleId: string) => {
|
||||
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
|
||||
try {
|
||||
await runManually({ scheduleId });
|
||||
toast.success("Schedule run successfully");
|
||||
const result = await runManually({ scheduleId });
|
||||
if (result.status === "error") {
|
||||
toast.error("Schedule run failed, check the deployment logs");
|
||||
} else {
|
||||
toast.success("Schedule run successfully");
|
||||
}
|
||||
await refetchSchedules();
|
||||
} catch {
|
||||
toast.error("Error running schedule");
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -111,7 +112,10 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Enable Isolated Deployment</CardTitle>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
Enable Isolated Deployment
|
||||
<Badge variant="yellow">Deprecated</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure isolated deployment to the compose file.
|
||||
<div className="text-sm text-muted-foreground flex flex-col gap-2">
|
||||
@@ -138,6 +142,11 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<AlertBlock type="warning">
|
||||
Isolated deployment is deprecated. Use the Networks section above to
|
||||
attach networks per service and detach them from dokploy-network —
|
||||
it is declarative and does not break on restarts.
|
||||
</AlertBlock>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
|
||||
@@ -84,19 +84,19 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload Compose"
|
||||
description="Are you sure you want to reload this compose?"
|
||||
title="Rebuild Compose"
|
||||
description="Are you sure you want to rebuild this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose reloaded successfully");
|
||||
toast.success("Compose rebuilt successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading compose");
|
||||
toast.error("Error rebuilding compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -109,12 +109,14 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-60">
|
||||
<p>Reload the compose without rebuilding it</p>
|
||||
<p>
|
||||
Rebuilds the compose without downloading the source code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
|
||||
+9
-5
@@ -451,14 +451,18 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -506,14 +510,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -251,14 +251,18 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -306,14 +310,14 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -442,14 +442,18 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -497,14 +501,14 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+15
-7
@@ -432,8 +432,12 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -479,14 +483,18 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -538,14 +546,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
+9
-5
@@ -468,14 +468,18 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove watch path"
|
||||
className="inline-flex items-center focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="ml-1 size-3 cursor-pointer" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -523,14 +527,14 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
||||
<FormLabel>Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
|
||||
Preview Compose
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-6xl max-h-200">
|
||||
<DialogContent className="sm:max-w-6xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Converted Compose</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -62,10 +62,6 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<AlertBlock type="info" className="mb-4">
|
||||
Preview your docker-compose file with added domains. Note: At least
|
||||
one domain must be specified for this conversion to take effect.
|
||||
</AlertBlock>
|
||||
{isPending ? (
|
||||
<div className="flex flex-row items-center justify-center min-h-100 border p-4 rounded-md">
|
||||
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
|
||||
@@ -79,7 +75,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-row gap-2 justify-end my-4">
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isPending}
|
||||
@@ -100,14 +96,15 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<pre>
|
||||
<div className="flex-1 min-h-0 overflow-auto rounded-md border">
|
||||
<CodeEditor
|
||||
value={compose || ""}
|
||||
language="yaml"
|
||||
readOnly
|
||||
height="50rem"
|
||||
height="100%"
|
||||
wrapperClassName="h-full"
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Check, ChevronsUpDown, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
const serviceSchema = z.object({
|
||||
serviceName: z.string(),
|
||||
networkIds: z.array(z.string()),
|
||||
detachDokployNetwork: z.boolean(),
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
services: z.array(serviceSchema),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const AssignComposeNetworks = ({ composeId }: Props) => {
|
||||
const [cacheType, setCacheType] = useState<"cache" | "fetch">("cache");
|
||||
|
||||
const { data: compose } = api.compose.one.useQuery({ composeId });
|
||||
const {
|
||||
data: services,
|
||||
isLoading: isLoadingServices,
|
||||
error: servicesError,
|
||||
refetch: refetchServices,
|
||||
isRefetching: isRefetchingServices,
|
||||
} = api.compose.loadServices.useQuery(
|
||||
{ composeId, type: cacheType },
|
||||
{ retry: false },
|
||||
);
|
||||
|
||||
const onRetry = () => {
|
||||
setCacheType("fetch");
|
||||
setTimeout(() => refetchServices(), 0);
|
||||
};
|
||||
|
||||
const { data: networks } = api.network.all.useQuery(
|
||||
{ serverId: compose?.serverId ?? undefined },
|
||||
{ enabled: compose !== undefined },
|
||||
);
|
||||
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { services: [] },
|
||||
});
|
||||
const { fields } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "services",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!services) return;
|
||||
const serviceNetworks = compose?.serviceNetworks ?? [];
|
||||
form.reset({
|
||||
services: services.map((serviceName) => {
|
||||
const config = serviceNetworks.find(
|
||||
(s) => s.serviceName === serviceName,
|
||||
);
|
||||
return {
|
||||
serviceName,
|
||||
networkIds: config?.networkIds ?? [],
|
||||
detachDokployNetwork: config?.detachDokployNetwork ?? false,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}, [services, compose?.serviceNetworks, form]);
|
||||
|
||||
const allowsBridge = compose?.composeType === "docker-compose";
|
||||
const availableNetworks = (networks ?? []).filter(
|
||||
(n) => allowsBridge || n.driver === "overlay",
|
||||
);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const serviceNetworks = values.services.filter(
|
||||
(s) => s.networkIds.length > 0 || s.detachDokployNetwork,
|
||||
);
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
serviceNetworks,
|
||||
});
|
||||
toast.success("Networks updated. Redeploy the compose to apply them.");
|
||||
await utils.compose.one.invalidate({ composeId });
|
||||
} catch (error) {
|
||||
toast.error("Error updating networks", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-start justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Networks</CardTitle>
|
||||
<CardDescription>
|
||||
Attach Docker networks per service and detach it from
|
||||
dokploy-network. Takes effect on the next deploy.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={isRefetchingServices}
|
||||
onClick={onRetry}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{servicesError ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<AlertBlock type="error">
|
||||
Could not load the compose services. If this compose was just
|
||||
created from a template, it hasn't been cloned yet — click Reload
|
||||
to clone the repository and read its services.
|
||||
</AlertBlock>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={isRefetchingServices}
|
||||
onClick={onRetry}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Reload services
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoadingServices ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<span>Loading services...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : !services?.length ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
No services found in this compose.
|
||||
</span>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{fields.map((fieldItem, index) => (
|
||||
<ServiceRow
|
||||
key={fieldItem.id}
|
||||
control={form.control}
|
||||
index={index}
|
||||
service={fieldItem.serviceName}
|
||||
availableNetworks={availableNetworks}
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.formState.isDirty}
|
||||
isLoading={isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
type NetworkOption = { networkId: string; name: string; driver: string };
|
||||
|
||||
const ServiceRow = ({
|
||||
control,
|
||||
index,
|
||||
service,
|
||||
availableNetworks,
|
||||
}: {
|
||||
control: ReturnType<typeof useForm<FormValues>>["control"];
|
||||
index: number;
|
||||
service: string;
|
||||
availableNetworks: NetworkOption[];
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`services.${index}.detachDokployNetwork`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between gap-3 space-y-0">
|
||||
<span className="text-sm font-medium">{service}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Detach dokploy-network
|
||||
</span>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name={`services.${index}.networkIds`}
|
||||
render={({ field }) => {
|
||||
const selectedNetworks = availableNetworks.filter((n) =>
|
||||
field.value.includes(n.networkId),
|
||||
);
|
||||
const toggle = (networkId: string) => {
|
||||
field.onChange(
|
||||
field.value.includes(networkId)
|
||||
? field.value.filter((id) => id !== networkId)
|
||||
: [...field.value, networkId],
|
||||
);
|
||||
};
|
||||
return (
|
||||
<FormItem className="space-y-3">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{selectedNetworks.length > 0
|
||||
? `${selectedNetworks.length} network${
|
||||
selectedNetworks.length > 1 ? "s" : ""
|
||||
} selected`
|
||||
: "Select networks..."}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search networks..."
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList className="max-h-60">
|
||||
{availableNetworks.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No networks available on this server.
|
||||
</div>
|
||||
) : (
|
||||
<CommandGroup>
|
||||
{availableNetworks.map((n) => {
|
||||
const isSelected = field.value.includes(
|
||||
n.networkId,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={n.networkId}
|
||||
value={n.name}
|
||||
onSelect={() => toggle(n.networkId)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className="mr-2"
|
||||
onCheckedChange={() => toggle(n.networkId)}
|
||||
/>
|
||||
<span>{n.name}</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{n.driver}
|
||||
</Badge>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto size-4",
|
||||
isSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{selectedNetworks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedNetworks.map((n) => (
|
||||
<Badge key={n.networkId} variant="secondary">
|
||||
{n.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
import { Check, ChevronsUpDown, X } from "lucide-react";
|
||||
import { useEffect, 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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type ServiceType =
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "libsql";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: ServiceType;
|
||||
}
|
||||
|
||||
export const AssignNetworks = ({ id, type }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [detached, setDetached] = useState(false);
|
||||
|
||||
const {
|
||||
service,
|
||||
serverId,
|
||||
networkIds,
|
||||
detachDokployNetwork,
|
||||
updateAsync,
|
||||
isUpdating,
|
||||
refetch,
|
||||
} = useServiceNetworks(id, type);
|
||||
|
||||
const { data: networks } = api.network.all.useQuery(
|
||||
{ serverId: serverId ?? undefined },
|
||||
{ enabled: service !== undefined },
|
||||
);
|
||||
|
||||
const { data: applicationDomains } = api.domain.byApplicationId.useQuery(
|
||||
{ applicationId: id },
|
||||
{ enabled: type === "application" },
|
||||
);
|
||||
const hasDomains = (applicationDomains?.length ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(networkIds ?? []);
|
||||
setDetached(detachDokployNetwork ?? false);
|
||||
}, [networkIds, detachDokployNetwork]);
|
||||
|
||||
const availableNetworks = (networks ?? []).filter(
|
||||
(n) => n.driver === "overlay",
|
||||
);
|
||||
const selectedNetworks = availableNetworks.filter((n) =>
|
||||
selected.includes(n.networkId),
|
||||
);
|
||||
const isDirty =
|
||||
selected.length !== (networkIds?.length ?? 0) ||
|
||||
selected.some((networkId) => !networkIds?.includes(networkId)) ||
|
||||
detached !== detachDokployNetwork;
|
||||
|
||||
const toggle = (networkId: string) => {
|
||||
setSelected((prev) =>
|
||||
prev.includes(networkId)
|
||||
? prev.filter((id) => id !== networkId)
|
||||
: [...prev, networkId],
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
await updateAsync({
|
||||
networkIds: selected,
|
||||
detachDokployNetwork: detached,
|
||||
});
|
||||
toast.success("Networks updated. Redeploy the service to apply them.");
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error("Error updating networks", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Networks</CardTitle>
|
||||
<CardDescription>
|
||||
Attach additional Docker networks to this service so it can reach
|
||||
services on those networks. Takes effect on the next deploy.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-start justify-between gap-3 rounded-lg border p-4">
|
||||
<div className="space-y-1 pr-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Detach from dokploy-network
|
||||
</span>
|
||||
<Badge variant="secondary">dokploy-network</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
By default the service joins the shared dokploy-network. Detach it
|
||||
to keep it reachable only through the networks you attach below.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={detached} onCheckedChange={setDetached} />
|
||||
</div>
|
||||
|
||||
{detached && hasDomains && (
|
||||
<AlertBlock type="warning">
|
||||
Warning: this service has domains. Detaching it from dokploy-network
|
||||
will break Traefik routing, and its domains will stop working.
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
{detached && !hasDomains && selected.length === 0 && (
|
||||
<AlertBlock type="warning">
|
||||
This service is detached from dokploy-network but has no other
|
||||
network attached. It would be unreachable, so dokploy-network will
|
||||
be kept until you attach a network below.
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{selectedNetworks.length > 0
|
||||
? `${selectedNetworks.length} network${
|
||||
selectedNetworks.length > 1 ? "s" : ""
|
||||
} selected`
|
||||
: "Select networks..."}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search networks..." className="h-9" />
|
||||
<CommandList className="max-h-60">
|
||||
{availableNetworks.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No overlay networks on this server.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty className="py-6 text-center text-sm text-muted-foreground">
|
||||
No networks found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableNetworks.map((n) => {
|
||||
const isSelected = selected.includes(n.networkId);
|
||||
return (
|
||||
<CommandItem
|
||||
key={n.networkId}
|
||||
value={n.name}
|
||||
onSelect={() => toggle(n.networkId)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className="mr-2"
|
||||
onCheckedChange={() => toggle(n.networkId)}
|
||||
/>
|
||||
<span>{n.name}</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{n.driver}
|
||||
</Badge>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto size-4",
|
||||
isSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{selectedNetworks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedNetworks.map((n) => (
|
||||
<Badge
|
||||
key={n.networkId}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 pr-1"
|
||||
>
|
||||
{n.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(n.networkId)}
|
||||
className="ml-0.5 rounded-full outline-hidden hover:opacity-70"
|
||||
>
|
||||
<X className="size-3" />
|
||||
<span className="sr-only">Remove {n.name}</span>
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button disabled={!isDirty} isLoading={isUpdating} onClick={onSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Maps a service type to its one-query and update-mutation, normalizing the
|
||||
// per-type id field name and the networkIds field.
|
||||
const useServiceNetworks = (id: string, type: ServiceType) => {
|
||||
const application = api.application.one.useQuery(
|
||||
{ applicationId: id },
|
||||
{ enabled: type === "application" },
|
||||
);
|
||||
const postgres = api.postgres.one.useQuery(
|
||||
{ postgresId: id },
|
||||
{ enabled: type === "postgres" },
|
||||
);
|
||||
const mysql = api.mysql.one.useQuery(
|
||||
{ mysqlId: id },
|
||||
{ enabled: type === "mysql" },
|
||||
);
|
||||
const mariadb = api.mariadb.one.useQuery(
|
||||
{ mariadbId: id },
|
||||
{ enabled: type === "mariadb" },
|
||||
);
|
||||
const mongo = api.mongo.one.useQuery(
|
||||
{ mongoId: id },
|
||||
{ enabled: type === "mongo" },
|
||||
);
|
||||
const redis = api.redis.one.useQuery(
|
||||
{ redisId: id },
|
||||
{ enabled: type === "redis" },
|
||||
);
|
||||
const libsql = api.libsql.one.useQuery(
|
||||
{ libsqlId: id },
|
||||
{ enabled: type === "libsql" },
|
||||
);
|
||||
const applicationUpdate = api.application.update.useMutation();
|
||||
const postgresUpdate = api.postgres.update.useMutation();
|
||||
const mysqlUpdate = api.mysql.update.useMutation();
|
||||
const mariadbUpdate = api.mariadb.update.useMutation();
|
||||
const mongoUpdate = api.mongo.update.useMutation();
|
||||
const redisUpdate = api.redis.update.useMutation();
|
||||
const libsqlUpdate = api.libsql.update.useMutation();
|
||||
|
||||
const map = {
|
||||
application: {
|
||||
query: application,
|
||||
mutation: applicationUpdate,
|
||||
save: (payload: SavePayload) =>
|
||||
applicationUpdate.mutateAsync({ applicationId: id, ...payload }),
|
||||
},
|
||||
postgres: {
|
||||
query: postgres,
|
||||
mutation: postgresUpdate,
|
||||
save: (payload: SavePayload) =>
|
||||
postgresUpdate.mutateAsync({ postgresId: id, ...payload }),
|
||||
},
|
||||
mysql: {
|
||||
query: mysql,
|
||||
mutation: mysqlUpdate,
|
||||
save: (payload: SavePayload) =>
|
||||
mysqlUpdate.mutateAsync({ mysqlId: id, ...payload }),
|
||||
},
|
||||
mariadb: {
|
||||
query: mariadb,
|
||||
mutation: mariadbUpdate,
|
||||
save: (payload: SavePayload) =>
|
||||
mariadbUpdate.mutateAsync({ mariadbId: id, ...payload }),
|
||||
},
|
||||
mongo: {
|
||||
query: mongo,
|
||||
mutation: mongoUpdate,
|
||||
save: (payload: SavePayload) =>
|
||||
mongoUpdate.mutateAsync({ mongoId: id, ...payload }),
|
||||
},
|
||||
redis: {
|
||||
query: redis,
|
||||
mutation: redisUpdate,
|
||||
save: (payload: SavePayload) =>
|
||||
redisUpdate.mutateAsync({ redisId: id, ...payload }),
|
||||
},
|
||||
libsql: {
|
||||
query: libsql,
|
||||
mutation: libsqlUpdate,
|
||||
save: (payload: SavePayload) =>
|
||||
libsqlUpdate.mutateAsync({ libsqlId: id, ...payload }),
|
||||
},
|
||||
}[type];
|
||||
|
||||
const service = map.query.data;
|
||||
|
||||
return {
|
||||
service,
|
||||
serverId: service?.serverId ?? null,
|
||||
networkIds: service?.networkIds ?? [],
|
||||
detachDokployNetwork: service?.detachDokployNetwork ?? false,
|
||||
updateAsync: map.save,
|
||||
isUpdating: map.mutation.isPending,
|
||||
refetch: map.query.refetch,
|
||||
};
|
||||
};
|
||||
|
||||
type SavePayload = {
|
||||
networkIds: string[];
|
||||
detachDokployNetwork: boolean;
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Network, Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
// Only bridge and overlay can be created: "host"/"none" are Docker
|
||||
// singletons and macvlan/ipvlan need driver options we don't expose.
|
||||
const networkDriverEnum = ["bridge", "overlay"] as const;
|
||||
|
||||
const ipamConfigEntrySchema = z.object({
|
||||
subnet: z.string().optional(),
|
||||
ipRange: z.string().optional(),
|
||||
gateway: z.string().optional(),
|
||||
});
|
||||
|
||||
const networkFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
driver: z.enum(networkDriverEnum),
|
||||
internal: z.boolean(),
|
||||
attachable: z.boolean(),
|
||||
enableIPv4: z.boolean(),
|
||||
enableIPv6: z.boolean(),
|
||||
mtu: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) =>
|
||||
value === "" ||
|
||||
(/^\d+$/.test(value) && +value >= 68 && +value <= 65535),
|
||||
{ message: "MTU must be a number between 68 and 65535" },
|
||||
),
|
||||
ipamDriver: z.string().optional(),
|
||||
ipamConfig: z.array(ipamConfigEntrySchema),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (!input.enableIPv4 && !input.enableIPv6) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["enableIPv4"],
|
||||
message: "IPv4 or IPv6 must be enabled",
|
||||
});
|
||||
}
|
||||
for (const [index, entry] of input.ipamConfig.entries()) {
|
||||
if (!entry.subnet && (entry.gateway || entry.ipRange)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["ipamConfig", index, "subnet"],
|
||||
message: "Gateway and IP range require a subnet",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type NetworkFormValues = z.infer<typeof networkFormSchema>;
|
||||
|
||||
const defaultValues: NetworkFormValues = {
|
||||
name: "",
|
||||
driver: "bridge",
|
||||
internal: false,
|
||||
attachable: false,
|
||||
enableIPv4: true,
|
||||
enableIPv6: false,
|
||||
mtu: "",
|
||||
ipamDriver: "",
|
||||
ipamConfig: [],
|
||||
};
|
||||
|
||||
const toggleOptions = [
|
||||
{
|
||||
name: "internal",
|
||||
label: "Internal",
|
||||
description: "Containers on this network cannot reach external networks.",
|
||||
},
|
||||
{
|
||||
name: "attachable",
|
||||
label: "Attachable",
|
||||
description:
|
||||
"Allow standalone containers to attach (overlay networks only).",
|
||||
},
|
||||
{
|
||||
name: "enableIPv4",
|
||||
label: "Enable IPv4",
|
||||
description: "Enable IPv4 addressing on the network.",
|
||||
},
|
||||
{
|
||||
name: "enableIPv6",
|
||||
label: "Enable IPv6",
|
||||
description: "Enable IPv6 addressing on the network.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface HandleNetworkProps {
|
||||
/** Target server; undefined creates on the local Dokploy server */
|
||||
serverId?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Docker networks are immutable, so this dialog only creates them;
|
||||
// changing a network means deleting and recreating it.
|
||||
export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isPending } = api.network.create.useMutation();
|
||||
|
||||
const form = useForm<NetworkFormValues>({
|
||||
resolver: zodResolver(networkFormSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const ipamConfigFieldArray = useFieldArray({
|
||||
control: form.control,
|
||||
name: "ipamConfig",
|
||||
});
|
||||
|
||||
const onSubmit = async (data: NetworkFormValues) => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
name: data.name,
|
||||
driver: data.driver,
|
||||
serverId,
|
||||
internal: data.internal,
|
||||
attachable: data.attachable,
|
||||
enableIPv4: data.enableIPv4,
|
||||
enableIPv6: data.enableIPv6,
|
||||
mtu: data.mtu ? Number(data.mtu) : undefined,
|
||||
ipam: {
|
||||
driver: data.ipamDriver || undefined,
|
||||
config: data.ipamConfig,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Network created");
|
||||
await utils.network.all.invalidate();
|
||||
setIsOpen(false);
|
||||
form.reset(defaultValues);
|
||||
} catch (error) {
|
||||
toast.error("Error creating network", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const trigger = children ?? (
|
||||
<Button>
|
||||
<Plus className=" size-4" />
|
||||
Add network
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Network className="size-5 text-muted-foreground" />
|
||||
Add network
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new Docker network for your organization. Networks are
|
||||
immutable: to change one, delete it and create it again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex w-full flex-col gap-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-network" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="driver"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Driver</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select driver" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{networkDriverEnum.map((d) => (
|
||||
<SelectItem key={d} value={d}>
|
||||
{d}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
bridge for single-server containers; overlay for Swarm
|
||||
services.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mtu"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>MTU (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="1500"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Maximum transmission unit. Leave empty to use Docker's
|
||||
default.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{toggleOptions.map((option) => (
|
||||
<FormField
|
||||
key={option.name}
|
||||
control={form.control}
|
||||
name={option.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start justify-between gap-3 space-y-0 rounded-lg border p-4">
|
||||
<div className="space-y-1 pr-1">
|
||||
<FormLabel>{option.label}</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
{option.description}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>IPAM</FormLabel>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
IP address management settings for this network.
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ipamDriver"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">
|
||||
Driver (optional)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="default" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-muted-foreground">
|
||||
Config (subnet / gateway / IP range)
|
||||
</FormLabel>
|
||||
{ipamConfigFieldArray.fields.map((field, index) => (
|
||||
<div key={field.id} className="flex flex-wrap gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ipamConfig.${index}.subnet`}
|
||||
render={({ field: f }) => (
|
||||
<FormItem className="min-w-[140px] flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...f}
|
||||
placeholder="Subnet (e.g. 172.20.0.0/16)"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ipamConfig.${index}.ipRange`}
|
||||
render={({ field: f }) => (
|
||||
<FormItem className="min-w-[120px] flex-1">
|
||||
<FormControl>
|
||||
<Input {...f} placeholder="IP range" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ipamConfig.${index}.gateway`}
|
||||
render={({ field: f }) => (
|
||||
<FormItem className="min-w-[120px] flex-1">
|
||||
<FormControl>
|
||||
<Input {...f} placeholder="Gateway" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Remove IPAM config"
|
||||
onClick={() => ipamConfigFieldArray.remove(index)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
ipamConfigFieldArray.append({
|
||||
subnet: "",
|
||||
ipRange: "",
|
||||
gateway: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add IPAM config
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
Create network
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
networkId: string;
|
||||
networkName: string;
|
||||
}
|
||||
|
||||
export const ShowNetworkConfig = ({ networkId, networkName }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data, isLoading, error } = api.network.inspect.useQuery(
|
||||
{ networkId },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="View network config">
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Network Config</DialogTitle>
|
||||
<DialogDescription>
|
||||
docker network inspect output for "{networkName}"
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<AlertBlock type="error">{error.message}</AlertBlock>
|
||||
) : isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
|
||||
<code>
|
||||
<pre className="whitespace-pre-wrap wrap-break-word">
|
||||
<CodeEditor
|
||||
language="json"
|
||||
lineWrapping
|
||||
lineNumbers={false}
|
||||
readOnly
|
||||
value={JSON.stringify(data, null, 2)}
|
||||
/>
|
||||
</pre>
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,494 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Loader2,
|
||||
Network,
|
||||
RotateCcw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { HandleNetwork } from "@/components/dashboard/networks/handle-network";
|
||||
import { ShowNetworkConfig } from "@/components/dashboard/networks/show-network-config";
|
||||
import { SyncNetworks } from "@/components/dashboard/networks/sync-networks";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { AppRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type NetworkRow = inferRouterOutputs<AppRouter>["network"]["all"][number];
|
||||
|
||||
interface Props {
|
||||
/** Selected server; undefined shows the local Dokploy server */
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
const getIpamEntries = (row: NetworkRow) =>
|
||||
(row.ipam?.config ?? []).filter((c) => c.subnet || c.gateway || c.ipRange);
|
||||
|
||||
const SortableHeader = ({
|
||||
column,
|
||||
title,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
title: string;
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{title}
|
||||
<ArrowUpDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const ShowNetworks = ({ serverId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "createdAt", desc: true },
|
||||
]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [driverFilter, setDriverFilter] = useState<string>("all");
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data: networks, isLoading } = api.network.all.useQuery({ serverId });
|
||||
const { mutateAsync: removeNetwork } = api.network.remove.useMutation();
|
||||
const recreateMutation = api.network.recreate.useMutation();
|
||||
|
||||
// Same query the Sync dialog uses; "missing" tells us which records
|
||||
// no longer have a real network in Docker
|
||||
const {
|
||||
data: syncStatus,
|
||||
isFetching: isVerifying,
|
||||
refetch: refetchVerify,
|
||||
} = api.network.networksToSync.useQuery({ serverId }, { enabled: verified });
|
||||
|
||||
const missingIds = useMemo(
|
||||
() => new Set(syncStatus?.missing.map((m) => m.networkId) ?? []),
|
||||
[syncStatus],
|
||||
);
|
||||
|
||||
const onVerify = async () => {
|
||||
setVerified(true);
|
||||
const { data: result, error } = await refetchVerify();
|
||||
if (error) {
|
||||
toast.error("Error verifying networks", {
|
||||
description: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!result) return;
|
||||
if (result.missing.length === 0) {
|
||||
toast.success("All networks exist in Docker");
|
||||
} else {
|
||||
toast.warning(
|
||||
`${result.missing.length} network(s) no longer exist in Docker`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let list = networks ?? [];
|
||||
if (driverFilter !== "all") {
|
||||
list = list.filter((n) => n.driver === driverFilter);
|
||||
}
|
||||
if (globalFilter.trim()) {
|
||||
const query = globalFilter.toLowerCase();
|
||||
list = list.filter(
|
||||
(n) =>
|
||||
n.name.toLowerCase().includes(query) ||
|
||||
(n.ipam?.config ?? []).some(
|
||||
(c) =>
|
||||
c.subnet?.toLowerCase().includes(query) ||
|
||||
c.gateway?.toLowerCase().includes(query) ||
|
||||
c.ipRange?.toLowerCase().includes(query),
|
||||
),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [networks, driverFilter, globalFilter]);
|
||||
|
||||
const columns = useMemo<ColumnDef<NetworkRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <SortableHeader column={column} title="Name" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{row.original.name}
|
||||
{verified &&
|
||||
syncStatus &&
|
||||
(missingIds.has(row.original.networkId) ? (
|
||||
<>
|
||||
<Badge variant="red">Missing in Docker</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
isLoading={recreateMutation.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await recreateMutation.mutateAsync({
|
||||
networkId: row.original.networkId,
|
||||
});
|
||||
toast.success(
|
||||
`Network "${row.original.name}" recreated in Docker`,
|
||||
);
|
||||
await utils.network.networksToSync.invalidate();
|
||||
} catch (error) {
|
||||
toast.error("Error recreating network", {
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Recreate
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="green">In sync</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "driver",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Driver" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{row.original.driver}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.driver === "overlay" ? "swarm" : "local"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "subnet",
|
||||
accessorFn: (row) => getIpamEntries(row)[0]?.subnet ?? "",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Subnet" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const ipamEntries = getIpamEntries(row.original);
|
||||
if (ipamEntries.length === 0) {
|
||||
return <span className="text-muted-foreground">Auto</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{ipamEntries.map((c, index) => (
|
||||
<div
|
||||
key={`${row.original.networkId}-ipam-${index}`}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<span>{c.subnet ?? "—"}</span>
|
||||
{(c.gateway || c.ipRange) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{[
|
||||
c.gateway && `gw ${c.gateway}`,
|
||||
c.ipRange && `range ${c.ipRange}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "internal",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Internal" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">
|
||||
{row.original.internal ? "Yes" : "No"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "attachable",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Attachable" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">
|
||||
{row.original.attachable ? "Yes" : "No"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground whitespace-nowrap">
|
||||
{new Date(row.original.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableSorting: false,
|
||||
header: () => <div className="text-right">Actions</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<ShowNetworkConfig
|
||||
networkId={row.original.networkId}
|
||||
networkName={row.original.name}
|
||||
/>
|
||||
<DialogAction
|
||||
title="Delete network"
|
||||
description={`The network "${row.original.name}" will be removed from Docker and Dokploy. This action cannot be undone.`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await removeNetwork({
|
||||
networkId: row.original.networkId,
|
||||
});
|
||||
toast.success("Network deleted");
|
||||
await utils.network.all.invalidate();
|
||||
await utils.network.networksToSync.invalidate();
|
||||
} catch (error) {
|
||||
toast.error("Error deleting network", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete network"
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[verified, syncStatus, missingIds, removeNetwork, recreateMutation, utils],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Network className="size-6 text-muted-foreground self-center" />
|
||||
Networks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage the Docker networks of the selected server.
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{networks && networks.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={isVerifying}
|
||||
onClick={onVerify}
|
||||
>
|
||||
<ShieldCheck className="size-4" />
|
||||
Verify
|
||||
</Button>
|
||||
)}
|
||||
<SyncNetworks serverId={serverId} />
|
||||
{networks && networks.length > 0 && (
|
||||
<HandleNetwork serverId={serverId} />
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 py-8 border-t">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : !networks?.length ? (
|
||||
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Network className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<p className="text-sm font-medium">No networks yet</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Create Docker networks for your organization and optionally
|
||||
attach them to a server. Add your first network to get
|
||||
started.
|
||||
</p>
|
||||
</div>
|
||||
<HandleNetwork serverId={serverId} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by name, subnet, gateway..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select value={driverFilter} onValueChange={setDriverFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Driver" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All drivers</SelectItem>
|
||||
<SelectItem value="bridge">bridge</SelectItem>
|
||||
<SelectItem value="overlay">overlay</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No networks match your filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{table.getPageCount() > 1 && (
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const SyncNetworks = ({ serverId }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data, isLoading, error, refetch } =
|
||||
api.network.networksToSync.useQuery({ serverId }, { enabled: open });
|
||||
|
||||
const importMutation = api.network.import.useMutation();
|
||||
const removeMutation = api.network.remove.useMutation();
|
||||
const recreateMutation = api.network.recreate.useMutation();
|
||||
|
||||
const toggleSelected = (name: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onImport = async () => {
|
||||
try {
|
||||
const result = await importMutation.mutateAsync({
|
||||
serverId,
|
||||
names: Array.from(selected),
|
||||
});
|
||||
|
||||
if (result.imported.length > 0) {
|
||||
toast.success(`Imported ${result.imported.length} network(s)`);
|
||||
}
|
||||
for (const failure of result.errors) {
|
||||
toast.error(`Could not import "${failure.name}"`, {
|
||||
description: failure.error,
|
||||
});
|
||||
}
|
||||
|
||||
setSelected(new Set());
|
||||
await utils.network.all.invalidate();
|
||||
|
||||
setOpen(false);
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error("Error importing networks", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveStale = async (networkId: string, name: string) => {
|
||||
try {
|
||||
await removeMutation.mutateAsync({ networkId });
|
||||
toast.success(`Removed stale record "${name}"`);
|
||||
await utils.network.all.invalidate();
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error("Error removing record", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRecreate = async (networkId: string, name: string) => {
|
||||
try {
|
||||
await recreateMutation.mutateAsync({ networkId });
|
||||
toast.success(`Network "${name}" recreated in Docker`);
|
||||
await utils.network.all.invalidate();
|
||||
await utils.network.networksToSync.invalidate();
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error("Error recreating network", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
setOpen(value);
|
||||
if (!value) setSelected(new Set());
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<RefreshCw className="size-4" />
|
||||
Sync
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RefreshCw className="size-5 text-muted-foreground" />
|
||||
Sync networks
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Import networks that exist in Docker but not in Dokploy, and clean
|
||||
up records whose network no longer exists.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error ? (
|
||||
<AlertBlock type="error">{error.message}</AlertBlock>
|
||||
) : isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<span>Scanning Docker networks...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Found in Docker ({data?.importable.length ?? 0})
|
||||
</span>
|
||||
{data?.importable.length ? (
|
||||
data.importable.map((dockerNetwork) => (
|
||||
<label
|
||||
key={dockerNetwork.name}
|
||||
htmlFor={`import-network-${dockerNetwork.name}`}
|
||||
className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`import-network-${dockerNetwork.name}`}
|
||||
checked={selected.has(dockerNetwork.name)}
|
||||
onCheckedChange={() =>
|
||||
toggleSelected(dockerNetwork.name)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{dockerNetwork.name}
|
||||
</span>
|
||||
{dockerNetwork.subnets.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dockerNetwork.subnets.join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">{dockerNetwork.driver}</Badge>
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Nothing to import — everything is in sync.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!data?.missing.length && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Missing in Docker ({data.missing.length})
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
These records exist in Dokploy but their network is gone
|
||||
from Docker.
|
||||
</span>
|
||||
{data.missing.map((stale) => (
|
||||
<div
|
||||
key={stale.networkId}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
|
||||
>
|
||||
<span className="text-sm">{stale.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
isLoading={recreateMutation.isPending}
|
||||
onClick={() =>
|
||||
onRecreate(stale.networkId, stale.name)
|
||||
}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Recreate
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove stale record ${stale.name}`}
|
||||
isLoading={removeMutation.isPending}
|
||||
onClick={() =>
|
||||
onRemoveStale(stale.networkId, stale.name)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={selected.size === 0}
|
||||
isLoading={importMutation.isPending}
|
||||
onClick={onImport}
|
||||
>
|
||||
Import selected ({selected.size})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -328,7 +328,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
open={!!selectedRow}
|
||||
onOpenChange={(_open) => setSelectedRow(undefined)}
|
||||
>
|
||||
<SheetContent className="sm:max-w-[740px] flex flex-col">
|
||||
<SheetContent className="w-full sm:max-w-[740px]! flex flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Request log</SheetTitle>
|
||||
<SheetDescription>
|
||||
|
||||
@@ -13,10 +13,14 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleAi } from "./handle-ai";
|
||||
import { HandleAiProviders } from "./handle-ai-providers";
|
||||
|
||||
export const AiForm = () => {
|
||||
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
||||
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
const isOrgAdmin =
|
||||
currentUser?.role === "owner" || currentUser?.role === "admin";
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -30,7 +34,10 @@ export const AiForm = () => {
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your AI configurations</CardDescription>
|
||||
</div>
|
||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||
<div className="flex flex-row gap-2">
|
||||
{isOrgAdmin && <HandleAiProviders />}
|
||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isPending ? (
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PlusIcon, ServerIcon, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const Schema = z.object({
|
||||
providers: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
export const HandleAiProviders = () => {
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: providers } = api.ai.getCustomProviders.useQuery();
|
||||
const { mutateAsync, isPending } = api.ai.saveCustomProviders.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: {
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "providers",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ providers: providers ?? [] });
|
||||
}
|
||||
}, [open, providers, form]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
try {
|
||||
await mutateAsync({ providers: data.providers });
|
||||
await utils.ai.getCustomProviders.invalidate();
|
||||
toast.success("Custom providers saved successfully");
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error("Failed to save custom providers", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="cursor-pointer space-x-3">
|
||||
<ServerIcon className="h-4 w-4" />
|
||||
Custom Presets
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Custom AI Providers</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define your own AI providers, like an internal LLM platform. When at
|
||||
least one is defined, only these providers can be used in AI
|
||||
configurations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No custom providers defined. The built-in provider list will be
|
||||
used.
|
||||
</p>
|
||||
)}
|
||||
{fields.map((fieldItem, index) => (
|
||||
<div key={fieldItem.id} className="flex gap-2 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`providers.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Internal LLM" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`providers.${index}.apiUrl`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-[2]">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://llm.internal.company/v1"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => append({ name: "", apiUrl: "" })}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -99,6 +99,11 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
enabled: !!aiId,
|
||||
},
|
||||
);
|
||||
const { data: customProviders } = api.ai.getCustomProviders.useQuery();
|
||||
const hasCustomProviders = (customProviders?.length ?? 0) > 0;
|
||||
const providerOptions: { name: string; apiUrl: string }[] = hasCustomProviders
|
||||
? (customProviders ?? [])
|
||||
: [...AI_PROVIDERS];
|
||||
const { mutateAsync, isPending } = aiId
|
||||
? api.ai.update.useMutation()
|
||||
: api.ai.create.useMutation();
|
||||
@@ -210,7 +215,9 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
|
||||
const provider = providerOptions.find(
|
||||
(p) => p.apiUrl === value,
|
||||
);
|
||||
if (provider) {
|
||||
form.setValue("name", provider.name);
|
||||
form.setValue("apiUrl", provider.apiUrl);
|
||||
@@ -222,15 +229,20 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<SelectValue placeholder="Select a provider preset..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AI_PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
|
||||
{providerOptions.map((provider) => (
|
||||
<SelectItem
|
||||
key={`${provider.name}-${provider.apiUrl}`}
|
||||
value={provider.apiUrl}
|
||||
>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Quick-fill provider name and URL, or configure manually below
|
||||
{hasCustomProviders
|
||||
? "Select one of the providers defined by your organization"
|
||||
: "Quick-fill provider name and URL, or configure manually below"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -260,6 +272,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.openai.com/v1"
|
||||
disabled={hasCustomProviders}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
@@ -271,7 +284,9 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The base URL for your AI provider's API
|
||||
{hasCustomProviders
|
||||
? "The API URL is defined by your organization's providers"
|
||||
: "The base URL for your AI provider's API"}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -100,7 +100,7 @@ export const Enable2FA = () => {
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
|
||||
if (result.error.code === "INVALID_CODE") {
|
||||
toast.error("Invalid verification code");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Fingerprint, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const ManagePasskeys = () => {
|
||||
const utils = api.useUtils();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const { data: passkeys, isLoading } = api.user.listPasskeys.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: isDialogOpen,
|
||||
},
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const handleAddPasskey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsAdding(true);
|
||||
try {
|
||||
const result = await authClient.passkey.addPasskey({
|
||||
name: name.trim() || undefined,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Passkey added successfully");
|
||||
setName("");
|
||||
utils.user.listPasskeys.invalidate();
|
||||
} catch {
|
||||
toast.error("Failed to add passkey");
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePasskey = async (id: string) => {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
const result = await authClient.passkey.deletePasskey({ id });
|
||||
|
||||
if (result?.error) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Passkey removed");
|
||||
utils.user.listPasskeys.invalidate();
|
||||
} catch {
|
||||
toast.error("Failed to remove passkey");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">
|
||||
<Fingerprint className="size-4 text-muted-foreground" />
|
||||
Passkeys
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Passkeys</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sign in without a password using your device's biometrics, security
|
||||
key, or password manager.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[10vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : passkeys && passkeys.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{passkeys.map((passkey) => (
|
||||
<div
|
||||
key={passkey.id}
|
||||
className="flex items-center justify-between gap-2 p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
<Fingerprint className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">
|
||||
{passkey.name || "Unnamed passkey"}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{passkey.deviceType === "singleDevice"
|
||||
? "Device"
|
||||
: "Synced"}
|
||||
</Badge>
|
||||
</span>
|
||||
{passkey.createdAt && (
|
||||
<DateTooltip
|
||||
date={passkey.createdAt.toString()}
|
||||
className="text-sm"
|
||||
>
|
||||
Added
|
||||
</DateTooltip>
|
||||
)}
|
||||
</div>
|
||||
<DialogAction
|
||||
title="Remove this passkey?"
|
||||
description="You will no longer be able to sign in with it. This action cannot be undone."
|
||||
onClick={() => handleDeletePasskey(passkey.id)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
isLoading={deletingId === passkey.id}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-sm text-muted-foreground border rounded-lg">
|
||||
<Fingerprint className="size-6" />
|
||||
<span>No passkeys registered yet</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddPasskey} className="flex flex-col gap-2">
|
||||
<Label htmlFor="passkey-name">Passkey Name</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="passkey-name"
|
||||
placeholder="e.g. MacBook Touch ID"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" isLoading={isAdding}>
|
||||
<Plus className="size-4" />
|
||||
Add Passkey
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -31,6 +31,7 @@ import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { Configure2FA } from "./configure-2fa";
|
||||
import { Enable2FA } from "./enable-2fa";
|
||||
import { ManagePasskeys } from "./manage-passkeys";
|
||||
|
||||
const profileSchema = z.object({
|
||||
email: z
|
||||
@@ -162,7 +163,10 @@ export const ProfileForm = () => {
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
|
||||
<div className="flex flex-row gap-2 flex-wrap">
|
||||
<ManagePasskeys />
|
||||
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
|
||||
@@ -19,8 +19,6 @@ export const ShowDokployActions = () => {
|
||||
const { mutateAsync: reloadServer, isPending } =
|
||||
api.settings.reloadServer.useMutation();
|
||||
|
||||
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
|
||||
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
|
||||
const { mutateAsync: cleanAllDeploymentQueue } =
|
||||
api.settings.cleanAllDeploymentQueue.useMutation();
|
||||
|
||||
@@ -70,21 +68,6 @@ export const ShowDokployActions = () => {
|
||||
</DropdownMenuItem>
|
||||
</UpdateServerIp>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
await cleanRedis()
|
||||
.then(async () => {
|
||||
toast.success("Redis cleaned");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error cleaning Redis");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clean Redis
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
@@ -99,21 +82,6 @@ export const ShowDokployActions = () => {
|
||||
>
|
||||
Clean all deployment queue
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
await reloadRedis()
|
||||
.then(async () => {
|
||||
toast.success("Redis reloaded");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Redis");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reload Redis
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -30,7 +30,6 @@ type ServiceStatus = {
|
||||
|
||||
type HealthResult = {
|
||||
postgres: ServiceStatus;
|
||||
redis: ServiceStatus;
|
||||
traefik: ServiceStatus;
|
||||
};
|
||||
|
||||
@@ -89,7 +88,6 @@ export const UpdateWebServer = ({
|
||||
const allHealthy =
|
||||
healthResult &&
|
||||
healthResult.postgres.status === "healthy" &&
|
||||
healthResult.redis.status === "healthy" &&
|
||||
healthResult.traefik.status === "healthy";
|
||||
|
||||
const checkIsUpdateFinished = async () => {
|
||||
@@ -179,7 +177,7 @@ export const UpdateWebServer = ({
|
||||
{modalState === "checking" && (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="animate-spin h-4 w-4" />
|
||||
Checking PostgreSQL, Redis and Traefik...
|
||||
Checking PostgreSQL and Traefik...
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -190,10 +188,6 @@ export const UpdateWebServer = ({
|
||||
name="PostgreSQL"
|
||||
service={healthResult.postgres}
|
||||
/>
|
||||
<ServiceStatusItem
|
||||
name="Redis"
|
||||
service={healthResult.redis}
|
||||
/>
|
||||
<ServiceStatusItem
|
||||
name="Traefik"
|
||||
service={healthResult.traefik}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
|
||||
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
||||
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
|
||||
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
|
||||
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
|
||||
import { RebuildDatabase } from "./rebuild-database";
|
||||
@@ -21,6 +22,7 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
|
||||
<ShowClusterSettings id={id} type={type} />
|
||||
) : null}
|
||||
<ShowVolumes id={id} type={type} />
|
||||
<AssignNetworks id={id} type={type} />
|
||||
<ShowResources id={id} type={type} />
|
||||
<RebuildDatabase id={id} type={type} />
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Loader2,
|
||||
LogIn,
|
||||
type LucideIcon,
|
||||
Network,
|
||||
Package,
|
||||
Palette,
|
||||
PieChart,
|
||||
@@ -209,6 +210,20 @@ const MENU: Menu = {
|
||||
// Only enabled for users with access to Docker
|
||||
isEnabled: ({ permissions }) => !!permissions?.docker.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Networks",
|
||||
url: "/dashboard/networks",
|
||||
icon: Network,
|
||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!(
|
||||
(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canAccessToDocker) &&
|
||||
!isCloud
|
||||
),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Requests",
|
||||
|
||||
@@ -95,6 +95,7 @@ const RESOURCE_LABELS: Record<string, string> = {
|
||||
notification: "Notification",
|
||||
settings: "Settings",
|
||||
session: "Session",
|
||||
network: "Network",
|
||||
};
|
||||
|
||||
function MetadataCell({ metadata }: { metadata: string | null }) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export const DrawerLogs = ({ isOpen, onClose, filteredLogs }: Props) => {
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent className="sm:max-w-[740px] flex flex-col">
|
||||
<SheetContent className="w-full sm:max-w-[740px]! flex flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Deployment Logs</SheetTitle>
|
||||
<SheetDescription>Details of the request log entry.</SheetDescription>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
"group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg:not(.cursor-pointer)]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
|
||||
defaultOpen ?? false,
|
||||
);
|
||||
const isControlled = open !== undefined;
|
||||
const isOpen = isControlled ? open : uncontrolledOpen;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root
|
||||
data-slot="dropdown-menu"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
open={isOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
// Radix closes menus on window blur, unmounting any dialog rendered inside
|
||||
if (!nextOpen && !document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
if (!isControlled) {
|
||||
setUncontrolledOpen(nextOpen);
|
||||
}
|
||||
if (!nextOpen) {
|
||||
markNestedPopupClosed();
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
onOpenChange?.(nextOpen);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -464,7 +464,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -473,8 +473,8 @@ const sidebarMenuButtonVariants = cva(
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
default: "h-8 text-sm group-data-[collapsible=icon]:p-2!",
|
||||
sm: "h-7 text-xs group-data-[collapsible=icon]:p-2!",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-[80px] w-full rounded-lg border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
"flex field-sizing-content min-h-[80px] w-full [overflow-wrap:anywhere] rounded-lg border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TYPE "public"."networkDriver" AS ENUM('bridge', 'overlay');--> statement-breakpoint
|
||||
CREATE TABLE "network" (
|
||||
"networkId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"driver" "networkDriver" DEFAULT 'bridge' NOT NULL,
|
||||
"internal" boolean DEFAULT false NOT NULL,
|
||||
"attachable" boolean DEFAULT false NOT NULL,
|
||||
"enableIPv4" boolean DEFAULT true NOT NULL,
|
||||
"enableIPv6" boolean DEFAULT false NOT NULL,
|
||||
"ipam" jsonb DEFAULT '{}'::jsonb,
|
||||
"createdAt" text NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
"serverId" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "compose" ADD COLUMN "serviceNetworks" jsonb DEFAULT '[]'::jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "libsql" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "libsql" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "mariadb" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "mariadb" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "mongo" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "mongo" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "mysql" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "mysql" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "postgres" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "postgres" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "redis" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "redis" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "network" ADD CONSTRAINT "network_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "network" ADD CONSTRAINT "network_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "compose" ADD COLUMN "icon" text;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "passkey" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text,
|
||||
"public_key" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"credential_id" text NOT NULL,
|
||||
"counter" integer NOT NULL,
|
||||
"device_type" text NOT NULL,
|
||||
"backed_up" boolean NOT NULL,
|
||||
"transports" text,
|
||||
"created_at" timestamp,
|
||||
"aaguid" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "passkey_credentialID_idx" ON "passkey" USING btree ("credential_id");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "network" ADD COLUMN "mtu" integer;
|
||||
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
@@ -1226,6 +1226,34 @@
|
||||
"when": 1783674181297,
|
||||
"tag": "0174_great_naoko",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 175,
|
||||
"version": "7",
|
||||
"when": 1784965709630,
|
||||
"tag": "0175_fantastic_peter_quill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 176,
|
||||
"version": "7",
|
||||
"when": 1785274179336,
|
||||
"tag": "0176_slimy_marten_broadcloak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 177,
|
||||
"version": "7",
|
||||
"when": 1785709848209,
|
||||
"tag": "0177_glossy_brother_voodoo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 178,
|
||||
"version": "7",
|
||||
"when": 1785751631838,
|
||||
"tag": "0178_silky_randall",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiKeyClient } from "@better-auth/api-key/client";
|
||||
import { passkeyClient } from "@better-auth/passkey/client";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import {
|
||||
adminClient,
|
||||
@@ -13,6 +14,7 @@ export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
organizationClient(),
|
||||
twoFactorClient(),
|
||||
passkeyClient(),
|
||||
apiKeyClient(),
|
||||
ssoClient(),
|
||||
adminClient(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.29.12",
|
||||
"version": "v0.29.13",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -48,6 +48,7 @@
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/api-key": "1.6.23",
|
||||
"@better-auth/passkey": "1.6.23",
|
||||
"@better-auth/scim": "1.6.23",
|
||||
"@better-auth/sso": "1.6.23",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
@@ -86,7 +87,6 @@
|
||||
"better-auth": "1.6.23",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.67.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^0.2.1",
|
||||
|
||||
@@ -5,12 +5,11 @@ import { buttonVariants } from "@/components/ui/button";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
interface Props {
|
||||
statusCode: number;
|
||||
error?: Error;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export default function Custom404({ statusCode, error }: Props) {
|
||||
const displayStatusCode = statusCode || 400;
|
||||
export default function ErrorPage({ statusCode }: Props) {
|
||||
const displayStatusCode = statusCode || 500;
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const logoUrl = whitelabeling?.logoUrl || undefined;
|
||||
@@ -45,12 +44,6 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
{errorDescription}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-3 text-red-500">
|
||||
<p>{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
|
||||
<Link
|
||||
href="/dashboard/home"
|
||||
@@ -101,8 +94,7 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
Error.getInitialProps = ({ res, err }: NextPageContext) => {
|
||||
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
|
||||
return { statusCode, error: err };
|
||||
ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => {
|
||||
const statusCode = res ? res.statusCode : err ? (err.statusCode ?? 500) : 404;
|
||||
return { statusCode };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { ServerFilter } from "@/components/shared/server-filter";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<ServerFilter>
|
||||
{(serverId) => <ShowNetworks serverId={serverId} />}
|
||||
</ServerFilter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
Dashboard.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { req } = ctx;
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: ctx.res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
try {
|
||||
await helpers.project.all.prefetch();
|
||||
|
||||
if (user.role === "member") {
|
||||
const userR = await helpers.user.one.fetch({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!userR?.canAccessToDocker) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await helpers.network.all.prefetch({});
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -259,6 +259,7 @@ export const extractServicesFromEnvironment = (
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
lastDeployDate,
|
||||
icon: item.icon || null,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
@@ -1701,9 +1702,17 @@ const EnvironmentPage = (
|
||||
) : (
|
||||
<GlobeIcon className="h-6 w-6" />
|
||||
))}
|
||||
{service.type === "compose" && (
|
||||
<CircuitBoard className="h-6 w-6" />
|
||||
)}
|
||||
{service.type === "compose" &&
|
||||
(service.icon ? (
|
||||
// biome-ignore lint/performance/noImgElement: compose icon is data URL
|
||||
<img
|
||||
src={service.icon}
|
||||
alt={service.name}
|
||||
className="size-7 object-contain rounded-sm"
|
||||
/>
|
||||
) : (
|
||||
<CircuitBoard className="h-6 w-6" />
|
||||
))}
|
||||
{service.type === "libsql" && (
|
||||
<LibsqlIcon className="h-6 w-6" />
|
||||
)}
|
||||
|
||||
+4
-1
@@ -35,6 +35,7 @@ import { ShowVolumeBackups } from "@/components/dashboard/application/volume-bac
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
@@ -126,7 +127,8 @@ const Service = (
|
||||
<CardTitle className="text-xl flex flex-row gap-2 items-center">
|
||||
<div className="relative flex flex-row gap-4 items-center">
|
||||
<ShowIconSettings
|
||||
applicationId={applicationId}
|
||||
serviceId={applicationId}
|
||||
serviceType="application"
|
||||
icon={data?.icon}
|
||||
/>
|
||||
<div className="absolute -right-1 -top-2 z-10">
|
||||
@@ -418,6 +420,7 @@ const Service = (
|
||||
<ShowBuildServer applicationId={applicationId} />
|
||||
<ShowResources id={applicationId} type="application" />
|
||||
<ShowVolumes id={applicationId} type="application" />
|
||||
<AssignNetworks id={applicationId} type="application" />
|
||||
<ShowRedirects applicationId={applicationId} />
|
||||
<ShowSecurity applicationId={applicationId} />
|
||||
<ShowPorts applicationId={applicationId} />
|
||||
|
||||
+12
-6
@@ -1,7 +1,7 @@
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CircuitBoard, HelpCircle, ServerOff } from "lucide-react";
|
||||
import { HelpCircle, ServerOff } from "lucide-react";
|
||||
import type {
|
||||
GetServerSidePropsContext,
|
||||
InferGetServerSidePropsType,
|
||||
@@ -17,6 +17,7 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
|
||||
import { ShowDeployments } from "@/components/dashboard/application/deployments/show-deployments";
|
||||
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||
import { ShowIconSettings } from "@/components/dashboard/application/icon/show-icon-settings";
|
||||
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
|
||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||
@@ -31,6 +32,7 @@ import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
|
||||
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
|
||||
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
|
||||
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
|
||||
import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
@@ -112,13 +114,16 @@ const Service = (
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<CardTitle className="text-xl flex flex-row gap-2 items-center">
|
||||
<div className="relative flex flex-row gap-4 items-center">
|
||||
<ShowIconSettings
|
||||
serviceId={composeId}
|
||||
serviceType="compose"
|
||||
icon={data?.icon}
|
||||
/>
|
||||
<div className="absolute -right-1 -top-2 z-10">
|
||||
<StatusTooltip status={data?.composeStatus} />
|
||||
</div>
|
||||
|
||||
<CircuitBoard className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
</CardTitle>
|
||||
@@ -427,6 +432,7 @@ const Service = (
|
||||
<AddCommandCompose composeId={composeId} />
|
||||
<ShowVolumes id={composeId} type="compose" />
|
||||
<ShowImport composeId={composeId} />
|
||||
<AssignComposeNetworks composeId={composeId} />
|
||||
<IsolatedDeploymentTab composeId={composeId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ const Libsql = (
|
||||
router.push(newPath, undefined, { shallow: true });
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||
|
||||
+1
-1
@@ -199,7 +199,7 @@ const Mariadb = (
|
||||
router.push(newPath, undefined, { shallow: true });
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||
|
||||
+1
-1
@@ -199,7 +199,7 @@ const Mongo = (
|
||||
router.push(newPath, undefined, { shallow: true });
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||
|
||||
+1
-1
@@ -199,7 +199,7 @@ const MySql = (
|
||||
router.push(newPath, undefined, { shallow: true });
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start ",
|
||||
|
||||
+1
-1
@@ -200,7 +200,7 @@ const Postgresql = (
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||
|
||||
+1
-1
@@ -198,7 +198,7 @@ const Redis = (
|
||||
router.push(newPath, undefined, { shallow: true });
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import { Fingerprint } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -68,6 +69,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
||||
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
||||
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
|
||||
const [isTwoFactor, setIsTwoFactor] = useState(false);
|
||||
@@ -123,6 +125,34 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
setIsLoginLoading(false);
|
||||
}
|
||||
};
|
||||
const onPasskeySignIn = async () => {
|
||||
setIsPasskeyLoading(true);
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.passkey();
|
||||
|
||||
if (error) {
|
||||
const errorCode = "code" in error ? error.code : undefined;
|
||||
if (
|
||||
errorCode !== "AUTH_CANCELLED" &&
|
||||
errorCode !== "ERROR_CEREMONY_ABORTED"
|
||||
) {
|
||||
toast.error(error.message || "Failed to sign in with passkey");
|
||||
setError(error.message || "Failed to sign in with passkey");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/home");
|
||||
}
|
||||
} catch {
|
||||
toast.error("An error occurred while signing in with passkey");
|
||||
} finally {
|
||||
setIsPasskeyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onTwoFactorSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (twoFactorCode.length !== 6) {
|
||||
@@ -227,6 +257,16 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-4"
|
||||
type="button"
|
||||
onClick={onPasskeySignIn}
|
||||
isLoading={isPasskeyLoading}
|
||||
>
|
||||
<Fingerprint className="size-4" />
|
||||
Sign in with Passkey
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { mariadbRouter } from "./routers/mariadb";
|
||||
import { mongoRouter } from "./routers/mongo";
|
||||
import { mountRouter } from "./routers/mount";
|
||||
import { mysqlRouter } from "./routers/mysql";
|
||||
import { networkRouter } from "./routers/network";
|
||||
import { notificationRouter } from "./routers/notification";
|
||||
import { organizationRouter } from "./routers/organization";
|
||||
import { patchRouter } from "./routers/patch";
|
||||
@@ -60,6 +61,7 @@ export const appRouter = createTRPCRouter({
|
||||
application: applicationRouter,
|
||||
backup: backupRouter,
|
||||
bitbucket: bitbucketRouter,
|
||||
network: networkRouter,
|
||||
certificates: certificateRouter,
|
||||
cluster: clusterRouter,
|
||||
compose: composeRouter,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import {
|
||||
apiCreateAi,
|
||||
apiSaveAiCustomProviders,
|
||||
apiUpdateAi,
|
||||
deploySuggestionSchema,
|
||||
} from "@dokploy/server/db/schema/ai";
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
deleteAiSettings,
|
||||
getAiSettingById,
|
||||
getAiSettingsByOrganizationId,
|
||||
getCustomAiProviders,
|
||||
saveAiSettings,
|
||||
saveCustomAiProviders,
|
||||
suggestVariants,
|
||||
} from "@dokploy/server/services/ai";
|
||||
import { createComposeByTemplate } from "@dokploy/server/services/compose";
|
||||
@@ -200,6 +203,19 @@ export const aiRouter = createTRPCRouter({
|
||||
return await deleteAiSettings(input.aiId);
|
||||
}),
|
||||
|
||||
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await getCustomAiProviders(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
saveCustomProviders: adminProcedure
|
||||
.input(apiSaveAiCustomProviders)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await saveCustomAiProviders(
|
||||
ctx.session.activeOrganizationId,
|
||||
input.providers,
|
||||
);
|
||||
}),
|
||||
|
||||
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
const settings = await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
@@ -346,7 +362,6 @@ ${input.logs}`,
|
||||
name: input.name,
|
||||
sourceType: "raw",
|
||||
appName: `${projectName}-${generatePassword(6)}`,
|
||||
isolatedDeployment: true,
|
||||
environmentId: input.environmentId,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deleteAllMiddlewares,
|
||||
findApplicationById,
|
||||
findEnvironmentById,
|
||||
findPreviewDeploymentsByApplicationId,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getApplicationStats,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
removeDeployments,
|
||||
removeDirectoryCode,
|
||||
removeMonitoringDirectory,
|
||||
removePreviewDeployment,
|
||||
removeService,
|
||||
removeTraefikConfig,
|
||||
startService,
|
||||
@@ -234,6 +236,15 @@ export const applicationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const previewDeploymentsList =
|
||||
await findPreviewDeploymentsByApplicationId(input.applicationId);
|
||||
|
||||
for (const previewDeployment of previewDeploymentsList) {
|
||||
try {
|
||||
await removePreviewDeployment(previewDeployment.previewDeploymentId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.delete(applications)
|
||||
.where(eq(applications.applicationId, input.applicationId))
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import {
|
||||
type CompleteTemplate,
|
||||
fetchTemplateFiles,
|
||||
fetchTemplateLogo,
|
||||
fetchTemplatesList,
|
||||
} from "@dokploy/server/templates/github";
|
||||
import { processTemplate } from "@dokploy/server/templates/processors";
|
||||
@@ -601,7 +602,10 @@ export const composeRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const template = await fetchTemplateFiles(input.id, input.baseUrl);
|
||||
const [template, templateLogo] = await Promise.all([
|
||||
fetchTemplateFiles(input.id, input.baseUrl),
|
||||
fetchTemplateLogo(input.id, input.baseUrl),
|
||||
]);
|
||||
|
||||
let serverIp = "127.0.0.1";
|
||||
|
||||
@@ -639,7 +643,7 @@ export const composeRouter = createTRPCRouter({
|
||||
name: input.id,
|
||||
sourceType: "raw",
|
||||
appName: appName,
|
||||
isolatedDeployment: template.config.config?.isolated !== false,
|
||||
icon: templateLogo,
|
||||
});
|
||||
|
||||
await addNewService(ctx, compose.composeId);
|
||||
@@ -997,7 +1001,6 @@ export const composeRouter = createTRPCRouter({
|
||||
composeFile: templateData.compose,
|
||||
sourceType: "raw",
|
||||
env: processedTemplate.envs?.join("\n"),
|
||||
isolatedDeployment: true,
|
||||
});
|
||||
|
||||
if (processedTemplate.mounts && processedTemplate.mounts.length > 0) {
|
||||
|
||||
@@ -153,6 +153,7 @@ export const environmentRouter = createTRPCRouter({
|
||||
|
||||
return environments;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Error fetching environments: ${error instanceof Error ? error.message : error}`,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
createNetwork,
|
||||
findNetworkById,
|
||||
findNetworksToSync,
|
||||
importDockerNetworks,
|
||||
inspectNetwork,
|
||||
recreateNetwork,
|
||||
removeNetwork,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
apiCreateNetwork,
|
||||
apiFindOneNetwork,
|
||||
apiRemoveNetwork,
|
||||
network as networkTable,
|
||||
} from "@/server/db/schema";
|
||||
import { audit } from "../utils/audit";
|
||||
|
||||
export const networkRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
.input(z.object({ serverId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const rows = await db.query.network.findMany({
|
||||
where: and(
|
||||
eq(networkTable.organizationId, ctx.session.activeOrganizationId),
|
||||
input.serverId
|
||||
? eq(networkTable.serverId, input.serverId)
|
||||
: isNull(networkTable.serverId),
|
||||
),
|
||||
orderBy: desc(networkTable.createdAt),
|
||||
});
|
||||
return rows;
|
||||
}),
|
||||
|
||||
one: protectedProcedure
|
||||
.input(apiFindOneNetwork)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const row = await findNetworkById(input.networkId);
|
||||
if (row.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}),
|
||||
create: protectedProcedure
|
||||
.input(apiCreateNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const created = await createNetwork(
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "network",
|
||||
resourceId: created.networkId,
|
||||
resourceName: created.name,
|
||||
});
|
||||
return created;
|
||||
}),
|
||||
networksToSync: protectedProcedure
|
||||
.input(z.object({ serverId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return findNetworksToSync(
|
||||
ctx.session.activeOrganizationId,
|
||||
input.serverId ?? null,
|
||||
);
|
||||
}),
|
||||
|
||||
import: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
serverId: z.string().optional(),
|
||||
names: z.array(z.string().min(1)).min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await importDockerNetworks(
|
||||
ctx.session.activeOrganizationId,
|
||||
input.serverId ?? null,
|
||||
input.names,
|
||||
);
|
||||
if (result.imported.length > 0) {
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "network",
|
||||
resourceName: result.imported.join(", "),
|
||||
metadata: { imported: result.imported },
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
|
||||
inspect: protectedProcedure
|
||||
.input(apiFindOneNetwork)
|
||||
.query(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",
|
||||
});
|
||||
}
|
||||
return inspectNetwork(input.networkId);
|
||||
}),
|
||||
|
||||
recreate: 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 recreated = await recreateNetwork(input.networkId);
|
||||
await audit(ctx, {
|
||||
action: "reload",
|
||||
resourceType: "network",
|
||||
resourceId: recreated.networkId,
|
||||
resourceName: recreated.name,
|
||||
});
|
||||
return recreated;
|
||||
}),
|
||||
|
||||
remove: protectedProcedure
|
||||
.input(apiRemoveNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const network = await findNetworkById(input.networkId);
|
||||
if (network.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authorized to delete this network",
|
||||
});
|
||||
}
|
||||
const removed = await removeNetwork(input.networkId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "network",
|
||||
resourceId: removed.networkId,
|
||||
resourceName: removed.name,
|
||||
});
|
||||
return removed;
|
||||
}),
|
||||
});
|
||||
@@ -259,9 +259,23 @@ export const scheduleRouter = createTRPCRouter({
|
||||
where: where[input.scheduleType],
|
||||
orderBy: [asc(schedules.createdAt)],
|
||||
with: {
|
||||
application: true,
|
||||
application: {
|
||||
columns: {
|
||||
applicationId: true,
|
||||
appName: true,
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
server: true,
|
||||
compose: true,
|
||||
compose: {
|
||||
columns: {
|
||||
composeId: true,
|
||||
appName: true,
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
deployments: {
|
||||
orderBy: [desc(deployments.createdAt)],
|
||||
},
|
||||
@@ -315,13 +329,17 @@ export const scheduleRouter = createTRPCRouter({
|
||||
await checkPermission(ctx, { schedule: ["create"] });
|
||||
}
|
||||
try {
|
||||
await runCommand(input.scheduleId);
|
||||
const deployment = await runCommand(input.scheduleId);
|
||||
await audit(ctx, {
|
||||
action: "run",
|
||||
resourceType: "schedule",
|
||||
resourceId: input.scheduleId,
|
||||
});
|
||||
return true;
|
||||
return {
|
||||
status: deployment.status,
|
||||
deploymentId: deployment.deploymentId,
|
||||
logPath: deployment.logPath,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
checkGPUStatus,
|
||||
checkPortInUse,
|
||||
checkPostgresHealth,
|
||||
checkRedisHealth,
|
||||
checkTraefikHealth,
|
||||
cleanupAll,
|
||||
cleanupAllBackground,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
cleanupSystem,
|
||||
cleanupVolumes,
|
||||
DEFAULT_UPDATE_DATA,
|
||||
execAsync,
|
||||
findServerById,
|
||||
getDockerDiskUsage,
|
||||
getDokployImageTag,
|
||||
@@ -103,41 +101,6 @@ export const settingsRouter = createTRPCRouter({
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
cleanRedis: adminProcedure.mutation(async ({ ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { stdout: containerId } = await execAsync(
|
||||
`docker ps --filter "name=dokploy-redis" --filter "status=running" -q | head -n 1`,
|
||||
);
|
||||
|
||||
if (!containerId) {
|
||||
throw new Error("Redis container not found");
|
||||
}
|
||||
|
||||
const redisContainerId = containerId.trim();
|
||||
|
||||
await execAsync(`docker exec -i ${redisContainerId} redis-cli flushall`);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "settings",
|
||||
resourceName: "clean-redis",
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
reloadRedis: adminProcedure.mutation(async ({ ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
await reloadDockerResource("dokploy-redis");
|
||||
await audit(ctx, {
|
||||
action: "reload",
|
||||
resourceType: "settings",
|
||||
resourceName: "dokploy-redis",
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
cleanAllDeploymentQueue: adminProcedure.mutation(async ({ ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
@@ -959,18 +922,16 @@ export const settingsRouter = createTRPCRouter({
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
postgres: { status: "healthy" as const },
|
||||
redis: { status: "healthy" as const },
|
||||
traefik: { status: "healthy" as const },
|
||||
};
|
||||
}
|
||||
|
||||
const [postgres, redis, traefik] = await Promise.all([
|
||||
const [postgres, traefik] = await Promise.all([
|
||||
checkPostgresHealth(),
|
||||
checkRedisHealth(),
|
||||
checkTraefikHealth(),
|
||||
]);
|
||||
|
||||
return { postgres, redis, traefik };
|
||||
return { postgres, traefik };
|
||||
}),
|
||||
setupGPU: adminProcedure
|
||||
.input(
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createOrganizationUserWithCredentials,
|
||||
findNotificationById,
|
||||
findOrganizationById,
|
||||
findPasskeysByUserId,
|
||||
findUserById,
|
||||
getDokployUrl,
|
||||
getUserByToken,
|
||||
@@ -173,6 +174,9 @@ export const userRouter = createTRPCRouter({
|
||||
getPermissions: protectedProcedure.query(async ({ ctx }) => {
|
||||
return resolvePermissions(ctx);
|
||||
}),
|
||||
listPasskeys: protectedProcedure.query(async ({ ctx }) => {
|
||||
return findPasskeysByUserId(ctx.user.id);
|
||||
}),
|
||||
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
return false;
|
||||
|
||||
@@ -57,14 +57,24 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
return await db.query.volumeBackups.findMany({
|
||||
where: eq(volumeBackups[`${input.volumeBackupType}Id`], input.id),
|
||||
with: {
|
||||
application: true,
|
||||
postgres: true,
|
||||
mysql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
redis: true,
|
||||
compose: true,
|
||||
libsql: true,
|
||||
application: {
|
||||
columns: { applicationId: true, appName: true, serverId: true },
|
||||
},
|
||||
postgres: {
|
||||
columns: { postgresId: true, appName: true, serverId: true },
|
||||
},
|
||||
mysql: { columns: { mysqlId: true, appName: true, serverId: true } },
|
||||
mariadb: {
|
||||
columns: { mariadbId: true, appName: true, serverId: true },
|
||||
},
|
||||
mongo: { columns: { mongoId: true, appName: true, serverId: true } },
|
||||
redis: { columns: { redisId: true, appName: true, serverId: true } },
|
||||
compose: {
|
||||
columns: { composeId: true, appName: true, serverId: true },
|
||||
},
|
||||
libsql: {
|
||||
columns: { libsqlId: true, appName: true, serverId: true },
|
||||
},
|
||||
},
|
||||
orderBy: [desc(volumeBackups.createdAt)],
|
||||
});
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { ConnectionOptions } from "bullmq";
|
||||
|
||||
export const redisConfig: ConnectionOptions = {
|
||||
host:
|
||||
process.env.NODE_ENV === "production"
|
||||
? process.env.REDIS_HOST || "dokploy-redis"
|
||||
: "127.0.0.1",
|
||||
};
|
||||
@@ -6,7 +6,6 @@ const execAsync = promisify(exec);
|
||||
|
||||
import { setupDirectories } from "@dokploy/server/setup/config-paths";
|
||||
import { initializePostgres } from "@dokploy/server/setup/postgres-setup";
|
||||
import { initializeRedis } from "@dokploy/server/setup/redis-setup";
|
||||
import {
|
||||
initializeNetwork,
|
||||
initializeSwarm,
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
createDefaultServerTraefikConfig();
|
||||
await execAsync(`docker pull traefik:v${TRAEFIK_VERSION}`);
|
||||
await initializeStandaloneTraefik();
|
||||
await initializeRedis();
|
||||
await initializePostgres();
|
||||
console.log("Dokploy setup completed");
|
||||
exit(0);
|
||||
|
||||
@@ -269,6 +269,12 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Cursor pointer on buttons */
|
||||
button:not([disabled]),
|
||||
[role="button"]:not([disabled]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 0.3125rem;
|
||||
|
||||
@@ -180,6 +180,9 @@ export const initializeJobs = async () => {
|
||||
where: eq(schedules.enabled, true),
|
||||
with: {
|
||||
application: {
|
||||
columns: {
|
||||
applicationId: true,
|
||||
},
|
||||
with: {
|
||||
server: true,
|
||||
},
|
||||
@@ -227,6 +230,9 @@ export const initializeJobs = async () => {
|
||||
where: eq(volumeBackups.enabled, true),
|
||||
with: {
|
||||
application: {
|
||||
columns: {
|
||||
applicationId: true,
|
||||
},
|
||||
with: {
|
||||
server: true,
|
||||
},
|
||||
|
||||
+35916
-24480
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/api-key": "1.6.23",
|
||||
"@better-auth/passkey": "1.6.23",
|
||||
"@better-auth/scim": "1.6.23",
|
||||
"@better-auth/sso": "1.6.23",
|
||||
"@better-auth/utils": "0.4.2",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { network } from "./network";
|
||||
import { projects } from "./project";
|
||||
import { server } from "./server";
|
||||
import { ssoProvider } from "./sso";
|
||||
@@ -108,6 +109,7 @@ export const organizationRelations = relations(
|
||||
references: [user.id],
|
||||
}),
|
||||
servers: many(server),
|
||||
networks: many(network),
|
||||
projects: many(projects),
|
||||
members: many(member),
|
||||
ssoProviders: many(ssoProvider),
|
||||
@@ -221,6 +223,36 @@ export const twoFactor = pgTable("two_factor", {
|
||||
lockedUntil: timestamp("locked_until"),
|
||||
});
|
||||
|
||||
export const passkey = pgTable(
|
||||
"passkey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
publicKey: text("public_key").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
credentialID: text("credential_id").notNull(),
|
||||
counter: integer("counter").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
backedUp: boolean("backed_up").notNull(),
|
||||
transports: text("transports"),
|
||||
createdAt: timestamp("created_at"),
|
||||
aaguid: text("aaguid"),
|
||||
},
|
||||
(table) => [
|
||||
index("passkey_userId_idx").on(table.userId),
|
||||
index("passkey_credentialID_idx").on(table.credentialID),
|
||||
],
|
||||
);
|
||||
|
||||
export const passkeyRelations = relations(passkey, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [passkey.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const apikey = pgTable("apikey", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
|
||||
@@ -54,6 +54,15 @@ export const apiUpdateAi = createSchema
|
||||
})
|
||||
.omit({ organizationId: true });
|
||||
|
||||
export const aiCustomProviderSchema = z.object({
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||
});
|
||||
|
||||
export const apiSaveAiCustomProviders = z.object({
|
||||
providers: z.array(aiCustomProviderSchema),
|
||||
});
|
||||
|
||||
export const deploySuggestionSchema = z.object({
|
||||
environmentId: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
|
||||
@@ -233,6 +233,10 @@ export const applications = pgTable("application", {
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const applicationsRelations = relations(
|
||||
@@ -374,6 +378,8 @@ const createSchema = createInsertSchema(applications, {
|
||||
previewRequireCollaboratorPermissions: z.boolean().optional(),
|
||||
watchPaths: z.array(z.string()).optional().optional(),
|
||||
previewLabels: z.array(z.string()).optional(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
cleanCache: z.boolean().optional(),
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
|
||||
@@ -91,4 +91,5 @@ export type AuditResourceType =
|
||||
| "cluster"
|
||||
| "mount"
|
||||
| "application"
|
||||
| "compose";
|
||||
| "compose"
|
||||
| "network";
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -91,6 +98,7 @@ export const compose = pgTable("compose", {
|
||||
.default(false),
|
||||
triggerType: triggerType("triggerType").default("push"),
|
||||
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
||||
icon: text("icon"),
|
||||
environmentId: text("environmentId")
|
||||
.notNull()
|
||||
.references(() => environments.environmentId, { onDelete: "cascade" }),
|
||||
@@ -113,6 +121,15 @@ export const compose = pgTable("compose", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
serviceNetworks: jsonb("serviceNetworks")
|
||||
.$type<
|
||||
Array<{
|
||||
serviceName: string;
|
||||
networkIds: string[];
|
||||
detachDokployNetwork: boolean;
|
||||
}>
|
||||
>()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const composeRelations = relations(compose, ({ one, many }) => ({
|
||||
@@ -174,6 +191,20 @@ const createSchema = createInsertSchema(compose, {
|
||||
.optional(),
|
||||
triggerType: z.enum(["push", "tag"]).optional(),
|
||||
composeStatus: z.enum(["idle", "running", "done", "error"]).optional(),
|
||||
icon: z
|
||||
.string()
|
||||
.max(2 * 1024 * 1024, "Icon must be less than 2MB")
|
||||
.nullable()
|
||||
.optional(),
|
||||
serviceNetworks: z
|
||||
.array(
|
||||
z.object({
|
||||
serviceName: z.string(),
|
||||
networkIds: z.array(z.string()),
|
||||
detachDokployNetwork: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const apiCreateCompose = createSchema.pick({
|
||||
|
||||
@@ -20,6 +20,7 @@ export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
export * from "./mysql";
|
||||
export * from "./network";
|
||||
export * from "./notification";
|
||||
export * from "./patch";
|
||||
export * from "./port";
|
||||
|
||||
@@ -83,6 +83,10 @@ export const libsql = pgTable("libsql", {
|
||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
|
||||
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||
replicas: integer("replicas").default(1).notNull(),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
@@ -146,6 +150,8 @@ const createSchema = createInsertSchema(libsql, {
|
||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateLibsql = createSchema
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
integer,
|
||||
json,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -88,6 +95,10 @@ export const mariadb = pgTable("mariadb", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
|
||||
@@ -148,6 +159,8 @@ const createSchema = createInsertSchema(mariadb, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateMariaDB = createSchema.pick({
|
||||
|
||||
@@ -92,6 +92,10 @@ export const mongo = pgTable("mongo", {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
replicaSets: boolean("replicaSets").default(false),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const mongoRelations = relations(mongo, ({ one, many }) => ({
|
||||
@@ -146,6 +150,8 @@ const createSchema = createInsertSchema(mongo, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateMongo = createSchema.pick({
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
integer,
|
||||
json,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -86,6 +93,10 @@ export const mysql = pgTable("mysql", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const mysqlRelations = relations(mysql, ({ one, many }) => ({
|
||||
@@ -145,6 +156,8 @@ const createSchema = createInsertSchema(mysql, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateMySql = createSchema.pick({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user