Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu
1d266b0840 feat(whitelabel): implement whitelabeling features and settings
- Added whitelabeling support, allowing customization of application name, logos, and login page.
- Introduced new WhitelabelSettings component for managing whitelabel configurations.
- Updated onboarding and sidebar layouts to reflect whitelabel settings dynamically.
- Created database schema changes to accommodate new whitelabel fields.
- Implemented API endpoints for retrieving and updating whitelabel settings.
2026-01-31 05:29:41 -06:00
134 changed files with 4555 additions and 11353 deletions

View File

@@ -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. 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.
- [ ] You have tested this PR in your local instance.
## Issues related (if applicable)

View File

@@ -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 community via github issues.
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
We have a few guidelines to follow when contributing to this project:
@@ -11,7 +11,6 @@ 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
@@ -163,9 +162,8 @@ 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`).

View File

@@ -65,8 +65,4 @@ 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
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"]
CMD [ "pnpm", "start" ]

View File

@@ -12,8 +12,24 @@
<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.
@@ -44,9 +60,40 @@ 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:
[![Individual Contributors on Open Collective](https://opencollective.com/dokploy/individuals.svg?width=890)](https://opencollective.com/dokploy)
### Contributors 🤝
<a href="https://github.com/dokploy/dokploy/graphs/contributors">

View File

@@ -14,7 +14,7 @@
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0",
"dotenv": "^16.4.5",
"hono": "^4.11.7",
"hono": "^4.7.10",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"react": "18.2.0",
@@ -23,7 +23,7 @@
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^20.16.0",
"@types/node": "^20.17.51",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"tsx": "^4.16.2",

View File

@@ -4,30 +4,21 @@ import { describe, expect, it } from "vitest";
describe("addDokployNetworkToService", () => {
it("should add network to an empty array", () => {
const result = addDokployNetworkToService([]);
expect(result).toEqual(["dokploy-network", "default"]);
expect(result).toEqual(["dokploy-network"]);
});
it("should not add duplicate network to an array", () => {
const result = addDokployNetworkToService(["dokploy-network"]);
expect(result).toEqual(["dokploy-network", "default"]);
expect(result).toEqual(["dokploy-network"]);
});
it("should add network to an existing array with other networks", () => {
const result = addDokployNetworkToService(["other-network"]);
expect(result).toEqual(["other-network", "dokploy-network", "default"]);
expect(result).toEqual(["other-network", "dokploy-network"]);
});
it("should add network to an object if networks is an object", () => {
const result = addDokployNetworkToService({ "other-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"]);
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
});
});

View File

@@ -13,11 +13,11 @@ type MockCreateServiceOptions = {
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
vi.hoisted(() => {
const inspect = vi.fn<() => Promise<never>>();
const inspect = vi.fn<[], Promise<never>>();
const getService = vi.fn(() => ({ inspect }));
const createService = vi.fn<
(opts: MockCreateServiceOptions) => Promise<void>
>(async () => undefined);
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
async () => undefined,
);
const getRemoteDocker = vi.fn(async () => ({
getService,
createService,
@@ -80,9 +80,7 @@ describe("mechanizeDockerContainer", () => {
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0] as
| [MockCreateServiceOptions]
| undefined;
const call = createServiceMock.mock.calls[0];
if (!call) {
throw new Error("createServiceMock should have been called once");
}
@@ -99,9 +97,7 @@ describe("mechanizeDockerContainer", () => {
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0] as
| [MockCreateServiceOptions]
| undefined;
const call = createServiceMock.mock.calls[0];
if (!call) {
throw new Error("createServiceMock should have been called once");
}

View File

@@ -1,40 +0,0 @@
import { vi } from "vitest";
/**
* Mock the DB module so tests that import from @dokploy/server (barrel)
* never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs).
* Without this, loading the server barrel pulls in lib/auth and db, which
* connect to localhost:5432 and cause ECONNREFUSED.
*/
vi.mock("@dokploy/server/db", () => {
const chain = () => chain;
chain.set = () => chain;
chain.where = () => chain;
chain.values = () => chain;
chain.returning = () => Promise.resolve([{}]);
chain.then = undefined;
const tableMock = {
findFirst: vi.fn(() => Promise.resolve(undefined)),
findMany: vi.fn(() => Promise.resolve([])),
insert: vi.fn(() => Promise.resolve([{}])),
update: vi.fn(() => chain),
delete: vi.fn(() => chain),
};
const createQueryMock = () => tableMock;
return {
db: {
select: vi.fn(() => chain),
insert: vi.fn(() => ({
values: () => ({ returning: () => Promise.resolve([{}]) }),
})),
update: vi.fn(() => chain),
delete: vi.fn(() => chain),
query: new Proxy({} as Record<string, typeof tableMock>, {
get: () => tableMock,
}),
},
dbUrl: "postgres://mock:mock@localhost:5432/mock",
};
});

View File

@@ -7,15 +7,10 @@ export default defineConfig({
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
pool: "forks",
setupFiles: [path.resolve(__dirname, "setup.ts")],
},
define: {
"process.env": {
NODE: "test",
GITHUB_CLIENT_ID: "test",
GITHUB_CLIENT_SECRET: "test",
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
},
},
plugins: [

View File

@@ -22,7 +22,6 @@ import {
HealthCheckForm,
LabelsForm,
ModeForm,
NetworkForm,
PlacementForm,
RestartPolicyForm,
RollbackConfigForm,
@@ -80,13 +79,6 @@ 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",
@@ -198,7 +190,6 @@ 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} />

View File

@@ -2,7 +2,6 @@ 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";

View File

@@ -1,313 +0,0 @@
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>
);
};

View File

@@ -24,8 +24,6 @@ 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({
@@ -61,7 +59,6 @@ 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,
@@ -88,15 +85,13 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
}, [data]);
const onSubmit = async (data: UpdateTraefikConfig) => {
if (!skipYamlValidation) {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: (error as string) || "Invalid YAML",
});
return;
}
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({
@@ -121,7 +116,6 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
setOpen(open);
if (!open) {
form.reset();
setSkipYamlValidation(false);
}
}}
>
@@ -175,28 +169,7 @@ routers:
</div>
</form>
<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>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-traefik-config"

View File

@@ -34,7 +34,6 @@ export const DockerLogs = dynamic(
export const badgeStateColor = (state: string) => {
switch (state) {
case "running":
case "ready":
return "green";
case "exited":
case "shutdown":
@@ -143,7 +142,6 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.status ? ` ${container.status}` : ""}
</SelectItem>
))}
</div>
@@ -159,9 +157,6 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.currentState
? ` ${container.currentState}`
: ""}
</SelectItem>
))}
</>
@@ -171,13 +166,6 @@ 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"}

View File

@@ -128,7 +128,6 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.status ? ` ${container.status}` : ""}
</SelectItem>
))}
</div>
@@ -144,9 +143,6 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.currentState
? ` ${container.currentState}`
: ""}
</SelectItem>
))}
</>
@@ -156,13 +152,6 @@ 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"}

View File

@@ -1,8 +1,8 @@
import { Loader2 } from "lucide-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 { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
@@ -93,7 +93,6 @@ export const ShowDockerLogsCompose = ({
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.status ? ` ${container.status}` : ""}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>

View File

@@ -7,7 +7,6 @@ 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,
@@ -17,7 +16,6 @@ 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";
@@ -49,7 +47,6 @@ 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();
@@ -69,15 +66,13 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
if (!skipYamlValidation) {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: error || "Invalid YAML",
});
return;
}
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: error || "Invalid YAML",
});
return;
}
form.clearErrors("traefikConfig");
await mutateAsync({
@@ -158,37 +153,14 @@ routers:
/>
)}
</div>
<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 className="flex justify-end">
<Button
isLoading={isLoading}
disabled={canEdit || isLoading}
type="submit"
>
Update
</Button>
</div>
</form>
</Form>

View File

@@ -73,8 +73,8 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
.catch(() => {
toast.error("Error saving the external port");
});
};

View File

@@ -73,8 +73,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
.catch(() => {
toast.error("Error saving the external port");
});
};

View File

@@ -73,8 +73,8 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
.catch(() => {
toast.error("Error saving the external port");
});
};

View File

@@ -75,8 +75,8 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
.catch(() => {
toast.error("Error saving the external port");
});
};

View File

@@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
.catch(() => {
toast.error("Error saving the external port");
});
};

View File

@@ -21,7 +21,6 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -40,10 +39,6 @@ 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",
}),
@@ -75,7 +70,6 @@ export const AddGiteaProvider = () => {
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
giteaInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -89,7 +83,6 @@ export const AddGiteaProvider = () => {
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
giteaInternalUrl: "",
});
}, [form, webhookUrl, isOpen]);
@@ -102,7 +95,6 @@ export const AddGiteaProvider = () => {
name: data.name,
redirectUri: data.redirectUri,
giteaUrl: data.giteaUrl,
giteaInternalUrl: data.giteaInternalUrl || undefined,
organizationName: data.organizationName,
})) as unknown as GiteaProviderResponse;
@@ -231,29 +223,6 @@ 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"

View File

@@ -19,7 +19,6 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -31,10 +30,6 @@ 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"),
});
@@ -99,7 +94,6 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
defaultValues: {
name: "",
giteaUrl: "https://gitea.com",
giteaInternalUrl: "",
clientId: "",
clientSecret: "",
},
@@ -110,7 +104,6 @@ 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 || "",
});
@@ -123,7 +116,6 @@ 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,
})
@@ -232,28 +224,6 @@ 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"

View File

@@ -21,7 +21,6 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -36,10 +35,6 @@ 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",
}),
@@ -71,7 +66,6 @@ export const AddGitlabProvider = () => {
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -86,7 +80,6 @@ export const AddGitlabProvider = () => {
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
});
}, [form, isOpen]);
@@ -99,7 +92,6 @@ 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();
@@ -200,29 +192,6 @@ 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"

View File

@@ -20,7 +20,6 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -34,10 +33,6 @@ 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(),
});
@@ -66,7 +61,6 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
groupName: "",
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -78,7 +72,6 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
groupName: gitlab?.groupName || "",
name: gitlab?.gitProvider.name || "",
gitlabUrl: gitlab?.gitlabUrl || "",
gitlabInternalUrl: gitlab?.gitlabInternalUrl || "",
});
}, [form, isOpen]);
@@ -89,7 +82,6 @@ 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();
@@ -159,29 +151,6 @@ 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"

View File

@@ -16,7 +16,6 @@ import {
LarkIcon,
NtfyIcon,
PushoverIcon,
ResendIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -98,23 +97,6 @@ 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"),
@@ -187,10 +169,6 @@ 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",
@@ -236,8 +214,6 @@ 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 } =
@@ -266,9 +242,6 @@ 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();
@@ -308,7 +281,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
});
useEffect(() => {
if ((type === "email" || type === "resend") && fields.length === 0) {
if (type === "email" && fields.length === 0) {
append("");
}
}, [type, append, fields.length]);
@@ -376,21 +349,6 @@ 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,
@@ -484,7 +442,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
telegram: telegramMutation,
discord: discordMutation,
email: emailMutation,
resend: resendMutation,
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
@@ -568,22 +525,6 @@ 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,
@@ -1101,96 +1042,6 @@ 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
@@ -1776,7 +1627,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail ||
isLoadingResend ||
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark ||
@@ -1817,12 +1667,6 @@ 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,

View File

@@ -5,7 +5,6 @@ import {
GotifyIcon,
LarkIcon,
NtfyIcon,
ResendIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -37,7 +36,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email, Resend, Lark.
Telegram, Email, Lark.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -87,11 +86,6 @@ 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" />

View File

@@ -23,8 +23,6 @@ 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>
@@ -89,21 +87,6 @@ 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 () => {

View File

@@ -12,7 +12,6 @@ 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";
@@ -34,45 +33,14 @@ 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 ||
isHealthCheckExecuting ||
isReloadHealthCheckExecuting
}
disabled={reloadTraefikIsLoading || toggleDashboardIsLoading}
>
<Button
isLoading={
reloadTraefikIsLoading ||
toggleDashboardIsLoading ||
isHealthCheckExecuting ||
isReloadHealthCheckExecuting
}
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
variant="outline"
>
{t("settings.server.webServer.traefik.label")}
@@ -86,19 +54,15 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
<DropdownMenuGroup>
<DropdownMenuItem
onClick={async () => {
try {
await executeReloadWithHealthCheck(() =>
reloadTraefik({ serverId }),
);
} catch (error) {
const errorMessage =
(error as Error)?.message ||
"Failed to reload Traefik. Please try again.";
toast.error(errorMessage);
}
await reloadTraefik({
serverId: serverId,
})
.then(async () => {
toast.success("Traefik Reloaded");
})
.catch(() => {});
}}
className="cursor-pointer"
disabled={isReloadHealthCheckExecuting}
>
<span>{t("settings.server.webServer.reload")}</span>
</DropdownMenuItem>
@@ -144,21 +108,24 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
</div>
}
onClick={async () => {
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);
}
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);
});
}}
disabled={toggleDashboardIsLoading || isHealthCheckExecuting}
disabled={toggleDashboardIsLoading}
type="default"
>
<DropdownMenuItem

View File

@@ -1,7 +1,6 @@
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";
@@ -47,14 +46,6 @@ 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 || "",
@@ -72,16 +63,16 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
}, [form, form.reset, data]);
const onSubmit = async (data: Schema) => {
try {
await executeWithHealthCheck(() =>
mutateAsync({
env: data.env,
serverId,
}),
);
} catch {
toast.error("Error updating the Traefik env");
}
await mutateAsync({
env: data.env,
serverId,
})
.then(async () => {
toast.success("Traefik Env Updated");
})
.catch(() => {
toast.error("Error updating the Traefik env");
});
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
@@ -163,8 +154,8 @@ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_PROVIDER=cloudflare
<DialogFooter>
<Button
isLoading={isLoading || isHealthCheckExecuting}
disabled={canEdit || isLoading || isHealthCheckExecuting}
isLoading={isLoading}
disabled={canEdit || isLoading}
form="hook-form-update-server-traefik-config"
type="submit"
>

View File

@@ -1,7 +1,6 @@
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";
@@ -77,19 +76,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
});
const { mutateAsync: updatePorts, isLoading } =
api.settings.updateTraefikPorts.useMutation();
const {
execute: executeWithHealthCheck,
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
onSuccess: () => {
refetchPorts();
setOpen(false);
},
});
api.settings.updateTraefikPorts.useMutation({
onSuccess: () => {
refetchPorts();
},
});
useEffect(() => {
if (currentPorts) {
@@ -108,12 +99,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const onSubmit = async (data: TraefikPortsForm) => {
try {
await executeWithHealthCheck(() =>
updatePorts({
serverId,
additionalPorts: data.ports,
}),
);
await updatePorts({
serverId,
additionalPorts: data.ports,
});
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
setOpen(false);
} catch (error) {
toast.error((error as Error).message || "Error updating Traefik ports");
@@ -327,7 +317,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
type="submit"
variant="default"
className="text-sm"
isLoading={isLoading || isHealthCheckExecuting}
isLoading={isLoading}
>
Save
</Button>

View File

@@ -257,23 +257,3 @@ 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>
);
};

View File

@@ -4,21 +4,35 @@ import { cn } from "@/lib/utils";
import { GithubIcon } from "../icons/data-tools-icons";
import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
import { api } from "@/utils/api";
interface Props {
children: React.ReactNode;
}
export const OnboardingLayout = ({ children }: Props) => {
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const appName = whitelabel?.whitelabelAppName ?? "Dokploy";
const logoUrl =
whitelabel?.whitelabelLogoUrl ?? whitelabel?.whitelabelLoginLogoUrl;
return (
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
<div className="absolute inset-0 bg-muted" />
{whitelabel?.whitelabelLoginBackgroundImageUrl && (
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
style={{
backgroundImage: `url(${whitelabel.whitelabelLoginBackgroundImageUrl})`,
}}
/>
)}
<Link
href="https://dokploy.com"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
>
<Logo className="size-10" />
Dokploy
<Logo className="size-10" logoUrl={logoUrl ?? undefined} />
{appName}
</Link>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">

View File

@@ -24,6 +24,7 @@ import {
LogIn,
type LucideIcon,
Package,
Palette,
PieChart,
Server,
ShieldCheck,
@@ -404,8 +405,8 @@ 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, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
},
{
isSingle: true,
@@ -416,6 +417,15 @@ const MENU: Menu = {
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
title: "Whitelabeling",
url: "/dashboard/settings/whitelabelling",
icon: Palette,
// Enterprise only page shows gate if no license
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
],
help: [
@@ -546,6 +556,7 @@ function SidebarLogo() {
refetch,
isLoading,
} = api.organization.all.useQuery();
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
api.organization.delete.useMutation();
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
@@ -611,7 +622,11 @@ function SidebarLogo() {
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
)}
logoUrl={activeOrganization?.logo || undefined}
logoUrl={
activeOrganization?.logo ||
whitelabel?.whitelabelLogoUrl ||
undefined
}
/>
</div>
<div
@@ -621,7 +636,9 @@ function SidebarLogo() {
)}
>
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
{activeOrganization?.name ??
whitelabel?.whitelabelAppName ??
"Select Organization"}
</p>
</div>
</div>

View File

@@ -101,32 +101,27 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
const scopes = data.scopes.filter(Boolean).length
? data.scopes.filter(Boolean)
: DEFAULT_SCOPES;
const isAzure = data.issuer.includes("login.microsoftonline.com");
const mapping = isAzure
? {
id: "sub",
email: "preferred_username",
emailVerified: "email_verified",
name: "name",
}
: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "preferred_username",
image: "picture",
};
const domain = data.domains
.map((d) => d.trim())
.filter(Boolean)
.join(",");
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
domains: data.domains,
domain,
oidcConfig: {
clientId: data.clientId,
clientSecret: data.clientSecret,
scopes,
pkce: true,
mapping,
// Keycloak (and many IdPs) send preferred_username; better-auth expects name
mapping: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "preferred_username",
image: "picture",
},
},
});

View File

@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -52,7 +52,12 @@ const samlProviderSchema = z.object({
.url("Invalid URL")
.trim(),
cert: z.string().min(1, "IdP signing certificate is required"),
idpMetadataXml: z.string().optional(),
callbackUrl: z
.string()
.min(1, "Callback URL is required")
.url("Invalid URL")
.trim(),
audience: z.string().min(1, "Audience (Entity ID) is required").trim(),
});
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
@@ -67,7 +72,8 @@ const formDefaultValues: SamlProviderForm = {
domains: [""],
entryPoint: "",
cert: "",
idpMetadataXml: "",
callbackUrl: "",
audience: "",
};
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
@@ -75,14 +81,6 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const [baseURL, setBaseURL] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues,
@@ -97,38 +95,24 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const onSubmit = async (data: SamlProviderForm) => {
try {
// maybe add the /saml/metadata endpoint to the baseURL
const baseURLWithMetadata = `${baseURL}/saml/metadata`;
const generateSpMetadata = (providerId: string) => {
return `<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${baseURL}">
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${baseURL}/api/auth/sso/saml2/callback/${providerId}" index="1"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>`;
};
const domain = data.domains
.map((d) => d.trim())
.filter(Boolean)
.join(",");
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
domains: data.domains,
domain,
samlConfig: {
entryPoint: data.entryPoint,
cert: data.cert,
callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`,
audience: baseURL,
idpMetadata: data.idpMetadataXml?.trim()
? { metadata: data.idpMetadataXml.trim() }
: undefined,
callbackUrl: data.callbackUrl,
audience: data.audience,
wantAssertionsSigned: true,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
metadata: generateSpMetadata(data.providerId),
},
mapping: {
id: "nameID",
email: "email",
name: "displayName",
firstName: "givenName",
lastName: "surname",
entityID: data.audience,
},
},
});
@@ -284,29 +268,39 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
</FormItem>
)}
/>
<FormField
control={form.control}
name="idpMetadataXml"
name="callbackUrl"
render={({ field }) => (
<FormItem>
<FormLabel>IdP metadata XML (optional)</FormLabel>
<FormLabel>Callback URL (ACS)</FormLabel>
<FormControl>
<Textarea
placeholder="Paste full IdP metadata XML if you have it (EntityDescriptor). Otherwise leave empty and use Issuer, IdP SSO URL and certificate above."
rows={5}
className="font-mono text-xs"
<Input
placeholder="https://yourapp.com/api/auth/sso/saml2/callback/my-provider"
{...field}
/>
</FormControl>
<FormDescription>
Some IdPs require full metadata; paste the XML here to
override issuer/entry point/cert.
Use the callback URL shown in your IdP app config for this
provider.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="audience"
render={({ field }) => (
<FormItem>
<FormLabel>Audience (Entity ID)</FormLabel>
<FormControl>
<Input placeholder="https://yourapp.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"

View File

@@ -340,10 +340,7 @@ export const SSOSettings = () => {
Callback URL (configure in your IdP)
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
{baseURL || "{baseURL}"}
{detailsProvider.samlConfig
? "/api/auth/sso/saml2/callback/"
: "/api/auth/sso/callback/"}
{baseURL || "{baseURL}"}/api/auth/sso/callback/
{detailsProvider.providerId}
</p>
{!baseURL && (

View File

@@ -0,0 +1,290 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Palette } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { CardDescription, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const whitelabelSchema = z.object({
whitelabelAppName: z.string().min(1).max(100),
whitelabelLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelLoginLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelFaviconUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelLoginTitle: z.string().max(200).optional(),
whitelabelLoginSubtitle: z.string().max(500).optional(),
whitelabelLoginBackgroundImageUrl: z
.union([z.string().url(), z.literal("")])
.optional(),
});
type WhitelabelFormValues = z.infer<typeof whitelabelSchema>;
export function WhitelabelSettings() {
const { data: settings, isLoading } =
api.settings.getWebServerSettings.useQuery();
const { mutateAsync: updateWhitelabel, isLoading: isSaving } =
api.settings.updateWhitelabelSettings.useMutation();
const utils = api.useUtils();
const form = useForm<WhitelabelFormValues>({
resolver: zodResolver(whitelabelSchema),
defaultValues: {
whitelabelAppName: "Dokploy",
whitelabelLogoUrl: "",
whitelabelLoginLogoUrl: "",
whitelabelFaviconUrl: "",
whitelabelLoginTitle: "",
whitelabelLoginSubtitle: "",
whitelabelLoginBackgroundImageUrl: "",
},
});
useEffect(() => {
if (settings) {
form.reset({
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? "",
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? "",
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? "",
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? "",
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? "",
whitelabelLoginBackgroundImageUrl:
settings.whitelabelLoginBackgroundImageUrl ?? "",
});
}
}, [settings, form]);
const onSubmit = async (values: WhitelabelFormValues) => {
try {
await updateWhitelabel({
whitelabelAppName: values.whitelabelAppName || null,
whitelabelLogoUrl: values.whitelabelLogoUrl || undefined,
whitelabelLoginLogoUrl: values.whitelabelLoginLogoUrl || undefined,
whitelabelFaviconUrl: values.whitelabelFaviconUrl || undefined,
whitelabelLoginTitle: values.whitelabelLoginTitle || null,
whitelabelLoginSubtitle: values.whitelabelLoginSubtitle || null,
whitelabelLoginBackgroundImageUrl:
values.whitelabelLoginBackgroundImageUrl || undefined,
});
toast.success("Whitelabel settings saved");
utils.settings.getWebServerSettings.invalidate();
utils.settings.getWhitelabelSettings.invalidate();
} catch (e) {
toast.error("Failed to save whitelabel settings");
}
};
if (isLoading) {
return (
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">
Loading whitelabel settings...
</span>
</div>
);
}
return (
<div className="flex flex-col gap-4 rounded-lg ">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Palette className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Whitelabeling</CardTitle>
</div>
<CardDescription>
Customize the application name, logos, and login page for your brand.
Leave URLs empty to use defaults.
</CardDescription>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<div className="space-y-4 pt-2 border-t">
<div>
<h3 className="text-sm font-medium">Brand</h3>
<p className="text-sm text-muted-foreground">
Application name and main logo (sidebar, header).
</p>
</div>
<FormField
control={form.control}
name="whitelabelAppName"
render={({ field }) => (
<FormItem>
<FormLabel>Application name</FormLabel>
<FormControl>
<Input
placeholder="Dokploy"
{...field}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLogoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo.png"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Logo shown in the sidebar and header.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelFaviconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Favicon URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/favicon.ico"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4 pt-6 border-t">
<div>
<h3 className="text-sm font-medium">Login page</h3>
<p className="text-sm text-muted-foreground">
Customize the sign-in and registration screens.
</p>
</div>
<FormField
control={form.control}
name="whitelabelLoginLogoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/login-logo.png"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Logo on the login and register pages. Falls back to the main
logo if empty.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginTitle"
render={({ field }) => (
<FormItem>
<FormLabel>Login title</FormLabel>
<FormControl>
<Input
placeholder="Sign in"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginSubtitle"
render={({ field }) => (
<FormItem>
<FormLabel>Login subtitle</FormLabel>
<FormControl>
<Input
placeholder="Enter your email and password to sign in"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginBackgroundImageUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login background image URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/background.jpg"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Optional background image for the login page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end pt-4 border-t">
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -1,18 +0,0 @@
CREATE TABLE "sso_provider" (
"id" text PRIMARY KEY NOT NULL,
"issuer" text NOT NULL,
"oidc_config" text,
"saml_config" text,
"provider_id" text NOT NULL,
"user_id" text,
"organization_id" text,
"domain" text NOT NULL,
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
);
--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "licenseKey" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "trustedOrigins" text[];--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "licenseKey" text;

View File

@@ -0,0 +1,13 @@
CREATE TABLE "sso_provider" (
"id" text PRIMARY KEY NOT NULL,
"issuer" text NOT NULL,
"oidc_config" text,
"saml_config" text,
"user_id" text,
"provider_id" text NOT NULL,
"organization_id" text,
"domain" text NOT NULL,
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
);
--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -1,10 +0,0 @@
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;

View File

@@ -1 +0,0 @@
ALTER TABLE "invitation" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -1 +0,0 @@
ALTER TABLE "gitlab" ADD COLUMN "gitlabInternalUrl" text;

View File

@@ -1 +0,0 @@
ALTER TABLE "gitea" ADD COLUMN "giteaInternalUrl" text;

View File

@@ -1,5 +1,5 @@
{
"id": "e5c16e66-ec3d-4a91-b3ac-f9ea4577f53f",
"id": "af1f5881-9a57-4f68-9ef2-632b0370b0c5",
"prevId": "5958b029-1fb9-4a44-be24-c96b4e899b84",
"version": "7",
"dialect": "postgresql",
@@ -6309,102 +6309,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sso_provider": {
"name": "sso_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_config": {
"name": "oidc_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"saml_config": {
"name": "saml_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sso_provider_user_id_user_id_fk": {
"name": "sso_provider_user_id_user_id_fk",
"tableFrom": "sso_provider",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"sso_provider_organization_id_organization_id_fk": {
"name": "sso_provider_organization_id_organization_id_fk",
"tableFrom": "sso_provider",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sso_provider_provider_id_unique": {
"name": "sso_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
@@ -6537,13 +6441,6 @@
"primaryKey": false,
"notNull": false
},
"isValidEnterpriseLicense": {
"name": "isValidEnterpriseLicense",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"stripeCustomerId": {
"name": "stripeCustomerId",
"type": "text",
@@ -6562,12 +6459,6 @@
"primaryKey": false,
"notNull": true,
"default": 0
},
"trustedOrigins": {
"name": "trustedOrigins",
"type": "text[]",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},

View File

@@ -1,6 +1,6 @@
{
"id": "45344442-d8aa-48cf-b45f-0c869acbd620",
"prevId": "e5c16e66-ec3d-4a91-b3ac-f9ea4577f53f",
"id": "9192b74d-8589-483e-a188-32d60d18c112",
"prevId": "af1f5881-9a57-4f68-9ef2-632b0370b0c5",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -639,6 +639,89 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sso_provider": {
"name": "sso_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_config": {
"name": "oidc_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"saml_config": {
"name": "saml_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sso_provider_user_id_user_id_fk": {
"name": "sso_provider_user_id_user_id_fk",
"tableFrom": "sso_provider",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sso_provider_provider_id_unique": {
"name": "sso_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.two_factor": {
"name": "two_factor",
"schema": "",
@@ -4492,12 +4575,6 @@
"primaryKey": false,
"notNull": false
},
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gotifyId": {
"name": "gotifyId",
"type": "text",
@@ -4589,19 +4666,6 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_resendId_resend_resendId_fk": {
"name": "notification_resendId_resend_resendId_fk",
"tableFrom": "notification",
"tableTo": "resend",
"columnsFrom": [
"resendId"
],
"columnsTo": [
"resendId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_gotifyId_gotify_gotifyId_fk": {
"name": "notification_gotifyId_gotify_gotifyId_fk",
"tableFrom": "notification",
@@ -4781,43 +4845,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.resend": {
"name": "resend",
"schema": "",
"columns": {
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"apiKey": {
"name": "apiKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fromAddress": {
"name": "fromAddress",
"type": "text",
"primaryKey": false,
"notNull": true
},
"toAddress": {
"name": "toAddress",
"type": "text[]",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slack": {
"name": "slack",
"schema": "",
@@ -6365,102 +6392,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sso_provider": {
"name": "sso_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_config": {
"name": "oidc_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"saml_config": {
"name": "saml_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sso_provider_user_id_user_id_fk": {
"name": "sso_provider_user_id_user_id_fk",
"tableFrom": "sso_provider",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"sso_provider_organization_id_organization_id_fk": {
"name": "sso_provider_organization_id_organization_id_fk",
"tableFrom": "sso_provider",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sso_provider_provider_id_unique": {
"name": "sso_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
@@ -6593,13 +6524,6 @@
"primaryKey": false,
"notNull": false
},
"isValidEnterpriseLicense": {
"name": "isValidEnterpriseLicense",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"stripeCustomerId": {
"name": "stripeCustomerId",
"type": "text",
@@ -6618,12 +6542,6 @@
"primaryKey": false,
"notNull": true,
"default": 0
},
"trustedOrigins": {
"name": "trustedOrigins",
"type": "text[]",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@@ -7122,7 +7040,6 @@
"telegram",
"discord",
"email",
"resend",
"gotify",
"ntfy",
"pushover",

View File

@@ -1,6 +1,6 @@
{
"id": "c845d075-eec8-41b2-a3c9-9c63e9425c4b",
"prevId": "45344442-d8aa-48cf-b45f-0c869acbd620",
"id": "4b2adb61-29b2-456d-829f-67faa7c64982",
"prevId": "9192b74d-8589-483e-a188-32d60d18c112",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -344,13 +344,6 @@
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
@@ -646,6 +639,89 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sso_provider": {
"name": "sso_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_config": {
"name": "oidc_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"saml_config": {
"name": "saml_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sso_provider_user_id_user_id_fk": {
"name": "sso_provider_user_id_user_id_fk",
"tableFrom": "sso_provider",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sso_provider_provider_id_unique": {
"name": "sso_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.two_factor": {
"name": "two_factor",
"schema": "",
@@ -4499,12 +4575,6 @@
"primaryKey": false,
"notNull": false
},
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gotifyId": {
"name": "gotifyId",
"type": "text",
@@ -4596,19 +4666,6 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_resendId_resend_resendId_fk": {
"name": "notification_resendId_resend_resendId_fk",
"tableFrom": "notification",
"tableTo": "resend",
"columnsFrom": [
"resendId"
],
"columnsTo": [
"resendId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_gotifyId_gotify_gotifyId_fk": {
"name": "notification_gotifyId_gotify_gotifyId_fk",
"tableFrom": "notification",
@@ -4788,43 +4845,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.resend": {
"name": "resend",
"schema": "",
"columns": {
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"apiKey": {
"name": "apiKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fromAddress": {
"name": "fromAddress",
"type": "text",
"primaryKey": false,
"notNull": true
},
"toAddress": {
"name": "toAddress",
"type": "text[]",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slack": {
"name": "slack",
"schema": "",
@@ -6372,102 +6392,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sso_provider": {
"name": "sso_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_config": {
"name": "oidc_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"saml_config": {
"name": "saml_config",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sso_provider_user_id_user_id_fk": {
"name": "sso_provider_user_id_user_id_fk",
"tableFrom": "sso_provider",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"sso_provider_organization_id_organization_id_fk": {
"name": "sso_provider_organization_id_organization_id_fk",
"tableFrom": "sso_provider",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sso_provider_provider_id_unique": {
"name": "sso_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
@@ -6625,12 +6549,6 @@
"primaryKey": false,
"notNull": true,
"default": 0
},
"trustedOrigins": {
"name": "trustedOrigins",
"type": "text[]",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@@ -7129,7 +7047,6 @@
"telegram",
"discord",
"email",
"resend",
"gotify",
"ntfy",
"pushover",

View File

@@ -1,6 +1,6 @@
{
"id": "9cf69a2e-b38d-4ed5-ad01-27065deac7f6",
"prevId": "c845d075-eec8-41b2-a3c9-9c63e9425c4b",
"id": "2d5967fa-7d7c-4efe-b573-02c14983be02",
"prevId": "4b2adb61-29b2-456d-829f-67faa7c64982",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -344,13 +344,6 @@
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
@@ -3218,12 +3211,6 @@
"notNull": true,
"default": "'https://gitlab.com'"
},
"gitlabInternalUrl": {
"name": "gitlabInternalUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"application_id": {
"name": "application_id",
"type": "text",
@@ -4505,12 +4492,6 @@
"primaryKey": false,
"notNull": false
},
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gotifyId": {
"name": "gotifyId",
"type": "text",
@@ -4602,19 +4583,6 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_resendId_resend_resendId_fk": {
"name": "notification_resendId_resend_resendId_fk",
"tableFrom": "notification",
"tableTo": "resend",
"columnsFrom": [
"resendId"
],
"columnsTo": [
"resendId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_gotifyId_gotify_gotifyId_fk": {
"name": "notification_gotifyId_gotify_gotifyId_fk",
"tableFrom": "notification",
@@ -4794,43 +4762,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.resend": {
"name": "resend",
"schema": "",
"columns": {
"resendId": {
"name": "resendId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"apiKey": {
"name": "apiKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fromAddress": {
"name": "fromAddress",
"type": "text",
"primaryKey": false,
"notNull": true
},
"toAddress": {
"name": "toAddress",
"type": "text[]",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slack": {
"name": "slack",
"schema": "",
@@ -6631,12 +6562,6 @@
"primaryKey": false,
"notNull": true,
"default": 0
},
"trustedOrigins": {
"name": "trustedOrigins",
"type": "text[]",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@@ -7135,7 +7060,6 @@
"telegram",
"discord",
"email",
"resend",
"gotify",
"ntfy",
"pushover",

File diff suppressed because it is too large Load Diff

View File

@@ -964,36 +964,29 @@
{
"idx": 137,
"version": "7",
"when": 1770274109332,
"tag": "0137_colossal_sally_floyd",
"when": 1769616589728,
"tag": "0137_naive_power_pack",
"breakpoints": true
},
{
"idx": 138,
"version": "7",
"when": 1770324882572,
"tag": "0138_pretty_ironclad",
"when": 1769745328628,
"tag": "0138_common_mathemanic",
"breakpoints": true
},
{
"idx": 139,
"version": "7",
"when": 1770442690721,
"tag": "0139_brave_bloodstorm",
"when": 1769746948088,
"tag": "0139_smiling_havok",
"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",
"when": 1769854977685,
"tag": "0140_great_lightspeed",
"breakpoints": true
}
]

View File

@@ -24,8 +24,6 @@ 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",
},

View File

@@ -1,92 +0,0 @@
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 };
};

View File

@@ -1,17 +1,15 @@
{
"name": "dokploy",
"version": "v0.27.0",
"version": "v0.26.7",
"private": true,
"license": "Apache-2.0",
"type": "module",
"scripts": {
"build": "npm run build-server && npm run build-next",
"start": "node -r dotenv/config dist/migration.mjs && node -r dotenv/config dist/server.mjs",
"start": "node -r dotenv/config dist/server.mjs",
"build-server": "tsx esbuild.config.ts",
"build-next": "next build --webpack",
"build-next": "next build",
"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 ",
@@ -39,7 +37,6 @@
"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",
@@ -101,7 +98,7 @@
"better-auth": "1.4.18",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.67.3",
"bullmq": "5.4.2",
"shell-quote": "^1.8.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -110,7 +107,7 @@
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dotenv": "16.4.5",
"drizzle-orm": "^0.41.0",
"drizzle-orm": "^0.39.3",
"drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"i18next": "^23.16.8",
@@ -120,7 +117,7 @@
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.1.6",
"next": "^16.0.10",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
@@ -168,7 +165,7 @@
"@types/js-cookie": "^3.0.6",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^20.16.0",
"@types/node": "^18.19.104",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
@@ -178,7 +175,7 @@
"@types/swagger-ui-react": "^4.19.0",
"@types/ws": "8.5.10",
"autoprefixer": "10.4.12",
"drizzle-kit": "^0.31.4",
"drizzle-kit": "^0.30.6",
"esbuild": "0.20.2",
"lint-staged": "^15.5.2",
"memfs": "^4.17.2",
@@ -186,7 +183,7 @@
"tsx": "^4.16.2",
"typescript": "^5.8.3",
"vite-tsconfig-paths": "4.3.2",
"vitest": "^4.0.18"
"vitest": "^1.6.1"
},
"ct3aMetadata": {
"initVersion": "7.25.2"

View File

@@ -355,11 +355,6 @@ 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;
@@ -480,7 +475,7 @@ export default async function handler(
let previewDeploymentId =
previewDeploymentResult?.previewDeploymentId || "";
if (!previewDeploymentResult && shouldCreateDeployment) {
if (!previewDeploymentResult) {
const previewDeployment = await createPreviewDeployment({
applicationId: app.applicationId as string,
branch: prBranch,
@@ -502,23 +497,21 @@ export default async function handler(
previewDeploymentId,
};
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,
},
);
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,
},
);
}
return res.status(200).json({ message: "Apps Deployed" });
}

View File

@@ -15,9 +15,7 @@ const parseState = (state: string): string | null => {
// Helper to fetch access token from Gitea
const fetchAccessToken = async (gitea: Gitea, code: string) => {
// 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`, {
const response = await fetch(`${gitea.giteaUrl}/login/oauth/access_token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",

View File

@@ -9,7 +9,6 @@ export interface Gitea {
refreshToken: string | null;
expiresAt: number | null;
giteaUrl: string;
giteaInternalUrl: string | null;
clientId: string | null;
clientSecret: string | null;
organizationName?: string;

View File

@@ -12,9 +12,7 @@ export default async function handler(
}
const gitlab = await findGitlabById(gitlabId as string);
// 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 gitlabUrl = new URL(gitlab.gitlabUrl);
const headers: HeadersInit = {
"Content-Type": "application/x-www-form-urlencoded",

View File

@@ -36,6 +36,14 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {

View File

@@ -0,0 +1,84 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { WhitelabelSettings } from "@/components/proprietary/whitelabelling/whitelabel-settings";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
<EnterpriseFeatureGate
lockedProps={{
title: "Enterprise Whitelabeling",
description:
"Whitelabeling is part of Dokploy Enterprise. Add a valid license to customize logos, app name, and login page.",
ctaLabel: "Go to License",
}}
>
<WhitelabelSettings />
</EnterpriseFeatureGate>
</div>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Whitelabeling">{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role === "member") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: ctx.res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -59,6 +59,7 @@ interface Props {
export default function Home({ IS_CLOUD }: Props) {
const router = useRouter();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const [isLoginLoading, setIsLoginLoading] = useState(false);
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
@@ -212,17 +213,27 @@ export default function Home({ IS_CLOUD }: Props) {
</>
);
const loginLogoUrl =
whitelabel?.whitelabelLoginLogoUrl ?? whitelabel?.whitelabelLogoUrl;
const loginTitle = whitelabel?.whitelabelLoginTitle ?? "Sign in";
const loginSubtitle =
whitelabel?.whitelabelLoginSubtitle ??
"Enter your email and password to sign in";
return (
<>
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
<div className="flex flex-row items-center justify-center gap-2">
<Logo className="size-12" />
Sign in
<Logo
className="size-12"
logoUrl={loginLogoUrl ?? undefined}
/>
{loginTitle}
</div>
</h1>
<p className="text-sm text-muted-foreground">
Enter your email and password to sign in
{loginSubtitle}
</p>
</div>
{error && (

View File

@@ -63,7 +63,7 @@ export default function Home() {
const onSubmit = async (values: Login) => {
setIsLoading(true);
const { error } = await authClient.requestPasswordReset({
const { error } = await authClient.forgetPassword({
email: values.email,
redirectTo: "/reset-password",
});

View File

@@ -57,11 +57,9 @@ 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";
@@ -242,15 +240,6 @@ 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),

View File

@@ -58,11 +58,9 @@ 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";
@@ -224,15 +222,6 @@ 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),

View File

@@ -5,6 +5,7 @@ import {
findDomainById,
findDomainsByApplicationId,
findDomainsByComposeId,
findOrganizationById,
findPreviewDeploymentById,
findServerById,
generateTraefikMeDomain,

View File

@@ -1,6 +1,5 @@
import {
addNewService,
checkPortInUse,
checkServiceAccess,
createMariadb,
createMount,
@@ -163,9 +162,9 @@ export const mariadbRouter = createTRPCRouter({
saveExternalPort: protectedProcedure
.input(apiSaveExternalPortMariaDB)
.mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId);
const mongo = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
@@ -173,25 +172,11 @@ 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 mariadb;
return mongo;
}),
deploy: protectedProcedure
.input(apiDeployMariaDB)

View File

@@ -1,6 +1,5 @@
import {
addNewService,
checkPortInUse,
checkServiceAccess,
createMongo,
createMount,
@@ -190,20 +189,6 @@ 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,
});

View File

@@ -1,6 +1,5 @@
import {
addNewService,
checkPortInUse,
checkServiceAccess,
createMount,
createMysql,
@@ -178,9 +177,9 @@ export const mysqlRouter = createTRPCRouter({
saveExternalPort: protectedProcedure
.input(apiSaveExternalPortMySql)
.mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId);
const mongo = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
@@ -188,25 +187,11 @@ 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 mysql;
return mongo;
}),
deploy: protectedProcedure
.input(apiDeployMySql)

View File

@@ -6,7 +6,6 @@ import {
createLarkNotification,
createNtfyNotification,
createPushoverNotification,
createResendNotification,
createSlackNotification,
createTelegramNotification,
findNotificationById,
@@ -20,7 +19,6 @@ import {
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTelegramNotification,
@@ -31,7 +29,6 @@ import {
updateLarkNotification,
updateNtfyNotification,
updatePushoverNotification,
updateResendNotification,
updateSlackNotification,
updateTelegramNotification,
} from "@dokploy/server";
@@ -53,7 +50,6 @@ import {
apiCreateLark,
apiCreateNtfy,
apiCreatePushover,
apiCreateResend,
apiCreateSlack,
apiCreateTelegram,
apiFindOneNotification,
@@ -64,7 +60,6 @@ import {
apiTestLarkConnection,
apiTestNtfyConnection,
apiTestPushoverConnection,
apiTestResendConnection,
apiTestSlackConnection,
apiTestTelegramConnection,
apiUpdateCustom,
@@ -74,7 +69,6 @@ import {
apiUpdateLark,
apiUpdateNtfy,
apiUpdatePushover,
apiUpdateResend,
apiUpdateSlack,
apiUpdateTelegram,
notifications,
@@ -308,63 +302,6 @@ 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 }) => {
@@ -407,7 +344,6 @@ export const notificationRouter = createTRPCRouter({
telegram: true,
discord: true,
email: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
@@ -766,7 +702,6 @@ export const notificationRouter = createTRPCRouter({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
with: {
email: true,
resend: true,
},
});
}),

View File

@@ -1,6 +1,5 @@
import {
addNewService,
checkPortInUse,
checkServiceAccess,
createMount,
createPostgres,
@@ -193,20 +192,6 @@ 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,
});

View File

@@ -506,7 +506,7 @@ export const projectRouter = createTRPCRouter({
}
for (const backup of backups) {
const { backupId, appName: _appName, ...rest } = backup;
const { backupId, ...rest } = backup;
await createBackup({
...rest,
postgresId: newPostgres.postgresId,
@@ -542,7 +542,7 @@ export const projectRouter = createTRPCRouter({
}
for (const backup of backups) {
const { backupId, appName: _appName, ...rest } = backup;
const { backupId, ...rest } = backup;
await createBackup({
...rest,
mariadbId: newMariadb.mariadbId,
@@ -578,7 +578,7 @@ export const projectRouter = createTRPCRouter({
}
for (const backup of backups) {
const { backupId, appName: _appName, ...rest } = backup;
const { backupId, ...rest } = backup;
await createBackup({
...rest,
mongoId: newMongo.mongoId,
@@ -614,7 +614,7 @@ export const projectRouter = createTRPCRouter({
}
for (const backup of backups) {
const { backupId, appName: _appName, ...rest } = backup;
const { backupId, ...rest } = backup;
await createBackup({
...rest,
mysqlId: newMysql.mysqlId,

View File

@@ -26,13 +26,6 @@ export const licenseKeyRouter = createTRPCRouter({
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to activate a license key",
});
}
if (!currentUser.enableEnterpriseFeatures) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -124,14 +117,6 @@ export const licenseKeyRouter = createTRPCRouter({
message: "No license key found",
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to deactivate a license key",
});
}
await deactivateLicenseKey(currentUser.licenseKey);
await db
.update(user)

View File

@@ -1,8 +1,6 @@
import { normalizeTrustedOrigin } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
import { member, ssoProvider } from "@dokploy/server/db/schema";
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
import { requestToHeaders } from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
@@ -14,6 +12,20 @@ import {
} from "@/server/api/trpc";
import { db } from "@/server/db";
function requestToHeaders(req: {
headers?: Record<string, string | string[] | undefined>;
}): Headers {
const headers = new Headers();
if (req?.headers) {
for (const [key, value] of Object.entries(req.headers)) {
if (value !== undefined && key.toLowerCase() !== "host") {
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
}
}
return headers;
}
export const ssoRouter = createTRPCRouter({
showSignInWithSSO: publicProcedure.query(async () => {
if (IS_CLOUD) {
@@ -61,28 +73,6 @@ export const ssoRouter = createTRPCRouter({
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
// Obtener el provider antes de eliminarlo para obtener sus dominios
const providerToDelete = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
domain: true,
issuer: true,
},
});
if (!providerToDelete) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to delete it",
});
}
const [deleted] = await db
.delete(ssoProvider)
.where(
@@ -102,24 +92,6 @@ export const ssoRouter = createTRPCRouter({
});
}
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
if (currentUser?.trustedOrigins) {
const issuerOrigin = normalizeTrustedOrigin(providerToDelete.issuer);
const updatedOrigins = currentUser.trustedOrigins.filter(
(origin) => origin.toLowerCase() !== issuerOrigin.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: updatedOrigins })
.where(eq(user.id, ctx.session.userId));
}
return { success: true };
}),
register: enterpriseProcedure
@@ -127,54 +99,15 @@ export const ssoRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const organizationId = ctx.session.activeOrganizationId;
const providers = await db.query.ssoProvider.findMany({
columns: {
domain: true,
},
});
for (const provider of providers) {
const providerDomains = provider.domain
.split(",")
.map((d) => d.trim().toLowerCase());
for (const domain of input.domains) {
if (providerDomains.includes(domain)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Domain ${domain} is already registered for another provider`,
});
}
}
}
const domain = input.domains.join(",");
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
const existingOrigins = currentUser?.trustedOrigins || [];
const issuerOrigin = normalizeTrustedOrigin(input.issuer);
const newOrigins = Array.from(
new Set([...existingOrigins, issuerOrigin]),
);
await db
.update(user)
.set({ trustedOrigins: newOrigins })
.where(eq(user.id, ctx.session.userId));
await auth.registerSSOProvider({
const result = await auth.registerSSOProvider({
body: {
...input,
organizationId,
domain,
},
headers: requestToHeaders(ctx.req),
});
console.log(result);
return { success: true };
}),
});

View File

@@ -1,6 +1,5 @@
import {
addNewService,
checkPortInUse,
checkServiceAccess,
createMount,
createRedis,
@@ -202,9 +201,9 @@ export const redisRouter = createTRPCRouter({
saveExternalPort: protectedProcedure
.input(apiSaveExternalPortRedis)
.mutation(async ({ input, ctx }) => {
const redis = await findRedisById(input.redisId);
const mongo = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
@@ -212,25 +211,11 @@ 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 redis;
return mongo;
}),
deploy: protectedProcedure
.input(apiDeployRedis)

View File

@@ -1,5 +1,4 @@
import {
CLEANUP_CRON_JOB,
canAccessToTraefikFiles,
checkGPUStatus,
checkPortInUse,
@@ -13,6 +12,7 @@ import {
DEFAULT_UPDATE_DATA,
execAsync,
findServerById,
getDokployImage,
getDokployImageTag,
getLogCleanupStatus,
getUpdateData,
@@ -22,6 +22,7 @@ import {
paths,
prepareEnvironmentVariables,
processLogs,
pullLatestRelease,
readConfig,
readConfigInPath,
readDirectory,
@@ -62,16 +63,17 @@ import {
apiServerSchema,
apiTraefikConfig,
apiUpdateDockerCleanup,
apiUpdateWhitelabel,
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";
import {
adminProcedure,
createTRPCRouter,
enterpriseProcedure,
protectedProcedure,
publicProcedure,
} from "../trpc";
@@ -84,6 +86,57 @@ export const settingsRouter = createTRPCRouter({
const settings = await getWebServerSettings();
return settings;
}),
getWhitelabelSettings: publicProcedure.query(async () => {
if (IS_CLOUD) {
return null;
}
const settings = await getWebServerSettings();
if (!settings) return null;
return {
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? null,
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? null,
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? null,
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? null,
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? null,
whitelabelLoginBackgroundImageUrl:
settings.whitelabelLoginBackgroundImageUrl ?? null,
};
}),
updateWhitelabelSettings: enterpriseProcedure
.input(apiUpdateWhitelabel)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
return null;
}
const updates: Record<string, unknown> = {};
if (input.whitelabelAppName !== undefined)
updates.whitelabelAppName = input.whitelabelAppName;
if (input.whitelabelLogoUrl !== undefined)
updates.whitelabelLogoUrl =
input.whitelabelLogoUrl === "" ? null : input.whitelabelLogoUrl;
if (input.whitelabelLoginLogoUrl !== undefined)
updates.whitelabelLoginLogoUrl =
input.whitelabelLoginLogoUrl === ""
? null
: input.whitelabelLoginLogoUrl;
if (input.whitelabelFaviconUrl !== undefined)
updates.whitelabelFaviconUrl =
input.whitelabelFaviconUrl === ""
? null
: input.whitelabelFaviconUrl;
if (input.whitelabelLoginTitle !== undefined)
updates.whitelabelLoginTitle = input.whitelabelLoginTitle;
if (input.whitelabelLoginSubtitle !== undefined)
updates.whitelabelLoginSubtitle = input.whitelabelLoginSubtitle;
if (input.whitelabelLoginBackgroundImageUrl !== undefined)
updates.whitelabelLoginBackgroundImageUrl =
input.whitelabelLoginBackgroundImageUrl === ""
? null
: input.whitelabelLoginBackgroundImageUrl;
const updated = await updateWebServerSettings(updates as any);
return updated;
}),
reloadServer: adminProcedure.mutation(async () => {
if (IS_CLOUD) {
return true;
@@ -117,21 +170,15 @@ export const settingsRouter = createTRPCRouter({
return true;
}),
cleanAllDeploymentQueue: adminProcedure.mutation(async () => {
if (IS_CLOUD) {
return true;
}
return cleanAllDeploymentQueue();
}),
reloadTraefik: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
// Run in background so the request returns immediately; avoids proxy timeouts.
void reloadDockerResource("dokploy-traefik", input?.serverId).catch(
(err) => {
console.error("reloadTraefik background:", err);
},
);
try {
await reloadDockerResource("dokploy-traefik", input?.serverId);
} catch (err) {
console.error(err);
}
return true;
}),
toggleDashboard: adminProcedure
@@ -166,14 +213,10 @@ export const settingsRouter = createTRPCRouter({
newPorts = ports.filter((port) => port.targetPort !== 8080);
}
// Run in background so the request returns immediately; client polls /api/health.
// Avoids proxy timeouts (520) while Traefik is recreated.
void writeTraefikSetup({
await writeTraefikSetup({
env: preparedEnv,
additionalPorts: newPorts,
serverId: input.serverId,
}).catch((err) => {
console.error("toggleDashboard background writeTraefikSetup:", err);
});
return true;
}),
@@ -299,12 +342,12 @@ export const settingsRouter = createTRPCRouter({
}
if (IS_CLOUD) {
await schedule({
cronSchedule: CLEANUP_CRON_JOB,
cronSchedule: "0 0 * * *",
serverId: input.serverId,
type: "server",
});
} else {
scheduleJob(server.serverId, CLEANUP_CRON_JOB, async () => {
scheduleJob(server.serverId, "0 0 * * *", async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
@@ -317,7 +360,7 @@ export const settingsRouter = createTRPCRouter({
} else {
if (IS_CLOUD) {
await removeJob({
cronSchedule: CLEANUP_CRON_JOB,
cronSchedule: "0 0 * * *",
serverId: input.serverId,
type: "server",
});
@@ -332,7 +375,7 @@ export const settingsRouter = createTRPCRouter({
});
if (settingsUpdated?.enableDockerCleanup) {
scheduleJob("docker-cleanup", CLEANUP_CRON_JOB, async () => {
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
@@ -416,17 +459,18 @@ export const settingsRouter = createTRPCRouter({
return true;
}
const data = await getUpdateData(packageInfo.version);
if (data.updateAvailable) {
void spawnAsync("docker", [
"service",
"update",
"--force",
"--image",
`dokploy/dokploy:${data.latestVersion}`,
"dokploy",
]);
}
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",
]);
return true;
}),
@@ -613,14 +657,12 @@ export const settingsRouter = createTRPCRouter({
const envs = prepareEnvironmentVariables(input.env);
const ports = await readPorts("dokploy-traefik", input?.serverId);
// Run in background so the request returns immediately; client polls /api/health.
void writeTraefikSetup({
await writeTraefikSetup({
env: envs,
additionalPorts: ports,
serverId: input.serverId,
}).catch((err) => {
console.error("writeTraefikEnv background writeTraefikSetup:", err);
});
return true;
}),
haveTraefikDashboardPortEnabled: adminProcedure
@@ -764,13 +806,16 @@ export const settingsRouter = createTRPCRouter({
return haveServers.length > 0 || haveProjects.length > 0;
}),
health: publicProcedure.query(async () => {
try {
await db.execute(sql`SELECT 1`);
return { status: "ok" };
} catch (error) {
console.error("Database connection error:", error);
throw error;
if (IS_CLOUD) {
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(
@@ -865,16 +910,10 @@ export const settingsRouter = createTRPCRouter({
}
const preparedEnv = prepareEnvironmentVariables(env);
// Run in background so the request returns immediately; client polls /api/health.
void writeTraefikSetup({
await writeTraefikSetup({
env: preparedEnv,
additionalPorts: input.additionalPorts,
serverId: input.serverId,
}).catch((err) => {
console.error(
"updateTraefikPorts background writeTraefikSetup:",
err,
);
});
return true;
} catch (error) {

View File

@@ -9,7 +9,6 @@ import {
IS_CLOUD,
removeUserById,
sendEmailNotification,
sendResendNotification,
updateUser,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -510,16 +509,15 @@ 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 && !resend) {
if (!email) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email provider not found",
message: "Email notification not found",
});
}
@@ -534,29 +532,16 @@ export const userRouter = createTRPCRouter({
);
try {
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,
);
}
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>
`,
);
} catch (error) {
console.log(error);
throw error;

View File

@@ -21,7 +21,7 @@ import {
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { desc, eq } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure } from "../trpc";
@@ -54,7 +54,6 @@ export const volumeBackupsRouter = createTRPCRouter({
redis: true,
compose: true,
},
orderBy: [desc(volumeBackups.createdAt)],
});
}),
create: protectedProcedure

View File

@@ -7,8 +7,10 @@
* need to use are documented accordingly near the end.
*/
import { user as userSchema } from "@dokploy/server/db/schema";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
import { eq } from "drizzle-orm";
import { initTRPC, TRPCError } from "@trpc/server";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import {
@@ -31,14 +33,7 @@ import { db } from "@/server/db";
*/
interface CreateContextOptions {
user:
| (User & {
role: "member" | "admin" | "owner";
ownerId: string;
enableEnterpriseFeatures: boolean;
isValidEnterpriseLicense: boolean;
})
| null;
user: (User & { role: "member" | "admin" | "owner"; ownerId: string }) | null;
session:
| (Session & { activeOrganizationId: string; impersonatedBy?: string })
| null;
@@ -239,9 +234,17 @@ export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const currentUser = await ctx.db.query.user.findFirst({
where: eq(userSchema.id, ctx.user.id),
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
});
if (
!ctx.user?.enableEnterpriseFeatures ||
!ctx.user.isValidEnterpriseLicense
!currentUser?.enableEnterpriseFeatures ||
!currentUser.isValidEnterpriseLicense
) {
throw new TRPCError({
code: "FORBIDDEN",

View File

@@ -3,23 +3,12 @@ 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);
@@ -45,11 +34,6 @@ 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"]);

View File

@@ -15,6 +15,7 @@ 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";
@@ -59,6 +60,7 @@ void app.prepare().then(async () => {
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
createDefaultMiddlewares();
await initializeNetwork();
await migration();
await initCronJobs();
await initSchedules();
await initCancelDeployments();
@@ -66,6 +68,10 @@ 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();

View File

@@ -1,4 +1,6 @@
import { getPublicIpWithFallback, LICENSE_KEY_URL } from "@dokploy/server";
import { getPublicIpWithFallback } from "@dokploy/server/index";
const LICENSE_KEY_URL = process.env.LICENSE_KEY_URL || "http://localhost:4002";
export const validateLicenseKey = async (licenseKey: string) => {
try {

View File

@@ -34,13 +34,14 @@ 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, serverId)) {
if (!readValidDirectory(logPath)) {
ws.close(4000, "Invalid log path");
return;
}

View File

@@ -32,11 +32,8 @@ export const isValidShell = (shell: string): boolean => {
return allowedShells.includes(shell);
};
export const readValidDirectory = (
directory: string,
serverId?: string | null,
) => {
const { BASE_PATH } = paths(!!serverId);
export const readValidDirectory = (directory: string) => {
const { BASE_PATH } = paths();
const resolvedBase = path.resolve(BASE_PATH);
const resolvedDir = path.resolve(directory);

View File

@@ -1,8 +1,5 @@
import { exit } from "node:process";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import { execAsync } from "@dokploy/server";
import { setupDirectories } from "@dokploy/server/setup/config-paths";
import { initializePostgres } from "@dokploy/server/setup/postgres-setup";
import { initializeRedis } from "@dokploy/server/setup/redis-setup";
@@ -15,7 +12,6 @@ import {
createDefaultServerTraefikConfig,
createDefaultTraefikConfig,
initializeStandaloneTraefik,
TRAEFIK_VERSION,
} from "@dokploy/server/setup/traefik-setup";
(async () => {
@@ -26,7 +22,7 @@ import {
await initializeNetwork();
createDefaultTraefikConfig();
createDefaultServerTraefikConfig();
await execAsync(`docker pull traefik:v${TRAEFIK_VERSION}`);
await execAsync("docker pull traefik:v3.6.7");
await initializeStandaloneTraefik();
await initializeRedis();
await initializePostgres();

View File

@@ -1,91 +0,0 @@
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);
});

View File

@@ -11,10 +11,10 @@
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0",
"bullmq": "5.67.3",
"bullmq": "5.4.2",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.41.0",
"hono": "^4.11.7",
"drizzle-orm": "^0.39.3",
"hono": "^4.7.10",
"ioredis": "5.4.1",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
@@ -23,7 +23,7 @@
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^20.16.0",
"@types/node": "^20.17.51",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"tsx": "^4.16.2",

View File

@@ -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");

View File

@@ -1,11 +1,13 @@
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: {
url: process.env.REDIS_URL!,
},
connection,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,

View File

@@ -1,5 +1,4 @@
import {
CLEANUP_CRON_JOB,
cleanupAll,
findBackupById,
findScheduleById,
@@ -126,7 +125,7 @@ export const initializeJobs = async () => {
scheduleJob({
serverId,
type: "server",
cronSchedule: CLEANUP_CRON_JOB,
cronSchedule: "0 0 * * *",
});
}

View File

@@ -1,5 +1,6 @@
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";
@@ -11,9 +12,7 @@ export const firstWorker = new Worker(
},
{
concurrency: 100,
connection: {
url: process.env.REDIS_URL!,
},
connection,
},
);
export const secondWorker = new Worker(
@@ -24,9 +23,7 @@ export const secondWorker = new Worker(
},
{
concurrency: 100,
connection: {
url: process.env.REDIS_URL!,
},
connection,
},
);
@@ -38,8 +35,6 @@ export const thirdWorker = new Worker(
},
{
concurrency: 100,
connection: {
url: process.env.REDIS_URL!,
},
connection,
},
);

View File

@@ -24,7 +24,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.1.1",
"@types/node": "^20.16.0",
"@types/node": "^18.19.104",
"dotenv": "16.4.5",
"esbuild": "0.20.2",
"lint-staged": "^15.5.2",

View File

@@ -37,7 +37,7 @@
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@better-auth/utils": "0.3.0",
"@better-auth/utils": "0.2.4",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.1.3",
"@octokit/rest": "^20.1.2",
@@ -57,7 +57,7 @@
"dockerode": "4.0.2",
"dotenv": "16.4.5",
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "^0.41.0",
"drizzle-orm": "^0.39.3",
"drizzle-zod": "0.5.1",
"yaml": "2.8.1",
"lodash": "4.17.21",
@@ -75,7 +75,6 @@
"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",
@@ -92,7 +91,7 @@
"@types/dockerode": "3.3.23",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^20.16.0",
"@types/node": "^18.19.104",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
@@ -101,7 +100,7 @@
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.31.4",
"drizzle-kit": "^0.30.6",
"esbuild": "0.20.2",
"esbuild-plugin-alias": "0.2.1",
"postcss": "^8.5.3",

1200
packages/server/schema.dbml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,8 @@ 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"
@@ -31,6 +25,5 @@ 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`,
};
};

View File

@@ -26,8 +26,7 @@ if (DATABASE_URL) {
password,
)}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`;
} else {
if (process.env.NODE_ENV !== "test") {
console.warn(`
console.warn(`
⚠️ [DEPRECATED DATABASE CONFIG]
You are using the legacy hardcoded database credentials.
This mode WILL BE REMOVED in a future release.
@@ -35,13 +34,5 @@ if (DATABASE_URL) {
Please migrate to Docker Secrets using POSTGRES_PASSWORD_FILE.
Please execute this command in your server: curl -sSL https://dokploy.com/security/0.26.6.sh | bash
`);
}
if (process.env.NODE_ENV === "production") {
dbUrl =
"postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
} else {
dbUrl =
"postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy";
}
dbUrl = "postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy";
}

View File

@@ -155,7 +155,6 @@ 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 }) => ({

View File

@@ -11,7 +11,6 @@ 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"),
@@ -41,7 +40,6 @@ 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(),
@@ -78,7 +76,6 @@ 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(),

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