mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-18 13:45:23 +02:00
Compare commits
119 Commits
feat/intro
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2859fd2a9 | ||
|
|
f5fa39b97e | ||
|
|
0a3a90c4e9 | ||
|
|
f440df343a | ||
|
|
4ec282b2f3 | ||
|
|
c039e638a6 | ||
|
|
65ffc63da4 | ||
|
|
5ba120567f | ||
|
|
8a335789b3 | ||
|
|
d420311507 | ||
|
|
a01ace12e8 | ||
|
|
24c022f837 | ||
|
|
ecd81eb7fa | ||
|
|
f2e4a96154 | ||
|
|
08ba24c252 | ||
|
|
ff55270b52 | ||
|
|
f78819d81a | ||
|
|
79e02483ad | ||
|
|
f25ed46dbc | ||
|
|
7ad09c0d0d | ||
|
|
a212d42495 | ||
|
|
51095e3ac5 | ||
|
|
a897fe6115 | ||
|
|
a0d9f06a35 | ||
|
|
f1d0fb95f4 | ||
|
|
4bc494e009 | ||
|
|
110bdce38c | ||
|
|
b9e700243e | ||
|
|
bc39addfa8 | ||
|
|
2532934cdf | ||
|
|
f4b5a589b6 | ||
|
|
105562bdcb | ||
|
|
16359e21a2 | ||
|
|
9451958193 | ||
|
|
1a810790cd | ||
|
|
e426c89cb2 | ||
|
|
325a0aeedf | ||
|
|
a8293b7b5c | ||
|
|
54bd25da39 | ||
|
|
e4c440b265 | ||
|
|
e39f0fee77 | ||
|
|
5b48e45536 | ||
|
|
3a7f76e33e | ||
|
|
a54c84a138 | ||
|
|
8ba26f01e3 | ||
|
|
9bc88eba72 | ||
|
|
b741618251 | ||
|
|
c0328ab63f | ||
|
|
425bcf8958 | ||
|
|
26d4058457 | ||
|
|
ccaac28f08 | ||
|
|
a1a348e22d | ||
|
|
ad29bb6ec2 | ||
|
|
aa2e0e81c6 | ||
|
|
3750cdab44 | ||
|
|
6cf448ba80 | ||
|
|
3e64647d0d | ||
|
|
dde00fc380 | ||
|
|
f4ad3dae35 | ||
|
|
85a8ec8ba9 | ||
|
|
c68525aa59 | ||
|
|
91d6365275 | ||
|
|
35a7445a09 | ||
|
|
4607b15a85 | ||
|
|
4eae1a5c14 | ||
|
|
5381b13813 | ||
|
|
66ae8e1fff | ||
|
|
1aa05eaa8d | ||
|
|
4f13c25ca2 | ||
|
|
83599cee37 | ||
|
|
1c4e95d8e3 | ||
|
|
97f1105cf4 | ||
|
|
c65026353a | ||
|
|
8872dc178c | ||
|
|
fa0c2ec5e3 | ||
|
|
f9eda8e95d | ||
|
|
5b2b0db686 | ||
|
|
6576731842 | ||
|
|
ec7bf9fd2f | ||
|
|
bc053744fc | ||
|
|
af87614cb0 | ||
|
|
b2484da2af | ||
|
|
5d3c05d291 | ||
|
|
40accfbf60 | ||
|
|
3f0558d077 | ||
|
|
7ae3d7d906 | ||
|
|
6877ebe027 | ||
|
|
51e881d831 | ||
|
|
80bbb752b6 | ||
|
|
3c9945ec35 | ||
|
|
4d8c358b33 | ||
|
|
4b82659a48 | ||
|
|
d77c562c84 | ||
|
|
37ea75be3e | ||
|
|
82158ed34d | ||
|
|
9ab98c9a63 | ||
|
|
ca7d3f8cb3 | ||
|
|
66448ff6c2 | ||
|
|
31d47efb1e | ||
|
|
33802f554a | ||
|
|
38265fd921 | ||
|
|
a86fe46b7b | ||
|
|
139c06b63d | ||
|
|
999dc7d360 | ||
|
|
c2894260dc | ||
|
|
582f493f3f | ||
|
|
f6f0921560 | ||
|
|
c739c67616 | ||
|
|
de2579401c | ||
|
|
21b1652259 | ||
|
|
25d37b76a1 | ||
|
|
ad382f1fe5 | ||
|
|
d5b0e3193a | ||
|
|
43228fc51b | ||
|
|
ba8a334fbe | ||
|
|
6c90075a64 | ||
|
|
6271f3bb1a | ||
|
|
57eee45dbb | ||
|
|
0a401843f8 |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -8,7 +8,7 @@ Before submitting this PR, please make sure that:
|
||||
|
||||
- [ ] You created a dedicated branch based on the `canary` branch.
|
||||
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||
- [ ] You have tested this PR in your local instance.
|
||||
- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.
|
||||
|
||||
## Issues related (if applicable)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
|
||||
|
||||
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
|
||||
Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues.
|
||||
|
||||
We have a few guidelines to follow when contributing to this project:
|
||||
|
||||
@@ -11,6 +11,7 @@ We have a few guidelines to follow when contributing to this project:
|
||||
- [Development](#development)
|
||||
- [Build](#build)
|
||||
- [Pull Request](#pull-request)
|
||||
- [Important Considerations](#important-considerations-for-pull-requests)
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -162,8 +163,9 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
|
||||
- If your pull request fixes an open issue, please reference the issue in the pull request description.
|
||||
- Once your pull request is merged, you will be automatically added as a contributor to the project.
|
||||
|
||||
**Important Considerations for Pull Requests:**
|
||||
### Important Considerations for Pull Requests
|
||||
|
||||
- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code.
|
||||
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
|
||||
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
|
||||
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
|
||||
|
||||
@@ -65,4 +65,8 @@ RUN curl -sSL https://railpack.com/install.sh | bash
|
||||
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "start" ]
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||
|
||||
47
README.md
47
README.md
@@ -12,24 +12,8 @@
|
||||
<br />
|
||||
|
||||
|
||||
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://tuple.app/dokploy">
|
||||
<img src=".github/sponsors/tuple.png" alt="Tuple's sponsorship image" width="400"/>
|
||||
</a>
|
||||
|
||||
### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy)
|
||||
[Available for MacOS & Windows](https://tuple.app/dokploy)<br>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
||||
|
||||
|
||||
## ✨ Features
|
||||
|
||||
Dokploy includes multiple features to make your life easier.
|
||||
@@ -60,40 +44,9 @@ curl -sSL https://dokploy.com/install.sh | sh
|
||||
|
||||
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
|
||||
## ♥️ Sponsors
|
||||
|
||||
🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features.
|
||||
|
||||
[Dokploy Open Collective](https://opencollective.com/dokploy)
|
||||
|
||||
[Github Sponsors](https://github.com/sponsors/Siumauricio)
|
||||
|
||||
## Sponsors
|
||||
|
||||
| Sponsor | Logo | Supporter Level |
|
||||
|---------|:----:|----------------|
|
||||
| [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) | <img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="200"/> | 🎖 Hero Sponsor |
|
||||
| [LX Aer](https://www.lxaer.com/?ref=dokploy) | <img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/> | 🎖 Hero Sponsor |
|
||||
| [LinkDR](https://linkdr.com/?ref=dokploy) | <img src="https://dokploy.com/linkdr-logo.svg" alt="LinkDR" width="100"/> | 🎖 Hero Sponsor |
|
||||
| [LambdaTest](https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor) | <img src="https://www.lambdatest.com/blue-logo.png" alt="LambdaTest" width="200"/> | 🎖 Hero Sponsor |
|
||||
| [Awesome Tools](https://awesome.tools/) | <img src=".github/sponsors/awesome.png" alt="Awesome Tools" width="100"/> | 🎖 Hero Sponsor |
|
||||
| [Supafort](https://supafort.com/?ref=dokploy) | <img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="200"/> | 🥇 Premium Supporter |
|
||||
| [Agentdock](https://agentdock.ai/?ref=dokploy) | <img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/> | 🥇 Premium Supporter |
|
||||
| [AmericanCloud](https://americancloud.com/?ref=dokploy) | <img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="200"/> | 🥈 Elite Contributor |
|
||||
| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | <img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/> | 🥈 Elite Contributor |
|
||||
| [Cloudblast](https://cloudblast.io/?ref=dokploy) | <img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" alt="Cloudblast.io" width="150"/> | 🥉 Supporting Member |
|
||||
| [Synexa](https://synexa.ai/?ref=dokploy) | <img src=".github/sponsors/synexa.png" alt="Synexa" width="100"/> | 🥉 Supporting Member |
|
||||
|
||||
### Community Backers 🤝
|
||||
|
||||
#### Organizations:
|
||||
|
||||
[Sponsors on Open Collective](https://opencollective.com/dokploy)
|
||||
|
||||
#### Individuals:
|
||||
|
||||
[](https://opencollective.com/dokploy)
|
||||
|
||||
### Contributors 🤝
|
||||
|
||||
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/zod-validator": "0.3.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.7.10",
|
||||
"hono": "^4.11.7",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"react": "18.2.0",
|
||||
|
||||
@@ -4,21 +4,30 @@ import { describe, expect, it } from "vitest";
|
||||
describe("addDokployNetworkToService", () => {
|
||||
it("should add network to an empty array", () => {
|
||||
const result = addDokployNetworkToService([]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should not add duplicate network to an array", () => {
|
||||
const result = addDokployNetworkToService(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should add network to an existing array with other networks", () => {
|
||||
const result = addDokployNetworkToService(["other-network"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should add network to an object if networks is an object", () => {
|
||||
const result = addDokployNetworkToService({ "other-network": {} });
|
||||
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
|
||||
expect(result).toEqual({
|
||||
"other-network": {},
|
||||
"dokploy-network": {},
|
||||
default: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not duplicate default network when already present", () => {
|
||||
const result = addDokployNetworkToService(["default", "dokploy-network"]);
|
||||
expect(result).toEqual(["default", "dokploy-network"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,6 +147,7 @@ const baseApp: ApplicationNested = {
|
||||
dockerContextPath: null,
|
||||
rollbackActive: false,
|
||||
stopGracePeriodSwarm: null,
|
||||
ulimitsSwarm: null,
|
||||
};
|
||||
|
||||
describe("unzipDrop using real zip files", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ type MockCreateServiceOptions = {
|
||||
TaskTemplate?: {
|
||||
ContainerSpec?: {
|
||||
StopGracePeriod?: number;
|
||||
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
@@ -57,6 +58,7 @@ const createApplication = (
|
||||
},
|
||||
replicas: 1,
|
||||
stopGracePeriodSwarm: 0n,
|
||||
ulimitsSwarm: null,
|
||||
serverId: "server-id",
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
@@ -110,4 +112,50 @@ describe("mechanizeDockerContainer", () => {
|
||||
"StopGracePeriod",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => {
|
||||
const ulimits = [
|
||||
{ Name: "nofile", Soft: 10000, Hard: 20000 },
|
||||
{ Name: "nproc", Soft: 4096, Hard: 8192 },
|
||||
];
|
||||
const application = createApplication({ ulimitsSwarm: ulimits });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits);
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is null", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: null });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is an empty array", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: [] });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +125,7 @@ const baseApp: ApplicationNested = {
|
||||
username: null,
|
||||
dockerContextPath: null,
|
||||
stopGracePeriodSwarm: null,
|
||||
ulimitsSwarm: null,
|
||||
};
|
||||
|
||||
const baseDomain: Domain = {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
HealthCheckForm,
|
||||
LabelsForm,
|
||||
ModeForm,
|
||||
NetworkForm,
|
||||
PlacementForm,
|
||||
RestartPolicyForm,
|
||||
RollbackConfigForm,
|
||||
@@ -79,6 +80,13 @@ const menuItems: MenuItem[] = [
|
||||
docDescription:
|
||||
"Set service mode to either 'Replicated' with a specified number of tasks (Replicas), or 'Global' (one task per node).",
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
label: "Network",
|
||||
description: "Configure network attachments",
|
||||
docDescription:
|
||||
"Attach the service to one or more networks. Specify the network name (Target) and optional network aliases for service discovery.",
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
label: "Labels",
|
||||
@@ -190,6 +198,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
<RollbackConfigForm id={id} type={type} />
|
||||
)}
|
||||
{activeMenu === "mode" && <ModeForm id={id} type={type} />}
|
||||
{activeMenu === "network" && <NetworkForm id={id} type={type} />}
|
||||
{activeMenu === "labels" && <LabelsForm id={id} type={type} />}
|
||||
{activeMenu === "stop-grace-period" && (
|
||||
<StopGracePeriodForm id={id} type={type} />
|
||||
|
||||
@@ -2,6 +2,7 @@ export { EndpointSpecForm } from "./endpoint-spec-form";
|
||||
export { HealthCheckForm } from "./health-check-form";
|
||||
export { LabelsForm } from "./labels-form";
|
||||
export { ModeForm } from "./mode-form";
|
||||
export { NetworkForm } from "./network-form";
|
||||
export { PlacementForm } from "./placement-form";
|
||||
export { RestartPolicyForm } from "./restart-policy-form";
|
||||
export { RollbackConfigForm } from "./rollback-config-form";
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const driverOptEntrySchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const networkFormSchema = z.object({
|
||||
networks: z
|
||||
.array(
|
||||
z.object({
|
||||
Target: z.string().optional(),
|
||||
Aliases: z.string().optional(),
|
||||
DriverOptsEntries: z.array(driverOptEntrySchema).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface NetworkFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<z.infer<typeof networkFormSchema>>({
|
||||
resolver: zodResolver(networkFormSchema),
|
||||
defaultValues: {
|
||||
networks: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "networks",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.networkSwarm && Array.isArray(data.networkSwarm)) {
|
||||
const networkEntries = data.networkSwarm.map((network) => ({
|
||||
Target: network.Target || "",
|
||||
Aliases: network.Aliases?.join(", ") || "",
|
||||
DriverOptsEntries: network.DriverOpts
|
||||
? Object.entries(network.DriverOpts).map(([key, value]) => ({
|
||||
key,
|
||||
value: value ?? "",
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
form.reset({ networks: networkEntries });
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: z.infer<typeof networkFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const networksArray =
|
||||
formData.networks
|
||||
?.filter((network) => network.Target)
|
||||
.map((network) => {
|
||||
const entries = (network.DriverOptsEntries ?? []).filter(
|
||||
(e) => e.key.trim() !== "",
|
||||
);
|
||||
const driverOpts =
|
||||
entries.length > 0
|
||||
? Object.fromEntries(
|
||||
entries.map((e) => [e.key.trim(), e.value]),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
Target: network.Target,
|
||||
Aliases: network.Aliases
|
||||
? network.Aliases.split(",").map((alias) => alias.trim())
|
||||
: undefined,
|
||||
DriverOpts: driverOpts,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
// If no networks, send null to clear the database
|
||||
const networksToSend = networksArray.length > 0 ? networksArray : null;
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
networkSwarm: networksToSend,
|
||||
});
|
||||
|
||||
toast.success("Network configuration updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating network configuration");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel>Networks</FormLabel>
|
||||
<FormDescription>
|
||||
Configure network attachments for your service
|
||||
</FormDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="space-y-2 p-3 border rounded">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Target`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Network Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="my-network" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name of the network to attach to
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Aliases`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Aliases (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="alias1, alias2, alias3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated list of network aliases
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Driver options (optional)</FormLabel>
|
||||
<FormDescription>
|
||||
e.g. com.docker.network.driver.mtu,
|
||||
com.docker.network.driver.host_binding
|
||||
</FormDescription>
|
||||
{(
|
||||
form.watch(`networks.${index}.DriverOptsEntries`) ?? []
|
||||
).map((_, optIndex) => (
|
||||
<div
|
||||
key={optIndex}
|
||||
className="flex gap-2 items-end flex-wrap"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[140px]">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="com.docker.network.driver.mtu"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[100px]">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="1500" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
) ?? [];
|
||||
form.setValue(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
entries.filter((_, i) => i !== optIndex),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(`networks.${index}.DriverOptsEntries`) ??
|
||||
[];
|
||||
form.setValue(`networks.${index}.DriverOptsEntries`, [
|
||||
...entries,
|
||||
{ key: "", value: "" },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add driver option
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove Network
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({
|
||||
Target: "",
|
||||
Aliases: "",
|
||||
DriverOptsEntries: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Network
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({ networks: [] });
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Networks
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -21,10 +21,18 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
createConverter,
|
||||
NumberInputWithSteps,
|
||||
} from "@/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -50,13 +58,36 @@ const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
||||
: `${formatNumber(mb)} MB`;
|
||||
});
|
||||
|
||||
const ulimitSchema = z.object({
|
||||
Name: z.string().min(1, "Name is required"),
|
||||
Soft: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
Hard: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
});
|
||||
|
||||
const addResourcesSchema = z.object({
|
||||
memoryReservation: z.string().optional(),
|
||||
cpuLimit: z.string().optional(),
|
||||
memoryLimit: z.string().optional(),
|
||||
cpuReservation: z.string().optional(),
|
||||
ulimitsSwarm: z.array(ulimitSchema).optional(),
|
||||
});
|
||||
|
||||
const ULIMIT_PRESETS = [
|
||||
{ value: "nofile", label: "nofile (Open Files)" },
|
||||
{ value: "nproc", label: "nproc (Processes)" },
|
||||
{ value: "memlock", label: "memlock (Locked Memory)" },
|
||||
{ value: "stack", label: "stack (Stack Size)" },
|
||||
{ value: "core", label: "core (Core File Size)" },
|
||||
{ value: "cpu", label: "cpu (CPU Time)" },
|
||||
{ value: "data", label: "data (Data Segment)" },
|
||||
{ value: "fsize", label: "fsize (File Size)" },
|
||||
{ value: "locks", label: "locks (File Locks)" },
|
||||
{ value: "msgqueue", label: "msgqueue (Message Queues)" },
|
||||
{ value: "nice", label: "nice (Nice Priority)" },
|
||||
{ value: "rtprio", label: "rtprio (Real-time Priority)" },
|
||||
{ value: "sigpending", label: "sigpending (Pending Signals)" },
|
||||
];
|
||||
|
||||
export type ServiceType =
|
||||
| "postgres"
|
||||
| "mongo"
|
||||
@@ -107,10 +138,16 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: "",
|
||||
memoryLimit: "",
|
||||
memoryReservation: "",
|
||||
ulimitsSwarm: [],
|
||||
},
|
||||
resolver: zodResolver(addResourcesSchema),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "ulimitsSwarm",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
@@ -118,6 +155,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: data?.cpuReservation || undefined,
|
||||
memoryLimit: data?.memoryLimit || undefined,
|
||||
memoryReservation: data?.memoryReservation || undefined,
|
||||
ulimitsSwarm: data?.ulimitsSwarm || [],
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
@@ -134,6 +172,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: formData.cpuReservation || null,
|
||||
memoryLimit: formData.memoryLimit || null,
|
||||
memoryReservation: formData.memoryReservation || null,
|
||||
ulimitsSwarm:
|
||||
formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0
|
||||
? formData.ulimitsSwarm
|
||||
: null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Resources Updated");
|
||||
@@ -325,6 +367,145 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ulimits Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>
|
||||
Set resource limits for the container. Each ulimit has
|
||||
a soft limit (warning threshold) and hard limit
|
||||
(maximum allowed). Use -1 for unlimited.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({ Name: "nofile", Soft: 65535, Hard: 65535 })
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Ulimit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Name`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel className="text-xs">Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ulimit" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ULIMIT_PRESETS.map((preset) => (
|
||||
<SelectItem
|
||||
key={preset.value}
|
||||
value={preset.value}
|
||||
>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Soft`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-32">
|
||||
<FormLabel className="text-xs">
|
||||
Soft Limit
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
placeholder="65535"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Hard`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-32">
|
||||
<FormLabel className="text-xs">
|
||||
Hard Limit
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
placeholder="65535"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-6 text-destructive hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No ulimits configured. Click "Add Ulimit" to set
|
||||
resource limits.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const UpdateTraefikConfigSchema = z.object({
|
||||
@@ -59,6 +61,7 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
||||
|
||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -85,13 +88,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
}, [data]);
|
||||
|
||||
const onSubmit = async (data: UpdateTraefikConfig) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
if (!skipYamlValidation) {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
@@ -116,6 +121,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setSkipYamlValidation(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -169,7 +175,28 @@ routers:
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-4">
|
||||
<div className="flex flex-col gap-1 w-full sm:w-auto sm:mr-auto">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="skip-yaml-validation-app"
|
||||
checked={skipYamlValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipYamlValidation(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-yaml-validation-app"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Skip YAML validation (for Go templating)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check to save configs with Go templating (e.g.{" "}
|
||||
<code className="text-xs">{"{{range}}"}</code>).
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-traefik-config"
|
||||
|
||||
@@ -34,6 +34,7 @@ export const DockerLogs = dynamic(
|
||||
export const badgeStateColor = (state: string) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
case "ready":
|
||||
return "green";
|
||||
case "exited":
|
||||
case "shutdown":
|
||||
@@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -157,6 +159,9 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.currentState
|
||||
? ` ${container.currentState}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
@@ -166,6 +171,13 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{option === "swarm" &&
|
||||
services?.find((c) => c.containerId === containerId)?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
|
||||
<span className="font-medium">Error: </span>
|
||||
{services?.find((c) => c.containerId === containerId)?.error}
|
||||
</div>
|
||||
)}
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
|
||||
@@ -128,6 +128,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -143,6 +144,9 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.currentState
|
||||
? ` ${container.currentState}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
@@ -152,6 +156,13 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{option === "swarm" &&
|
||||
services?.find((c) => c.containerId === containerId)?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
|
||||
<span className="font-medium">Error: </span>
|
||||
{services.find((c) => c.containerId === containerId)?.error}
|
||||
</div>
|
||||
)}
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -93,6 +93,7 @@ export const ShowDockerLogsCompose = ({
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
|
||||
|
||||
@@ -47,6 +49,7 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
|
||||
},
|
||||
);
|
||||
const [canEdit, setCanEdit] = useState(true);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.settings.updateTraefikFile.useMutation();
|
||||
@@ -66,13 +69,15 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
if (!skipYamlValidation) {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
@@ -153,14 +158,37 @@ routers:
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="skip-yaml-validation"
|
||||
checked={skipYamlValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipYamlValidation(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-yaml-validation"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Skip YAML validation (for Go templating)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground -mt-2">
|
||||
Traefik supports Go templating in dynamic configs (e.g.{" "}
|
||||
<code className="text-xs">{"{{range}}"}</code>). Configs using
|
||||
templates will fail standard YAML validation. Check this to save
|
||||
without validation.
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -39,6 +40,10 @@ const Schema = z.object({
|
||||
giteaUrl: z.string().min(1, {
|
||||
message: "Gitea URL is required",
|
||||
}),
|
||||
giteaInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
clientId: z.string().min(1, {
|
||||
message: "Client ID is required",
|
||||
}),
|
||||
@@ -70,6 +75,7 @@ export const AddGiteaProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -83,6 +89,7 @@ export const AddGiteaProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
});
|
||||
}, [form, webhookUrl, isOpen]);
|
||||
|
||||
@@ -95,6 +102,7 @@ export const AddGiteaProvider = () => {
|
||||
name: data.name,
|
||||
redirectUri: data.redirectUri,
|
||||
giteaUrl: data.giteaUrl,
|
||||
giteaInternalUrl: data.giteaInternalUrl || undefined,
|
||||
organizationName: data.organizationName,
|
||||
})) as unknown as GiteaProviderResponse;
|
||||
|
||||
@@ -223,6 +231,29 @@ export const AddGiteaProvider = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitea:3000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when Gitea runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach Gitea via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUri"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -30,6 +31,10 @@ import { useUrl } from "@/utils/hooks/use-url";
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
giteaUrl: z.string().min(1, "Gitea URL is required"),
|
||||
giteaInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
clientId: z.string().min(1, "Client ID is required"),
|
||||
clientSecret: z.string().min(1, "Client Secret is required"),
|
||||
});
|
||||
@@ -94,6 +99,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
defaultValues: {
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
@@ -104,6 +110,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
form.reset({
|
||||
name: gitea.gitProvider?.name || "",
|
||||
giteaUrl: gitea.giteaUrl || "https://gitea.com",
|
||||
giteaInternalUrl: gitea.giteaInternalUrl || "",
|
||||
clientId: gitea.clientId || "",
|
||||
clientSecret: gitea.clientSecret || "",
|
||||
});
|
||||
@@ -116,6 +123,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
gitProviderId: gitea?.gitProvider?.gitProviderId || "",
|
||||
name: values.name,
|
||||
giteaUrl: values.giteaUrl,
|
||||
giteaInternalUrl: values.giteaInternalUrl ?? null,
|
||||
clientId: values.clientId,
|
||||
clientSecret: values.clientSecret,
|
||||
})
|
||||
@@ -224,6 +232,28 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitea:3000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when Gitea runs on the same instance as Dokploy. Used
|
||||
for OAuth token exchange to reach Gitea via internal network
|
||||
(e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -35,6 +36,10 @@ const Schema = z.object({
|
||||
gitlabUrl: z.string().min(1, {
|
||||
message: "GitLab URL is required",
|
||||
}),
|
||||
gitlabInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
applicationId: z.string().min(1, {
|
||||
message: "Application ID is required",
|
||||
}),
|
||||
@@ -66,6 +71,7 @@ export const AddGitlabProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -80,6 +86,7 @@ export const AddGitlabProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
});
|
||||
}, [form, isOpen]);
|
||||
|
||||
@@ -92,6 +99,7 @@ export const AddGitlabProvider = () => {
|
||||
name: data.name || "",
|
||||
redirectUri: data.redirectUri || "",
|
||||
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
|
||||
gitlabInternalUrl: data.gitlabInternalUrl || undefined,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
@@ -192,6 +200,29 @@ export const AddGitlabProvider = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gitlabInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitlab:80"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when GitLab runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach GitLab via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUri"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -33,6 +34,10 @@ const Schema = z.object({
|
||||
gitlabUrl: z.string().url({
|
||||
message: "Invalid Gitlab URL",
|
||||
}),
|
||||
gitlabInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
groupName: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -61,6 +66,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: "",
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -72,6 +78,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: gitlab?.groupName || "",
|
||||
name: gitlab?.gitProvider.name || "",
|
||||
gitlabUrl: gitlab?.gitlabUrl || "",
|
||||
gitlabInternalUrl: gitlab?.gitlabInternalUrl || "",
|
||||
});
|
||||
}, [form, isOpen]);
|
||||
|
||||
@@ -82,6 +89,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: data.groupName || "",
|
||||
name: data.name || "",
|
||||
gitlabUrl: data.gitlabUrl || "",
|
||||
gitlabInternalUrl: data.gitlabInternalUrl ?? null,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
@@ -151,6 +159,29 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gitlabInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitlab:80"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when GitLab runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach GitLab via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="groupName"
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
LarkIcon,
|
||||
NtfyIcon,
|
||||
PushoverIcon,
|
||||
ResendIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
@@ -97,6 +98,23 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
.min(1, { message: "At least one email is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("resend"),
|
||||
apiKey: z.string().min(1, { message: "API Key is required" }),
|
||||
fromAddress: z
|
||||
.string()
|
||||
.min(1, { message: "From Address is required" })
|
||||
.email({ message: "Email is invalid" }),
|
||||
toAddresses: z
|
||||
.array(
|
||||
z.string().min(1, { message: "Email is required" }).email({
|
||||
message: "Email is invalid",
|
||||
}),
|
||||
)
|
||||
.min(1, { message: "At least one email is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("gotify"),
|
||||
@@ -169,6 +187,10 @@ export const notificationsMap = {
|
||||
icon: <Mail size={29} className="text-muted-foreground" />,
|
||||
label: "Email",
|
||||
},
|
||||
resend: {
|
||||
icon: <ResendIcon className="text-muted-foreground" />,
|
||||
label: "Resend",
|
||||
},
|
||||
gotify: {
|
||||
icon: <GotifyIcon />,
|
||||
label: "Gotify",
|
||||
@@ -214,6 +236,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
api.notification.testDiscordConnection.useMutation();
|
||||
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
|
||||
api.notification.testEmailConnection.useMutation();
|
||||
const { mutateAsync: testResendConnection, isLoading: isLoadingResend } =
|
||||
api.notification.testResendConnection.useMutation();
|
||||
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
|
||||
api.notification.testGotifyConnection.useMutation();
|
||||
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
|
||||
@@ -242,6 +266,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
const emailMutation = notificationId
|
||||
? api.notification.updateEmail.useMutation()
|
||||
: api.notification.createEmail.useMutation();
|
||||
const resendMutation = notificationId
|
||||
? api.notification.updateResend.useMutation()
|
||||
: api.notification.createResend.useMutation();
|
||||
const gotifyMutation = notificationId
|
||||
? api.notification.updateGotify.useMutation()
|
||||
: api.notification.createGotify.useMutation();
|
||||
@@ -281,7 +308,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "email" && fields.length === 0) {
|
||||
if ((type === "email" || type === "resend") && fields.length === 0) {
|
||||
append("");
|
||||
}
|
||||
}, [type, append, fields.length]);
|
||||
@@ -349,6 +376,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "resend") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
apiKey: notification.resend?.apiKey,
|
||||
toAddresses: notification.resend?.toAddresses,
|
||||
fromAddress: notification.resend?.fromAddress,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "gotify") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
@@ -442,6 +484,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
telegram: telegramMutation,
|
||||
discord: discordMutation,
|
||||
email: emailMutation,
|
||||
resend: resendMutation,
|
||||
gotify: gotifyMutation,
|
||||
ntfy: ntfyMutation,
|
||||
lark: larkMutation,
|
||||
@@ -525,6 +568,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
emailId: notification?.emailId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "resend") {
|
||||
promise = resendMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
apiKey: data.apiKey,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
notificationId: notificationId || "",
|
||||
resendId: notification?.resendId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "gotify") {
|
||||
promise = gotifyMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
@@ -1042,6 +1101,96 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "resend" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="re_********"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="from@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<FormLabel>To Addresses</FormLabel>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex flex-row gap-2 w-full"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`toAddresses.${index}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="email@example.com"
|
||||
className="w-full"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{type === "resend" &&
|
||||
"toAddresses" in form.formState.errors && (
|
||||
<div className="text-sm font-medium text-destructive">
|
||||
{form.formState?.errors?.toAddresses?.root?.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
append("");
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "gotify" && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -1627,6 +1776,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingTelegram ||
|
||||
isLoadingDiscord ||
|
||||
isLoadingEmail ||
|
||||
isLoadingResend ||
|
||||
isLoadingGotify ||
|
||||
isLoadingNtfy ||
|
||||
isLoadingLark ||
|
||||
@@ -1667,6 +1817,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
});
|
||||
} else if (data.type === "resend") {
|
||||
await testResendConnection({
|
||||
apiKey: data.apiKey,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
});
|
||||
} else if (data.type === "gotify") {
|
||||
await testGotifyConnection({
|
||||
serverUrl: data.serverUrl,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GotifyIcon,
|
||||
LarkIcon,
|
||||
NtfyIcon,
|
||||
ResendIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
@@ -36,7 +37,7 @@ export const ShowNotifications = () => {
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add your providers to receive notifications, like Discord, Slack,
|
||||
Telegram, Email, Lark.
|
||||
Telegram, Email, Resend, Lark.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -86,6 +87,11 @@ export const ShowNotifications = () => {
|
||||
<Mail className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "resend" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<ResendIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "gotify" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<GotifyIcon className="size-6" />
|
||||
|
||||
@@ -23,6 +23,8 @@ export const ShowDokployActions = () => {
|
||||
|
||||
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
|
||||
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
|
||||
const { mutateAsync: cleanAllDeploymentQueue } =
|
||||
api.settings.cleanAllDeploymentQueue.useMutation();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -87,6 +89,21 @@ export const ShowDokployActions = () => {
|
||||
Clean Redis
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
await cleanAllDeploymentQueue()
|
||||
.then(() => {
|
||||
toast.success("Deployment queue cleaned");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error cleaning deployment queue");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clean all deployment queue
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import { api } from "@/utils/api";
|
||||
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
|
||||
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
|
||||
@@ -33,14 +34,45 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
serverId,
|
||||
});
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
pollInterval: 4000,
|
||||
successMessage: "Traefik dashboard updated successfully",
|
||||
onSuccess: () => {
|
||||
refetchDashboard();
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
execute: executeReloadWithHealthCheck,
|
||||
isExecuting: isReloadHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
pollInterval: 4000,
|
||||
successMessage: "Traefik Reloaded",
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
disabled={
|
||||
reloadTraefikIsLoading ||
|
||||
toggleDashboardIsLoading ||
|
||||
isHealthCheckExecuting ||
|
||||
isReloadHealthCheckExecuting
|
||||
}
|
||||
>
|
||||
<Button
|
||||
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
isLoading={
|
||||
reloadTraefikIsLoading ||
|
||||
toggleDashboardIsLoading ||
|
||||
isHealthCheckExecuting ||
|
||||
isReloadHealthCheckExecuting
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{t("settings.server.webServer.traefik.label")}
|
||||
@@ -54,15 +86,19 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await reloadTraefik({
|
||||
serverId: serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Traefik Reloaded");
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
await executeReloadWithHealthCheck(() =>
|
||||
reloadTraefik({ serverId }),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
(error as Error)?.message ||
|
||||
"Failed to reload Traefik. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
disabled={isReloadHealthCheckExecuting}
|
||||
>
|
||||
<span>{t("settings.server.webServer.reload")}</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -108,24 +144,21 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
</div>
|
||||
}
|
||||
onClick={async () => {
|
||||
await toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
serverId: serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(
|
||||
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
|
||||
);
|
||||
refetchDashboard();
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
try {
|
||||
await executeWithHealthCheck(() =>
|
||||
toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
serverId: serverId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
(error as Error)?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}}
|
||||
disabled={toggleDashboardIsLoading}
|
||||
disabled={toggleDashboardIsLoading || isHealthCheckExecuting}
|
||||
type="default"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -46,6 +47,14 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.settings.writeTraefikEnv.useMutation();
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
successMessage: "Traefik Env Updated",
|
||||
});
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
env: data || "",
|
||||
@@ -63,16 +72,16 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
await mutateAsync({
|
||||
env: data.env,
|
||||
serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Traefik Env Updated");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the Traefik env");
|
||||
});
|
||||
try {
|
||||
await executeWithHealthCheck(() =>
|
||||
mutateAsync({
|
||||
env: data.env,
|
||||
serverId,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
toast.error("Error updating the Traefik env");
|
||||
}
|
||||
};
|
||||
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
@@ -154,8 +163,8 @@ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_PROVIDER=cloudflare
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
isLoading={isLoading || isHealthCheckExecuting}
|
||||
disabled={canEdit || isLoading || isHealthCheckExecuting}
|
||||
form="hook-form-update-server-traefik-config"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -76,11 +77,19 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
});
|
||||
|
||||
const { mutateAsync: updatePorts, isLoading } =
|
||||
api.settings.updateTraefikPorts.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchPorts();
|
||||
},
|
||||
});
|
||||
api.settings.updateTraefikPorts.useMutation();
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
|
||||
onSuccess: () => {
|
||||
refetchPorts();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPorts) {
|
||||
@@ -99,11 +108,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
|
||||
const onSubmit = async (data: TraefikPortsForm) => {
|
||||
try {
|
||||
await updatePorts({
|
||||
serverId,
|
||||
additionalPorts: data.ports,
|
||||
});
|
||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||
await executeWithHealthCheck(() =>
|
||||
updatePorts({
|
||||
serverId,
|
||||
additionalPorts: data.ports,
|
||||
}),
|
||||
);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "Error updating Traefik ports");
|
||||
@@ -317,7 +327,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="text-sm"
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isHealthCheckExecuting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -257,3 +257,23 @@ export const PushoverIcon = ({ className }: Props) => {
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResendIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn("size-8", className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.12" />
|
||||
<path
|
||||
d="M8 17V7h6a3 3 0 0 1 0 6H8m6 0 2 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -404,8 +404,7 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/license",
|
||||
icon: Key,
|
||||
// Only enabled for admins in non-cloud environments
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
|
||||
@@ -166,7 +166,12 @@ export function LicenseKeySettings() {
|
||||
{!haveValidLicenseKey && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isSaving || isValidating || isDeactivating}
|
||||
disabled={
|
||||
isSaving ||
|
||||
isValidating ||
|
||||
isDeactivating ||
|
||||
!licenseKey.trim()
|
||||
}
|
||||
isLoading={isActivating}
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
||||
10
apps/dokploy/drizzle/0138_pretty_ironclad.sql
Normal file
10
apps/dokploy/drizzle/0138_pretty_ironclad.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TYPE "public"."notificationType" ADD VALUE 'resend' BEFORE 'gotify';--> statement-breakpoint
|
||||
CREATE TABLE "resend" (
|
||||
"resendId" text PRIMARY KEY NOT NULL,
|
||||
"apiKey" text NOT NULL,
|
||||
"fromAddress" text NOT NULL,
|
||||
"toAddress" text[] NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD COLUMN "resendId" text;--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_resendId_resend_resendId_fk" FOREIGN KEY ("resendId") REFERENCES "public"."resend"("resendId") ON DELETE cascade ON UPDATE no action;
|
||||
1
apps/dokploy/drizzle/0139_brave_bloodstorm.sql
Normal file
1
apps/dokploy/drizzle/0139_brave_bloodstorm.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "invitation" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;
|
||||
1
apps/dokploy/drizzle/0140_lame_mattie_franklin.sql
Normal file
1
apps/dokploy/drizzle/0140_lame_mattie_franklin.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "gitlab" ADD COLUMN "gitlabInternalUrl" text;
|
||||
1
apps/dokploy/drizzle/0141_plain_earthquake.sql
Normal file
1
apps/dokploy/drizzle/0141_plain_earthquake.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "gitea" ADD COLUMN "giteaInternalUrl" text;
|
||||
6
apps/dokploy/drizzle/0142_outstanding_tusk.sql
Normal file
6
apps/dokploy/drizzle/0142_outstanding_tusk.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "application" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mariadb" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mongo" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mysql" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "postgres" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "redis" ADD COLUMN "ulimitsSwarm" json;
|
||||
7229
apps/dokploy/drizzle/meta/0138_snapshot.json
Normal file
7229
apps/dokploy/drizzle/meta/0138_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7236
apps/dokploy/drizzle/meta/0139_snapshot.json
Normal file
7236
apps/dokploy/drizzle/meta/0139_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7242
apps/dokploy/drizzle/meta/0140_snapshot.json
Normal file
7242
apps/dokploy/drizzle/meta/0140_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7248
apps/dokploy/drizzle/meta/0141_snapshot.json
Normal file
7248
apps/dokploy/drizzle/meta/0141_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7284
apps/dokploy/drizzle/meta/0142_snapshot.json
Normal file
7284
apps/dokploy/drizzle/meta/0142_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -967,6 +967,41 @@
|
||||
"when": 1770274109332,
|
||||
"tag": "0137_colossal_sally_floyd",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 138,
|
||||
"version": "7",
|
||||
"when": 1770324882572,
|
||||
"tag": "0138_pretty_ironclad",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 139,
|
||||
"version": "7",
|
||||
"when": 1770442690721,
|
||||
"tag": "0139_brave_bloodstorm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 140,
|
||||
"version": "7",
|
||||
"when": 1770489900075,
|
||||
"tag": "0140_lame_mattie_franklin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 141,
|
||||
"version": "7",
|
||||
"when": 1770490719123,
|
||||
"tag": "0141_plain_earthquake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 142,
|
||||
"version": "7",
|
||||
"when": 1770615019498,
|
||||
"tag": "0142_outstanding_tusk",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -24,6 +24,8 @@ try {
|
||||
.build({
|
||||
entryPoints: {
|
||||
server: "server/server.ts",
|
||||
migration: "migration.ts",
|
||||
"wait-for-postgres": "wait-for-postgres.ts",
|
||||
"reset-password": "reset-password.ts",
|
||||
"reset-2fa": "reset-2fa.ts",
|
||||
},
|
||||
|
||||
92
apps/dokploy/hooks/use-health-check-after-mutation.ts
Normal file
92
apps/dokploy/hooks/use-health-check-after-mutation.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const HEALTH_CHECK_URL = "/api/health";
|
||||
|
||||
export interface UseHealthCheckAfterMutationOptions {
|
||||
/**
|
||||
* Delay in ms before starting to poll the health endpoint.
|
||||
* Gives time for the service (e.g. Traefik) to restart.
|
||||
* @default 5000
|
||||
*/
|
||||
initialDelay?: number;
|
||||
/**
|
||||
* Delay in ms between each health check poll.
|
||||
* @default 2000
|
||||
*/
|
||||
pollInterval?: number;
|
||||
/**
|
||||
* Message shown in toast when the operation completes successfully.
|
||||
*/
|
||||
successMessage: string;
|
||||
/**
|
||||
* Callback when health check passes. Use for refetching data.
|
||||
*/
|
||||
onSuccess?: () => void | Promise<void>;
|
||||
/**
|
||||
* If true, reloads the page when health check passes (e.g. for server update).
|
||||
* @default false
|
||||
*/
|
||||
reloadOnSuccess?: boolean;
|
||||
}
|
||||
|
||||
export const useHealthCheckAfterMutation = ({
|
||||
initialDelay = 5000,
|
||||
pollInterval = 2000,
|
||||
successMessage,
|
||||
onSuccess,
|
||||
reloadOnSuccess = false,
|
||||
}: UseHealthCheckAfterMutationOptions) => {
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
|
||||
const checkHealth = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(HEALTH_CHECK_URL);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pollUntilHealthy = useCallback(async (): Promise<void> => {
|
||||
const isHealthy = await checkHealth();
|
||||
|
||||
if (isHealthy) {
|
||||
toast.success(successMessage);
|
||||
|
||||
if (reloadOnSuccess) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
await onSuccess?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
await pollUntilHealthy();
|
||||
}, [checkHealth, successMessage, reloadOnSuccess, onSuccess, pollInterval]);
|
||||
|
||||
const execute = useCallback(
|
||||
async <T>(mutationFn: () => Promise<T>): Promise<T> => {
|
||||
setIsExecuting(true);
|
||||
|
||||
try {
|
||||
const result = await mutationFn();
|
||||
|
||||
// Give time for the service to restart before polling
|
||||
await new Promise((resolve) => setTimeout(resolve, initialDelay));
|
||||
|
||||
await pollUntilHealthy();
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
},
|
||||
[initialDelay, pollUntilHealthy],
|
||||
);
|
||||
|
||||
return { execute, isExecuting };
|
||||
};
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.26.7",
|
||||
"version": "v0.27.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build-server && npm run build-next",
|
||||
"start": "node -r dotenv/config dist/server.mjs",
|
||||
"start": "node -r dotenv/config dist/migration.mjs && node -r dotenv/config dist/server.mjs",
|
||||
"build-server": "tsx esbuild.config.ts",
|
||||
"build-next": "next build",
|
||||
"build-next": "next build --webpack",
|
||||
"setup": "tsx -r dotenv/config setup.ts && sleep 5 && pnpm run migration:run",
|
||||
"wait-for-postgres": "node -r dotenv/config dist/wait-for-postgres.mjs",
|
||||
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
|
||||
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
||||
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
|
||||
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
||||
@@ -37,6 +39,7 @@
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"resend": "^6.0.2",
|
||||
"@better-auth/sso": "1.4.18",
|
||||
"@ai-sdk/anthropic": "^2.0.5",
|
||||
"@ai-sdk/azure": "^2.0.16",
|
||||
@@ -98,7 +101,7 @@
|
||||
"better-auth": "1.4.18",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.4.2",
|
||||
"bullmq": "5.67.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -355,6 +355,11 @@ export default async function handler(
|
||||
action === "labeled" ||
|
||||
action === "unlabeled"
|
||||
) {
|
||||
const shouldCreateDeployment =
|
||||
action === "opened" ||
|
||||
action === "synchronize" ||
|
||||
action === "reopened";
|
||||
|
||||
const repository = githubBody?.repository?.name;
|
||||
const deploymentHash = githubBody?.pull_request?.head?.sha;
|
||||
const branch = githubBody?.pull_request?.base?.ref;
|
||||
@@ -475,7 +480,7 @@ export default async function handler(
|
||||
let previewDeploymentId =
|
||||
previewDeploymentResult?.previewDeploymentId || "";
|
||||
|
||||
if (!previewDeploymentResult) {
|
||||
if (!previewDeploymentResult && shouldCreateDeployment) {
|
||||
const previewDeployment = await createPreviewDeployment({
|
||||
applicationId: app.applicationId as string,
|
||||
branch: prBranch,
|
||||
@@ -497,21 +502,23 @@ export default async function handler(
|
||||
previewDeploymentId,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && app.serverId) {
|
||||
jobData.serverId = app.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
continue;
|
||||
if (previewDeploymentId) {
|
||||
if (IS_CLOUD && app.serverId) {
|
||||
jobData.serverId = app.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
return res.status(200).json({ message: "Apps Deployed" });
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ const parseState = (state: string): string | null => {
|
||||
|
||||
// Helper to fetch access token from Gitea
|
||||
const fetchAccessToken = async (gitea: Gitea, code: string) => {
|
||||
const response = await fetch(`${gitea.giteaUrl}/login/oauth/access_token`, {
|
||||
// Use internal URL for token exchange when Gitea is on same instance as Dokploy
|
||||
const baseUrl = gitea.giteaInternalUrl || gitea.giteaUrl;
|
||||
const response = await fetch(`${baseUrl}/login/oauth/access_token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface Gitea {
|
||||
refreshToken: string | null;
|
||||
expiresAt: number | null;
|
||||
giteaUrl: string;
|
||||
giteaInternalUrl: string | null;
|
||||
clientId: string | null;
|
||||
clientSecret: string | null;
|
||||
organizationName?: string;
|
||||
|
||||
@@ -12,7 +12,9 @@ export default async function handler(
|
||||
}
|
||||
|
||||
const gitlab = await findGitlabById(gitlabId as string);
|
||||
const gitlabUrl = new URL(gitlab.gitlabUrl);
|
||||
// Use internal URL for token exchange when GitLab is on same instance as Dokploy
|
||||
const baseUrl = gitlab.gitlabInternalUrl || gitlab.gitlabUrl;
|
||||
const gitlabUrl = new URL(baseUrl);
|
||||
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IS_CLOUD, validateRequest } from "@dokploy/server";
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
@@ -45,7 +45,7 @@ export async function getServerSideProps(
|
||||
},
|
||||
};
|
||||
}
|
||||
if (user.role === "member") {
|
||||
if (user.role !== "owner") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 4.6 KiB |
@@ -57,9 +57,11 @@ import {
|
||||
apiUpdateApplication,
|
||||
applications,
|
||||
} from "@/server/db/schema";
|
||||
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import {
|
||||
cleanQueuesByApplication,
|
||||
getJobsByApplicationId,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
@@ -240,6 +242,15 @@ export const applicationRouter = createTRPCRouter({
|
||||
.where(eq(applications.applicationId, input.applicationId))
|
||||
.returning();
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
const queueJobs = await getJobsByApplicationId(input.applicationId);
|
||||
for (const job of queueJobs) {
|
||||
if (job.id) {
|
||||
deploymentWorker.cancelJob(job.id, "User requested cancellation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupOperations = [
|
||||
async () => await deleteAllMiddlewares(application),
|
||||
async () => await removeDeployments(application),
|
||||
|
||||
@@ -58,9 +58,11 @@ import {
|
||||
apiUpdateCompose,
|
||||
compose as composeTable,
|
||||
} from "@/server/db/schema";
|
||||
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import {
|
||||
cleanQueuesByCompose,
|
||||
getJobsByComposeId,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
@@ -222,6 +224,15 @@ export const composeRouter = createTRPCRouter({
|
||||
.where(eq(composeTable.composeId, input.composeId))
|
||||
.returning();
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
const queueJobs = await getJobsByComposeId(input.composeId);
|
||||
for (const job of queueJobs) {
|
||||
if (job.id) {
|
||||
deploymentWorker.cancelJob(job.id, "User requested cancellation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupOperations = [
|
||||
async () => await removeCompose(composeResult, input.deleteVolumes),
|
||||
async () => await removeDeploymentsByComposeId(composeResult),
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
findDomainById,
|
||||
findDomainsByApplicationId,
|
||||
findDomainsByComposeId,
|
||||
findOrganizationById,
|
||||
findPreviewDeploymentById,
|
||||
findServerById,
|
||||
generateTraefikMeDomain,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
addNewService,
|
||||
checkPortInUse,
|
||||
checkServiceAccess,
|
||||
createMariadb,
|
||||
createMount,
|
||||
@@ -162,9 +163,9 @@ export const mariadbRouter = createTRPCRouter({
|
||||
saveExternalPort: protectedProcedure
|
||||
.input(apiSaveExternalPortMariaDB)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mongo = await findMariadbById(input.mariadbId);
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
@@ -172,11 +173,25 @@ export const mariadbRouter = createTRPCRouter({
|
||||
message: "You are not authorized to save this external port",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.externalPort) {
|
||||
const portCheck = await checkPortInUse(
|
||||
input.externalPort,
|
||||
mariadb.serverId || undefined,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await updateMariadbById(input.mariadbId, {
|
||||
externalPort: input.externalPort,
|
||||
});
|
||||
await deployMariadb(input.mariadbId);
|
||||
return mongo;
|
||||
return mariadb;
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
.input(apiDeployMariaDB)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
addNewService,
|
||||
checkPortInUse,
|
||||
checkServiceAccess,
|
||||
createMongo,
|
||||
createMount,
|
||||
@@ -189,6 +190,20 @@ export const mongoRouter = createTRPCRouter({
|
||||
message: "You are not authorized to save this external port",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.externalPort) {
|
||||
const portCheck = await checkPortInUse(
|
||||
input.externalPort,
|
||||
mongo.serverId || undefined,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await updateMongoById(input.mongoId, {
|
||||
externalPort: input.externalPort,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
addNewService,
|
||||
checkPortInUse,
|
||||
checkServiceAccess,
|
||||
createMount,
|
||||
createMysql,
|
||||
@@ -177,9 +178,9 @@ export const mysqlRouter = createTRPCRouter({
|
||||
saveExternalPort: protectedProcedure
|
||||
.input(apiSaveExternalPortMySql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mongo = await findMySqlById(input.mysqlId);
|
||||
const mysql = await findMySqlById(input.mysqlId);
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
mysql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
@@ -187,11 +188,25 @@ export const mysqlRouter = createTRPCRouter({
|
||||
message: "You are not authorized to save this external port",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.externalPort) {
|
||||
const portCheck = await checkPortInUse(
|
||||
input.externalPort,
|
||||
mysql.serverId || undefined,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await updateMySqlById(input.mysqlId, {
|
||||
externalPort: input.externalPort,
|
||||
});
|
||||
await deployMySql(input.mysqlId);
|
||||
return mongo;
|
||||
return mysql;
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
.input(apiDeployMySql)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createLarkNotification,
|
||||
createNtfyNotification,
|
||||
createPushoverNotification,
|
||||
createResendNotification,
|
||||
createSlackNotification,
|
||||
createTelegramNotification,
|
||||
findNotificationById,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
sendLarkNotification,
|
||||
sendNtfyNotification,
|
||||
sendPushoverNotification,
|
||||
sendResendNotification,
|
||||
sendServerThresholdNotifications,
|
||||
sendSlackNotification,
|
||||
sendTelegramNotification,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
updateLarkNotification,
|
||||
updateNtfyNotification,
|
||||
updatePushoverNotification,
|
||||
updateResendNotification,
|
||||
updateSlackNotification,
|
||||
updateTelegramNotification,
|
||||
} from "@dokploy/server";
|
||||
@@ -50,6 +53,7 @@ import {
|
||||
apiCreateLark,
|
||||
apiCreateNtfy,
|
||||
apiCreatePushover,
|
||||
apiCreateResend,
|
||||
apiCreateSlack,
|
||||
apiCreateTelegram,
|
||||
apiFindOneNotification,
|
||||
@@ -60,6 +64,7 @@ import {
|
||||
apiTestLarkConnection,
|
||||
apiTestNtfyConnection,
|
||||
apiTestPushoverConnection,
|
||||
apiTestResendConnection,
|
||||
apiTestSlackConnection,
|
||||
apiTestTelegramConnection,
|
||||
apiUpdateCustom,
|
||||
@@ -69,6 +74,7 @@ import {
|
||||
apiUpdateLark,
|
||||
apiUpdateNtfy,
|
||||
apiUpdatePushover,
|
||||
apiUpdateResend,
|
||||
apiUpdateSlack,
|
||||
apiUpdateTelegram,
|
||||
notifications,
|
||||
@@ -302,6 +308,63 @@ export const notificationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
createResend: adminProcedure
|
||||
.input(apiCreateResend)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await createResendNotification(
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
updateResend: adminProcedure
|
||||
.input(apiUpdateResend)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const notification = await findNotificationById(input.notificationId);
|
||||
if (notification.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to update this notification",
|
||||
});
|
||||
}
|
||||
return await updateResendNotification({
|
||||
...input,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error updating the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
testResendConnection: adminProcedure
|
||||
.input(apiTestResendConnection)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await sendResendNotification(
|
||||
input,
|
||||
"Test Email",
|
||||
"<p>Hi, From Dokploy 👋</p>",
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
remove: adminProcedure
|
||||
.input(apiFindOneNotification)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@@ -344,6 +407,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
telegram: true,
|
||||
discord: true,
|
||||
email: true,
|
||||
resend: true,
|
||||
gotify: true,
|
||||
ntfy: true,
|
||||
custom: true,
|
||||
@@ -702,6 +766,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
|
||||
with: {
|
||||
email: true,
|
||||
resend: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
addNewService,
|
||||
checkPortInUse,
|
||||
checkServiceAccess,
|
||||
createMount,
|
||||
createPostgres,
|
||||
@@ -192,6 +193,20 @@ export const postgresRouter = createTRPCRouter({
|
||||
message: "You are not authorized to save this external port",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.externalPort) {
|
||||
const portCheck = await checkPortInUse(
|
||||
input.externalPort,
|
||||
postgres.serverId || undefined,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await updatePostgresById(input.postgresId, {
|
||||
externalPort: input.externalPort,
|
||||
});
|
||||
|
||||
@@ -506,7 +506,7 @@ export const projectRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
for (const backup of backups) {
|
||||
const { backupId, ...rest } = backup;
|
||||
const { backupId, appName: _appName, ...rest } = backup;
|
||||
await createBackup({
|
||||
...rest,
|
||||
postgresId: newPostgres.postgresId,
|
||||
@@ -542,7 +542,7 @@ export const projectRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
for (const backup of backups) {
|
||||
const { backupId, ...rest } = backup;
|
||||
const { backupId, appName: _appName, ...rest } = backup;
|
||||
await createBackup({
|
||||
...rest,
|
||||
mariadbId: newMariadb.mariadbId,
|
||||
@@ -578,7 +578,7 @@ export const projectRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
for (const backup of backups) {
|
||||
const { backupId, ...rest } = backup;
|
||||
const { backupId, appName: _appName, ...rest } = backup;
|
||||
await createBackup({
|
||||
...rest,
|
||||
mongoId: newMongo.mongoId,
|
||||
@@ -614,7 +614,7 @@ export const projectRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
for (const backup of backups) {
|
||||
const { backupId, ...rest } = backup;
|
||||
const { backupId, appName: _appName, ...rest } = backup;
|
||||
await createBackup({
|
||||
...rest,
|
||||
mysqlId: newMysql.mysqlId,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
export const licenseKeyRouter = createTRPCRouter({
|
||||
activate: adminProcedure
|
||||
.input(z.object({ licenseKey: z.string() }))
|
||||
.input(z.object({ licenseKey: z.string().min(1) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const currentUserId = ctx.user.id;
|
||||
@@ -74,6 +74,13 @@ export const licenseKeyRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not authorized to validate a license key",
|
||||
});
|
||||
}
|
||||
|
||||
if (!currentUser.licenseKey) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -164,6 +171,13 @@ export const licenseKeyRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not authorized to get enterprise settings",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
enableEnterpriseFeatures: !!currentUser.enableEnterpriseFeatures,
|
||||
licenseKey: currentUser.licenseKey ?? "",
|
||||
@@ -200,6 +214,13 @@ export const licenseKeyRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not authorized to update enterprise settings",
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
addNewService,
|
||||
checkPortInUse,
|
||||
checkServiceAccess,
|
||||
createMount,
|
||||
createRedis,
|
||||
@@ -201,9 +202,9 @@ export const redisRouter = createTRPCRouter({
|
||||
saveExternalPort: protectedProcedure
|
||||
.input(apiSaveExternalPortRedis)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mongo = await findRedisById(input.redisId);
|
||||
const redis = await findRedisById(input.redisId);
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
redis.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
@@ -211,11 +212,25 @@ export const redisRouter = createTRPCRouter({
|
||||
message: "You are not authorized to save this external port",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.externalPort) {
|
||||
const portCheck = await checkPortInUse(
|
||||
input.externalPort,
|
||||
redis.serverId || undefined,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await updateRedisById(input.redisId, {
|
||||
externalPort: input.externalPort,
|
||||
});
|
||||
await deployRedis(input.redisId);
|
||||
return mongo;
|
||||
return redis;
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
.input(apiDeployRedis)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CLEANUP_CRON_JOB,
|
||||
canAccessToTraefikFiles,
|
||||
checkGPUStatus,
|
||||
checkPortInUse,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
DEFAULT_UPDATE_DATA,
|
||||
execAsync,
|
||||
findServerById,
|
||||
getDokployImage,
|
||||
getDokployImageTag,
|
||||
getLogCleanupStatus,
|
||||
getUpdateData,
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
paths,
|
||||
prepareEnvironmentVariables,
|
||||
processLogs,
|
||||
pullLatestRelease,
|
||||
readConfig,
|
||||
readConfigInPath,
|
||||
readDirectory,
|
||||
@@ -66,6 +65,7 @@ import {
|
||||
projects,
|
||||
server,
|
||||
} from "@/server/db/schema";
|
||||
import { cleanAllDeploymentQueue } from "@/server/queues/queueSetup";
|
||||
import { removeJob, schedule } from "@/server/utils/backup";
|
||||
import packageInfo from "../../../package.json";
|
||||
import { appRouter } from "../root";
|
||||
@@ -117,15 +117,21 @@ export const settingsRouter = createTRPCRouter({
|
||||
|
||||
return true;
|
||||
}),
|
||||
cleanAllDeploymentQueue: adminProcedure.mutation(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
return cleanAllDeploymentQueue();
|
||||
}),
|
||||
reloadTraefik: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await reloadDockerResource("dokploy-traefik", input?.serverId);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// Run in background so the request returns immediately; avoids proxy timeouts.
|
||||
void reloadDockerResource("dokploy-traefik", input?.serverId).catch(
|
||||
(err) => {
|
||||
console.error("reloadTraefik background:", err);
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}),
|
||||
toggleDashboard: adminProcedure
|
||||
@@ -160,10 +166,14 @@ export const settingsRouter = createTRPCRouter({
|
||||
newPorts = ports.filter((port) => port.targetPort !== 8080);
|
||||
}
|
||||
|
||||
await writeTraefikSetup({
|
||||
// Run in background so the request returns immediately; client polls /api/health.
|
||||
// Avoids proxy timeouts (520) while Traefik is recreated.
|
||||
void writeTraefikSetup({
|
||||
env: preparedEnv,
|
||||
additionalPorts: newPorts,
|
||||
serverId: input.serverId,
|
||||
}).catch((err) => {
|
||||
console.error("toggleDashboard background writeTraefikSetup:", err);
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
@@ -289,12 +299,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
}
|
||||
if (IS_CLOUD) {
|
||||
await schedule({
|
||||
cronSchedule: "0 0 * * *",
|
||||
cronSchedule: CLEANUP_CRON_JOB,
|
||||
serverId: input.serverId,
|
||||
type: "server",
|
||||
});
|
||||
} else {
|
||||
scheduleJob(server.serverId, "0 0 * * *", async () => {
|
||||
scheduleJob(server.serverId, CLEANUP_CRON_JOB, async () => {
|
||||
console.log(
|
||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||
);
|
||||
@@ -307,7 +317,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
} else {
|
||||
if (IS_CLOUD) {
|
||||
await removeJob({
|
||||
cronSchedule: "0 0 * * *",
|
||||
cronSchedule: CLEANUP_CRON_JOB,
|
||||
serverId: input.serverId,
|
||||
type: "server",
|
||||
});
|
||||
@@ -322,7 +332,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (settingsUpdated?.enableDockerCleanup) {
|
||||
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
|
||||
scheduleJob("docker-cleanup", CLEANUP_CRON_JOB, async () => {
|
||||
console.log(
|
||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||
);
|
||||
@@ -406,18 +416,17 @@ export const settingsRouter = createTRPCRouter({
|
||||
return true;
|
||||
}
|
||||
|
||||
await pullLatestRelease();
|
||||
|
||||
// This causes restart of dokploy, thus it will not finish executing properly, so don't await it
|
||||
// Status after restart is checked via frontend /api/health endpoint
|
||||
void spawnAsync("docker", [
|
||||
"service",
|
||||
"update",
|
||||
"--force",
|
||||
"--image",
|
||||
getDokployImage(),
|
||||
"dokploy",
|
||||
]);
|
||||
const data = await getUpdateData(packageInfo.version);
|
||||
if (data.updateAvailable) {
|
||||
void spawnAsync("docker", [
|
||||
"service",
|
||||
"update",
|
||||
"--force",
|
||||
"--image",
|
||||
`dokploy/dokploy:${data.latestVersion}`,
|
||||
"dokploy",
|
||||
]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
@@ -604,12 +613,14 @@ export const settingsRouter = createTRPCRouter({
|
||||
const envs = prepareEnvironmentVariables(input.env);
|
||||
const ports = await readPorts("dokploy-traefik", input?.serverId);
|
||||
|
||||
await writeTraefikSetup({
|
||||
// Run in background so the request returns immediately; client polls /api/health.
|
||||
void writeTraefikSetup({
|
||||
env: envs,
|
||||
additionalPorts: ports,
|
||||
serverId: input.serverId,
|
||||
}).catch((err) => {
|
||||
console.error("writeTraefikEnv background writeTraefikSetup:", err);
|
||||
});
|
||||
|
||||
return true;
|
||||
}),
|
||||
haveTraefikDashboardPortEnabled: adminProcedure
|
||||
@@ -753,16 +764,13 @@ export const settingsRouter = createTRPCRouter({
|
||||
return haveServers.length > 0 || haveProjects.length > 0;
|
||||
}),
|
||||
health: publicProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { status: "ok" };
|
||||
} catch (error) {
|
||||
console.error("Database connection error:", error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { status: "ok" };
|
||||
} catch (error) {
|
||||
console.error("Database connection error:", error);
|
||||
throw error;
|
||||
}
|
||||
return { status: "not_cloud" };
|
||||
}),
|
||||
setupGPU: adminProcedure
|
||||
.input(
|
||||
@@ -857,10 +865,16 @@ export const settingsRouter = createTRPCRouter({
|
||||
}
|
||||
const preparedEnv = prepareEnvironmentVariables(env);
|
||||
|
||||
await writeTraefikSetup({
|
||||
// Run in background so the request returns immediately; client polls /api/health.
|
||||
void writeTraefikSetup({
|
||||
env: preparedEnv,
|
||||
additionalPorts: input.additionalPorts,
|
||||
serverId: input.serverId,
|
||||
}).catch((err) => {
|
||||
console.error(
|
||||
"updateTraefikPorts background writeTraefikSetup:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IS_CLOUD,
|
||||
removeUserById,
|
||||
sendEmailNotification,
|
||||
sendResendNotification,
|
||||
updateUser,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
@@ -509,15 +510,16 @@ export const userRouter = createTRPCRouter({
|
||||
const notification = await findNotificationById(input.notificationId);
|
||||
|
||||
const email = notification.email;
|
||||
const resend = notification.resend;
|
||||
|
||||
const currentInvitation = await db.query.invitation.findFirst({
|
||||
where: eq(invitation.id, input.invitationId),
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
if (!email && !resend) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Email notification not found",
|
||||
message: "Email provider not found",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -532,16 +534,29 @@ export const userRouter = createTRPCRouter({
|
||||
);
|
||||
|
||||
try {
|
||||
await sendEmailNotification(
|
||||
{
|
||||
...email,
|
||||
toAddresses: [currentInvitation?.email || ""],
|
||||
},
|
||||
"Invitation to join organization",
|
||||
`
|
||||
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
|
||||
`,
|
||||
);
|
||||
const htmlContent = `
|
||||
\t\t\t\t<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
|
||||
\t\t\t\t`;
|
||||
|
||||
if (email) {
|
||||
await sendEmailNotification(
|
||||
{
|
||||
...email,
|
||||
toAddresses: [currentInvitation?.email || ""],
|
||||
},
|
||||
"Invitation to join organization",
|
||||
htmlContent,
|
||||
);
|
||||
} else if (resend) {
|
||||
await sendResendNotification(
|
||||
{
|
||||
...resend,
|
||||
toAddresses: [currentInvitation?.email || ""],
|
||||
},
|
||||
"Invitation to join organization",
|
||||
htmlContent,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
@@ -54,6 +54,7 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
redis: true,
|
||||
compose: true,
|
||||
},
|
||||
orderBy: [desc(volumeBackups.createdAt)],
|
||||
});
|
||||
}),
|
||||
create: protectedProcedure
|
||||
|
||||
@@ -3,12 +3,23 @@ import {
|
||||
execAsyncRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { Queue } from "bullmq";
|
||||
import { deploymentWorker } from "./deployments-queue";
|
||||
import { redisConfig } from "./redis-connection";
|
||||
|
||||
const myQueue = new Queue("deployments", {
|
||||
connection: redisConfig,
|
||||
});
|
||||
|
||||
export const getJobsByApplicationId = async (applicationId: string) => {
|
||||
const jobs = await myQueue.getJobs();
|
||||
return jobs.filter((job) => job?.data?.applicationId === applicationId);
|
||||
};
|
||||
|
||||
export const getJobsByComposeId = async (composeId: string) => {
|
||||
const jobs = await myQueue.getJobs();
|
||||
return jobs.filter((job) => job?.data?.composeId === composeId);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
myQueue.close();
|
||||
process.exit(0);
|
||||
@@ -34,6 +45,11 @@ export const cleanQueuesByApplication = async (applicationId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanAllDeploymentQueue = async () => {
|
||||
deploymentWorker.cancelAllJobs("User requested cancellation");
|
||||
return true;
|
||||
};
|
||||
|
||||
export const cleanQueuesByCompose = async (composeId: string) => {
|
||||
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { config } from "dotenv";
|
||||
import next from "next";
|
||||
import { migration } from "@/server/db/migration";
|
||||
import packageInfo from "../package.json";
|
||||
import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs";
|
||||
import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal";
|
||||
@@ -60,7 +59,6 @@ void app.prepare().then(async () => {
|
||||
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
|
||||
createDefaultMiddlewares();
|
||||
await initializeNetwork();
|
||||
await migration();
|
||||
await initCronJobs();
|
||||
await initSchedules();
|
||||
await initCancelDeployments();
|
||||
@@ -68,10 +66,6 @@ void app.prepare().then(async () => {
|
||||
await sendDokployRestartNotifications();
|
||||
}
|
||||
|
||||
if (IS_CLOUD && process.env.NODE_ENV === "production") {
|
||||
await migration();
|
||||
}
|
||||
|
||||
server.listen(PORT, HOST);
|
||||
console.log(`Server Started on: http://${HOST}:${PORT}`);
|
||||
await initEnterpriseBackupCronJobs();
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { getPublicIpWithFallback, LICENSE_KEY_URL } from "@dokploy/server";
|
||||
|
||||
const LICENSE_SERVER_UNREACHABLE =
|
||||
"Could not reach the license server. Check your connection or try again later.";
|
||||
|
||||
function isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "fetch failed") return true;
|
||||
const cause = (error as Error & { cause?: { code?: string } }).cause;
|
||||
const code = cause?.code;
|
||||
return code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const validateLicenseKey = async (licenseKey: string) => {
|
||||
try {
|
||||
const ip = await getPublicIpWithFallback();
|
||||
@@ -22,6 +35,9 @@ export const validateLicenseKey = async (licenseKey: string) => {
|
||||
console.error(
|
||||
error instanceof Error ? error.message : "Failed to validate license key",
|
||||
);
|
||||
if (isNetworkError(error)) {
|
||||
throw new Error(LICENSE_SERVER_UNREACHABLE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -48,6 +64,9 @@ export const activateLicenseKey = async (licenseKey: string) => {
|
||||
console.error(
|
||||
error instanceof Error ? error.message : "Failed to activate license key",
|
||||
);
|
||||
if (isNetworkError(error)) {
|
||||
throw new Error(LICENSE_SERVER_UNREACHABLE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -76,6 +95,9 @@ export const deactivateLicenseKey = async (licenseKey: string) => {
|
||||
? error.message
|
||||
: "Failed to deactivate license key",
|
||||
);
|
||||
if (isNetworkError(error)) {
|
||||
throw new Error(LICENSE_SERVER_UNREACHABLE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,14 +34,13 @@ export const setupDeploymentLogsWebSocketServer = (
|
||||
|
||||
// Generate unique connection ID for tracking
|
||||
const connectionId = `deployment-logs-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
if (!logPath) {
|
||||
console.log(`[${connectionId}] logPath no provided`);
|
||||
ws.close(4000, "logPath no provided");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!readValidDirectory(logPath)) {
|
||||
if (!readValidDirectory(logPath, serverId)) {
|
||||
ws.close(4000, "Invalid log path");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,11 @@ export const isValidShell = (shell: string): boolean => {
|
||||
return allowedShells.includes(shell);
|
||||
};
|
||||
|
||||
export const readValidDirectory = (directory: string) => {
|
||||
const { BASE_PATH } = paths();
|
||||
export const readValidDirectory = (
|
||||
directory: string,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
const { BASE_PATH } = paths(!!serverId);
|
||||
|
||||
const resolvedBase = path.resolve(BASE_PATH);
|
||||
const resolvedDir = path.resolve(directory);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { exit } from "node:process";
|
||||
import { execAsync } from "@dokploy/server";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
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";
|
||||
@@ -12,6 +15,7 @@ import {
|
||||
createDefaultServerTraefikConfig,
|
||||
createDefaultTraefikConfig,
|
||||
initializeStandaloneTraefik,
|
||||
TRAEFIK_VERSION,
|
||||
} from "@dokploy/server/setup/traefik-setup";
|
||||
|
||||
(async () => {
|
||||
@@ -22,7 +26,7 @@ import {
|
||||
await initializeNetwork();
|
||||
createDefaultTraefikConfig();
|
||||
createDefaultServerTraefikConfig();
|
||||
await execAsync("docker pull traefik:v3.6.7");
|
||||
await execAsync(`docker pull traefik:v${TRAEFIK_VERSION}`);
|
||||
await initializeStandaloneTraefik();
|
||||
await initializeRedis();
|
||||
await initializePostgres();
|
||||
|
||||
91
apps/dokploy/wait-for-postgres.ts
Normal file
91
apps/dokploy/wait-for-postgres.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import net from "node:net";
|
||||
import { URL } from "node:url";
|
||||
import { dbUrl } from "@dokploy/server/db/constants";
|
||||
|
||||
const TIMEOUT_MS = Number(process.env.POSTGRES_WAIT_TIMEOUT || 120_000);
|
||||
const RETRY_DELAY_MS = Number(process.env.POSTGRES_WAIT_RETRY || 2000);
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function resolvePostgresTarget(): { host: string; port: number } {
|
||||
const databaseUrl = dbUrl;
|
||||
|
||||
if (!databaseUrl) {
|
||||
console.error("[wait-for-postgres] DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(databaseUrl);
|
||||
|
||||
const host = url.hostname;
|
||||
const port = Number(url.port || 5432);
|
||||
|
||||
if (!host) {
|
||||
throw new Error("DATABASE_URL has no hostname");
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
} catch (err) {
|
||||
console.error("[wait-for-postgres] Invalid DATABASE_URL:", databaseUrl);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function checkTcpConnection(host: string, port: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
|
||||
socket.setTimeout(3000);
|
||||
|
||||
socket.on("connect", () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
reject(new Error("Connection timeout"));
|
||||
});
|
||||
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPostgres() {
|
||||
const { host, port } = resolvePostgresTarget();
|
||||
const start = Date.now();
|
||||
|
||||
console.log(
|
||||
`[wait-for-postgres] Waiting for postgres at ${host}:${port} (timeout ${TIMEOUT_MS}ms)`,
|
||||
);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await checkTcpConnection(host, port);
|
||||
console.log("[wait-for-postgres] Postgres is reachable ✅");
|
||||
return;
|
||||
} catch {
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
if (elapsed > TIMEOUT_MS) {
|
||||
console.error(
|
||||
`[wait-for-postgres] Timeout after ${elapsed}ms. Postgres not reachable ❌`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[wait-for-postgres] Postgres not ready yet, retrying in ${RETRY_DELAY_MS}ms...`,
|
||||
);
|
||||
await sleep(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
waitForPostgres().catch((err) => {
|
||||
console.error("[wait-for-postgres] Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -11,10 +11,10 @@
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/zod-validator": "0.3.0",
|
||||
"bullmq": "5.4.2",
|
||||
"bullmq": "5.67.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"hono": "^4.7.10",
|
||||
"hono": "^4.11.7",
|
||||
"ioredis": "5.4.1",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
|
||||
@@ -47,25 +47,25 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
|
||||
result = await removeJob({
|
||||
backupId: data.backupId,
|
||||
type: "backup",
|
||||
cronSchedule: job.pattern,
|
||||
cronSchedule: job.pattern || "",
|
||||
});
|
||||
} else if (data.type === "server") {
|
||||
result = await removeJob({
|
||||
serverId: data.serverId,
|
||||
type: "server",
|
||||
cronSchedule: job.pattern,
|
||||
cronSchedule: job.pattern || "",
|
||||
});
|
||||
} else if (data.type === "schedule") {
|
||||
result = await removeJob({
|
||||
scheduleId: data.scheduleId,
|
||||
type: "schedule",
|
||||
cronSchedule: job.pattern,
|
||||
cronSchedule: job.pattern || "",
|
||||
});
|
||||
} else if (data.type === "volume-backup") {
|
||||
result = await removeJob({
|
||||
volumeBackupId: data.volumeBackupId,
|
||||
type: "volume-backup",
|
||||
cronSchedule: job.pattern,
|
||||
cronSchedule: job.pattern || "",
|
||||
});
|
||||
}
|
||||
logger.info({ result }, "Job removed");
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Queue, type RepeatableJob } from "bullmq";
|
||||
import IORedis from "ioredis";
|
||||
import { logger } from "./logger.js";
|
||||
import type { QueueJob } from "./schema.js";
|
||||
|
||||
export const connection = new IORedis(process.env.REDIS_URL!, {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
export const jobQueue = new Queue("backupQueue", {
|
||||
connection,
|
||||
connection: {
|
||||
url: process.env.REDIS_URL!,
|
||||
},
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CLEANUP_CRON_JOB,
|
||||
cleanupAll,
|
||||
findBackupById,
|
||||
findScheduleById,
|
||||
@@ -125,7 +126,7 @@ export const initializeJobs = async () => {
|
||||
scheduleJob({
|
||||
serverId,
|
||||
type: "server",
|
||||
cronSchedule: "0 0 * * *",
|
||||
cronSchedule: CLEANUP_CRON_JOB,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type Job, Worker } from "bullmq";
|
||||
import { logger } from "./logger.js";
|
||||
import { connection } from "./queue.js";
|
||||
import type { QueueJob } from "./schema.js";
|
||||
import { runJobs } from "./utils.js";
|
||||
|
||||
@@ -12,7 +11,9 @@ export const firstWorker = new Worker(
|
||||
},
|
||||
{
|
||||
concurrency: 100,
|
||||
connection,
|
||||
connection: {
|
||||
url: process.env.REDIS_URL!,
|
||||
},
|
||||
},
|
||||
);
|
||||
export const secondWorker = new Worker(
|
||||
@@ -23,7 +24,9 @@ export const secondWorker = new Worker(
|
||||
},
|
||||
{
|
||||
concurrency: 100,
|
||||
connection,
|
||||
connection: {
|
||||
url: process.env.REDIS_URL!,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -35,6 +38,8 @@ export const thirdWorker = new Worker(
|
||||
},
|
||||
{
|
||||
concurrency: 100,
|
||||
connection,
|
||||
connection: {
|
||||
url: process.env.REDIS_URL!,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"resend": "^6.0.2",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.6",
|
||||
"ssh2": "1.15.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,14 @@ import path from "node:path";
|
||||
import Docker from "dockerode";
|
||||
|
||||
export const IS_CLOUD = process.env.IS_CLOUD === "true";
|
||||
export const CLEANUP_CRON_JOB = "50 23 * * *";
|
||||
export const docker = new Docker();
|
||||
|
||||
// When not set, use the legacy default so 2FA remains working for users who
|
||||
// enabled it before BETTER_AUTH_SECRET was introduced .
|
||||
export const BETTER_AUTH_SECRET =
|
||||
process.env.BETTER_AUTH_SECRET || "better-auth-secret-123456789";
|
||||
|
||||
export const paths = (isServer = false) => {
|
||||
const BASE_PATH =
|
||||
isServer || process.env.NODE_ENV === "production"
|
||||
@@ -25,5 +31,6 @@ export const paths = (isServer = false) => {
|
||||
REGISTRY_PATH: `${BASE_PATH}/registry`,
|
||||
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
||||
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
|
||||
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -155,6 +155,7 @@ export const invitation = pgTable("invitation", {
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
teamId: text("team_id"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
type ServiceModeSwarm,
|
||||
ServiceModeSwarmSchema,
|
||||
triggerType,
|
||||
type UlimitsSwarm,
|
||||
UlimitsSwarmSchema,
|
||||
type UpdateConfigSwarm,
|
||||
UpdateConfigSwarmSchema,
|
||||
} from "./shared";
|
||||
@@ -172,6 +174,7 @@ export const applications = pgTable("application", {
|
||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
|
||||
//
|
||||
replicas: integer("replicas").default(1).notNull(),
|
||||
applicationStatus: applicationStatus("applicationStatus")
|
||||
@@ -364,6 +367,7 @@ const createSchema = createInsertSchema(applications, {
|
||||
cleanCache: z.boolean().optional(),
|
||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
});
|
||||
|
||||
export const apiCreateApplication = createSchema.pick({
|
||||
|
||||
@@ -11,6 +11,7 @@ export const gitea = pgTable("gitea", {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
giteaUrl: text("giteaUrl").default("https://gitea.com").notNull(),
|
||||
giteaInternalUrl: text("giteaInternalUrl"),
|
||||
redirectUri: text("redirect_uri"),
|
||||
clientId: text("client_id"),
|
||||
clientSecret: text("client_secret"),
|
||||
@@ -40,6 +41,7 @@ export const apiCreateGitea = createSchema.extend({
|
||||
redirectUri: z.string().optional(),
|
||||
name: z.string().min(1),
|
||||
giteaUrl: z.string().min(1),
|
||||
giteaInternalUrl: z.string().optional().nullable(),
|
||||
giteaUsername: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
@@ -76,6 +78,7 @@ export const apiUpdateGitea = createSchema.extend({
|
||||
name: z.string().min(1),
|
||||
giteaId: z.string().min(1),
|
||||
giteaUrl: z.string().min(1),
|
||||
giteaInternalUrl: z.string().optional().nullable(),
|
||||
giteaUsername: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
|
||||
@@ -11,6 +11,7 @@ export const gitlab = pgTable("gitlab", {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
gitlabUrl: text("gitlabUrl").default("https://gitlab.com").notNull(),
|
||||
gitlabInternalUrl: text("gitlabInternalUrl"),
|
||||
applicationId: text("application_id"),
|
||||
redirectUri: text("redirect_uri"),
|
||||
secret: text("secret"),
|
||||
@@ -41,6 +42,7 @@ export const apiCreateGitlab = createSchema.extend({
|
||||
authId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
gitlabUrl: z.string().min(1),
|
||||
gitlabInternalUrl: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const apiFindOneGitlab = createSchema
|
||||
@@ -70,4 +72,5 @@ export const apiUpdateGitlab = createSchema.extend({
|
||||
name: z.string().min(1),
|
||||
gitlabId: z.string().min(1),
|
||||
gitlabUrl: z.string().min(1),
|
||||
gitlabInternalUrl: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
RestartPolicySwarmSchema,
|
||||
type ServiceModeSwarm,
|
||||
ServiceModeSwarmSchema,
|
||||
type UlimitsSwarm,
|
||||
UlimitsSwarmSchema,
|
||||
type UpdateConfigSwarm,
|
||||
UpdateConfigSwarmSchema,
|
||||
} from "./shared";
|
||||
@@ -67,6 +69,7 @@ export const mariadb = pgTable("mariadb", {
|
||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
|
||||
replicas: integer("replicas").default(1).notNull(),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
@@ -141,6 +144,7 @@ const createSchema = createInsertSchema(mariadb, {
|
||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
});
|
||||
|
||||
export const apiCreateMariaDB = createSchema.pick({
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
RestartPolicySwarmSchema,
|
||||
type ServiceModeSwarm,
|
||||
ServiceModeSwarmSchema,
|
||||
type UlimitsSwarm,
|
||||
UlimitsSwarmSchema,
|
||||
type UpdateConfigSwarm,
|
||||
UpdateConfigSwarmSchema,
|
||||
} from "./shared";
|
||||
@@ -70,6 +72,7 @@ export const mongo = pgTable("mongo", {
|
||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
|
||||
replicas: integer("replicas").default(1).notNull(),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
@@ -138,6 +141,7 @@ const createSchema = createInsertSchema(mongo, {
|
||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
});
|
||||
|
||||
export const apiCreateMongo = createSchema.pick({
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
RestartPolicySwarmSchema,
|
||||
type ServiceModeSwarm,
|
||||
ServiceModeSwarmSchema,
|
||||
type UlimitsSwarm,
|
||||
UlimitsSwarmSchema,
|
||||
type UpdateConfigSwarm,
|
||||
UpdateConfigSwarmSchema,
|
||||
} from "./shared";
|
||||
@@ -65,6 +67,7 @@ export const mysql = pgTable("mysql", {
|
||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
|
||||
replicas: integer("replicas").default(1).notNull(),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
@@ -138,6 +141,7 @@ const createSchema = createInsertSchema(mysql, {
|
||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
});
|
||||
|
||||
export const apiCreateMySql = createSchema.pick({
|
||||
|
||||
@@ -17,6 +17,7 @@ export const notificationType = pgEnum("notificationType", [
|
||||
"telegram",
|
||||
"discord",
|
||||
"email",
|
||||
"resend",
|
||||
"gotify",
|
||||
"ntfy",
|
||||
"pushover",
|
||||
@@ -53,6 +54,9 @@ export const notifications = pgTable("notification", {
|
||||
emailId: text("emailId").references(() => email.emailId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
resendId: text("resendId").references(() => resend.resendId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
gotifyId: text("gotifyId").references(() => gotify.gotifyId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
@@ -114,6 +118,16 @@ export const email = pgTable("email", {
|
||||
toAddresses: text("toAddress").array().notNull(),
|
||||
});
|
||||
|
||||
export const resend = pgTable("resend", {
|
||||
resendId: text("resendId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
apiKey: text("apiKey").notNull(),
|
||||
fromAddress: text("fromAddress").notNull(),
|
||||
toAddresses: text("toAddress").array().notNull(),
|
||||
});
|
||||
|
||||
export const gotify = pgTable("gotify", {
|
||||
gotifyId: text("gotifyId")
|
||||
.notNull()
|
||||
@@ -182,6 +196,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||
fields: [notifications.emailId],
|
||||
references: [email.emailId],
|
||||
}),
|
||||
resend: one(resend, {
|
||||
fields: [notifications.resendId],
|
||||
references: [resend.resendId],
|
||||
}),
|
||||
gotify: one(gotify, {
|
||||
fields: [notifications.gotifyId],
|
||||
references: [gotify.gotifyId],
|
||||
@@ -335,6 +353,36 @@ export const apiTestEmailConnection = apiCreateEmail.pick({
|
||||
fromAddress: true,
|
||||
});
|
||||
|
||||
export const apiCreateResend = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
dockerCleanup: true,
|
||||
serverThreshold: true,
|
||||
})
|
||||
.extend({
|
||||
apiKey: z.string().min(1),
|
||||
fromAddress: z.string().min(1),
|
||||
toAddresses: z.array(z.string()).min(1),
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiUpdateResend = apiCreateResend.partial().extend({
|
||||
notificationId: z.string().min(1),
|
||||
resendId: z.string().min(1),
|
||||
organizationId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiTestResendConnection = apiCreateResend.pick({
|
||||
apiKey: true,
|
||||
fromAddress: true,
|
||||
toAddresses: true,
|
||||
});
|
||||
|
||||
export const apiCreateGotify = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
@@ -534,6 +582,7 @@ export const apiSendTest = notificationsSchema
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
toAddresses: z.array(z.string()),
|
||||
apiKey: z.string(),
|
||||
serverUrl: z.string(),
|
||||
topic: z.string(),
|
||||
appToken: z.string(),
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
RestartPolicySwarmSchema,
|
||||
type ServiceModeSwarm,
|
||||
ServiceModeSwarmSchema,
|
||||
type UlimitsSwarm,
|
||||
UlimitsSwarmSchema,
|
||||
type UpdateConfigSwarm,
|
||||
UpdateConfigSwarmSchema,
|
||||
} from "./shared";
|
||||
@@ -65,6 +67,7 @@ export const postgres = pgTable("postgres", {
|
||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
|
||||
replicas: integer("replicas").default(1).notNull(),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
@@ -132,6 +135,7 @@ const createSchema = createInsertSchema(postgres, {
|
||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
});
|
||||
|
||||
export const apiCreatePostgres = createSchema.pick({
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
RestartPolicySwarmSchema,
|
||||
type ServiceModeSwarm,
|
||||
ServiceModeSwarmSchema,
|
||||
type UlimitsSwarm,
|
||||
UlimitsSwarmSchema,
|
||||
type UpdateConfigSwarm,
|
||||
UpdateConfigSwarmSchema,
|
||||
} from "./shared";
|
||||
@@ -64,6 +66,7 @@ export const redis = pgTable("redis", {
|
||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
|
||||
replicas: integer("replicas").default(1).notNull(),
|
||||
|
||||
environmentId: text("environmentId")
|
||||
@@ -120,6 +123,7 @@ const createSchema = createInsertSchema(redis, {
|
||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
});
|
||||
|
||||
export const apiCreateRedis = createSchema.pick({
|
||||
|
||||
@@ -471,6 +471,7 @@ table git_provider {
|
||||
table gitea {
|
||||
giteaId text [pk, not null]
|
||||
giteaUrl text [not null, default: 'https://gitea.com']
|
||||
giteaInternalUrl text
|
||||
redirect_uri text
|
||||
client_id text
|
||||
client_secret text
|
||||
@@ -497,6 +498,7 @@ table github {
|
||||
table gitlab {
|
||||
gitlabId text [pk, not null]
|
||||
gitlabUrl text [not null, default: 'https://gitlab.com']
|
||||
gitlabInternalUrl text
|
||||
application_id text
|
||||
redirect_uri text
|
||||
secret text
|
||||
|
||||
@@ -86,6 +86,14 @@ export interface EndpointSpecSwarm {
|
||||
Ports?: EndpointPortConfigSwarm[] | undefined;
|
||||
}
|
||||
|
||||
export interface UlimitSwarm {
|
||||
Name: string;
|
||||
Soft: number;
|
||||
Hard: number;
|
||||
}
|
||||
|
||||
export type UlimitsSwarm = UlimitSwarm[];
|
||||
|
||||
export const HealthCheckSwarmSchema = z
|
||||
.object({
|
||||
Test: z.array(z.string()).optional(),
|
||||
@@ -167,7 +175,7 @@ export const NetworkSwarmSchema = z.array(
|
||||
.object({
|
||||
Target: z.string().optional(),
|
||||
Aliases: z.array(z.string()).optional(),
|
||||
DriverOpts: z.object({}).optional(),
|
||||
DriverOpts: z.record(z.string()).optional(),
|
||||
})
|
||||
.strict(),
|
||||
);
|
||||
@@ -189,3 +197,13 @@ export const EndpointSpecSwarmSchema = z
|
||||
Ports: z.array(EndpointPortConfigSwarmSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const UlimitSwarmSchema = z
|
||||
.object({
|
||||
Name: z.string().min(1),
|
||||
Soft: z.number().int().min(-1),
|
||||
Hard: z.number().int().min(-1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const UlimitsSwarmSchema = z.array(UlimitSwarmSchema);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { admin, apiKey, organization, twoFactor } from "better-auth/plugins";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
import { BETTER_AUTH_SECRET, IS_CLOUD } from "../constants";
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema";
|
||||
import { getTrustedOrigins, getUserByToken } from "../services/admin";
|
||||
@@ -29,6 +29,20 @@ const { handler, api } = betterAuth({
|
||||
"/organization/update",
|
||||
"/organization/delete",
|
||||
],
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
...(!IS_CLOUD
|
||||
? {
|
||||
advanced: {
|
||||
useSecureCookies: false,
|
||||
defaultCookieAttributes: {
|
||||
sameSite: "lax",
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
appName: "Dokploy",
|
||||
socialProviders: {
|
||||
github: {
|
||||
@@ -56,7 +70,10 @@ const { handler, api } = betterAuth({
|
||||
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||
...(process.env.NODE_ENV === "development"
|
||||
? ["http://localhost:3000"]
|
||||
? [
|
||||
"http://localhost:3000",
|
||||
"https://absolutely-handy-falcon.ngrok-free.app",
|
||||
]
|
||||
: []),
|
||||
...trustedOrigins,
|
||||
];
|
||||
|
||||
@@ -174,6 +174,10 @@ export const deployApplication = async ({
|
||||
}) => {
|
||||
const application = await findApplicationById(applicationId);
|
||||
const serverId = application.buildServerId || application.serverId;
|
||||
const applicationEntity = {
|
||||
...application,
|
||||
serverId: serverId,
|
||||
};
|
||||
|
||||
const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`;
|
||||
const deployment = await createDeployment({
|
||||
@@ -185,15 +189,15 @@ export const deployApplication = async ({
|
||||
try {
|
||||
let command = "set -e;";
|
||||
if (application.sourceType === "github") {
|
||||
command += await cloneGithubRepository(application);
|
||||
command += await cloneGithubRepository(applicationEntity);
|
||||
} else if (application.sourceType === "gitlab") {
|
||||
command += await cloneGitlabRepository(application);
|
||||
command += await cloneGitlabRepository(applicationEntity);
|
||||
} else if (application.sourceType === "gitea") {
|
||||
command += await cloneGiteaRepository(application);
|
||||
command += await cloneGiteaRepository(applicationEntity);
|
||||
} else if (application.sourceType === "bitbucket") {
|
||||
command += await cloneBitbucketRepository(application);
|
||||
command += await cloneBitbucketRepository(applicationEntity);
|
||||
} else if (application.sourceType === "git") {
|
||||
command += await cloneGitRepository(application);
|
||||
command += await cloneGitRepository(applicationEntity);
|
||||
} else if (application.sourceType === "docker") {
|
||||
command += await buildRemoteDocker(application);
|
||||
}
|
||||
@@ -258,7 +262,6 @@ export const deployApplication = async ({
|
||||
type: "application",
|
||||
serverId: serverId,
|
||||
});
|
||||
|
||||
if (commitInfo) {
|
||||
await updateDeployment(deployment.deploymentId, {
|
||||
title: commitInfo.message,
|
||||
|
||||
@@ -109,7 +109,7 @@ export const getContainersByAppNameMatch = async (
|
||||
try {
|
||||
let result: string[] = [];
|
||||
const cmd =
|
||||
"docker ps -a --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}}'";
|
||||
"docker ps -a --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}} | Status: {{.Status}}'";
|
||||
|
||||
const command =
|
||||
appType === "docker-compose"
|
||||
@@ -148,10 +148,14 @@ export const getContainersByAppNameMatch = async (
|
||||
const state = parts[2]
|
||||
? parts[2].replace("State: ", "").trim()
|
||||
: "No state";
|
||||
|
||||
const status = parts[3] ? parts[3].replace("Status: ", "").trim() : "";
|
||||
|
||||
return {
|
||||
containerId,
|
||||
name,
|
||||
state,
|
||||
status,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -168,7 +172,9 @@ export const getStackContainersByAppName = async (
|
||||
try {
|
||||
let result: string[] = [];
|
||||
|
||||
const command = `docker stack ps ${appName} --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}}'`;
|
||||
const command = `docker stack ps ${appName} --no-trunc --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`;
|
||||
|
||||
console.log("command ", command);
|
||||
if (serverId) {
|
||||
const { stdout, stderr } = await execAsyncRemote(serverId, command);
|
||||
|
||||
@@ -205,11 +211,17 @@ export const getStackContainersByAppName = async (
|
||||
const node = parts[3]
|
||||
? parts[3].replace("Node: ", "").trim()
|
||||
: "No specific node";
|
||||
const currentState = parts[4]
|
||||
? parts[4].replace("CurrentState: ", "").trim()
|
||||
: "";
|
||||
const error = parts[5] ? parts[5].replace("Error: ", "").trim() : "";
|
||||
return {
|
||||
containerId,
|
||||
name,
|
||||
state,
|
||||
node,
|
||||
currentState,
|
||||
error,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -226,8 +238,7 @@ export const getServiceContainersByAppName = async (
|
||||
try {
|
||||
let result: string[] = [];
|
||||
|
||||
const command = `docker service ps ${appName} --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}}'`;
|
||||
|
||||
const command = `docker service ps ${appName} --no-trunc --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`;
|
||||
if (serverId) {
|
||||
const { stdout, stderr } = await execAsyncRemote(serverId, command);
|
||||
|
||||
@@ -265,11 +276,18 @@ export const getServiceContainersByAppName = async (
|
||||
const node = parts[3]
|
||||
? parts[3].replace("Node: ", "").trim()
|
||||
: "No specific node";
|
||||
|
||||
const currentState = parts[4]
|
||||
? parts[4].replace("CurrentState: ", "").trim()
|
||||
: "";
|
||||
const error = parts[5] ? parts[5].replace("Error: ", "").trim() : "";
|
||||
return {
|
||||
containerId,
|
||||
name,
|
||||
state,
|
||||
currentState,
|
||||
node,
|
||||
error,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user