mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-17 05:05:22 +02:00
Compare commits
69 Commits
v0.28.3
...
dosu/doc-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cff1118794 | ||
|
|
2879816e41 | ||
|
|
3501996b9e | ||
|
|
47556a6486 | ||
|
|
e554adc376 | ||
|
|
1804b935f6 | ||
|
|
985c9102da | ||
|
|
2e03cf3d48 | ||
|
|
33532d3cf7 | ||
|
|
a6999b1cf2 | ||
|
|
f5d18d6f9b | ||
|
|
e3ff7ef3e3 | ||
|
|
b84bc9b7c6 | ||
|
|
6e67864204 | ||
|
|
2102840bb9 | ||
|
|
30f061e774 | ||
|
|
c00aa6acbf | ||
|
|
8e9ab98a7a | ||
|
|
ce82e2322b | ||
|
|
ec7df05990 | ||
|
|
75a4e8e8ef | ||
|
|
b4319c7ea2 | ||
|
|
e9787b753d | ||
|
|
b419294b09 | ||
|
|
922b4d58f1 | ||
|
|
dc8ff78ee5 | ||
|
|
735c9952d8 | ||
|
|
21821295e3 | ||
|
|
a8467e80e8 | ||
|
|
95e14b4199 | ||
|
|
076262e479 | ||
|
|
c4f4db3ebc | ||
|
|
4882bd25ad | ||
|
|
7a8f2e53d5 | ||
|
|
50182a8048 | ||
|
|
35d35028f6 | ||
|
|
a5a4a1a818 | ||
|
|
c106d13ab5 | ||
|
|
808001d8de | ||
|
|
ce24eadbb4 | ||
|
|
b87f8cc5d8 | ||
|
|
f650200771 | ||
|
|
f961dc6e7a | ||
|
|
4be25da185 | ||
|
|
675c1d7a7d | ||
|
|
28cc361c47 | ||
|
|
cedec5239f | ||
|
|
2f4cbbd3ac | ||
|
|
38b20450dc | ||
|
|
49f43ab3fb | ||
|
|
2eae756cec | ||
|
|
70c261d021 | ||
|
|
9ae2ebff46 | ||
|
|
8ce880d108 | ||
|
|
34304526b1 | ||
|
|
a16c4c1294 | ||
|
|
d1c4ac20e3 | ||
|
|
0195119a86 | ||
|
|
48a577e792 | ||
|
|
bf7a75dd9f | ||
|
|
d316aa4401 | ||
|
|
f1b2cc35b3 | ||
|
|
d2fabc998d | ||
|
|
7185047eb7 | ||
|
|
7121fbe50a | ||
|
|
36cf3a69fc | ||
|
|
c34a01a173 | ||
|
|
9ac147a140 | ||
|
|
20f79ac655 |
@@ -62,6 +62,16 @@ pnpm install
|
||||
cp apps/dokploy/.env.example apps/dokploy/.env
|
||||
```
|
||||
|
||||
### Optional Docker Configuration
|
||||
|
||||
The following environment variables can be added to your `.env` file if you need custom Docker daemon configuration:
|
||||
|
||||
- **DOCKER_API_VERSION**: Specify which Docker API version to use (optional)
|
||||
- **DOKPLOY_DOCKER_HOST**: Specify a custom Docker daemon host (optional)
|
||||
- **DOKPLOY_DOCKER_PORT**: Specify a custom Docker daemon port (optional)
|
||||
|
||||
These variables are typically not needed for standard local development but can be useful if you need to connect to a remote Docker daemon or require a specific Docker API version.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Docker](/GUIDES.md#docker)
|
||||
@@ -171,6 +181,11 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
|
||||
- **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`).
|
||||
- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing.
|
||||
|
||||
### Pull Request Guidelines
|
||||
|
||||
- **Keep PRs small and focused.** Avoid very large PRs; prefer several smaller PRs (e.g., one template or one logical change per PR). This speeds up review and keeps the history clear.
|
||||
- **Test before submitting.** Any PR that has not been tested by the contributor will be closed. This keeps the PR queue tidy and ensures that only contributions that have been verified by their authors are considered.
|
||||
|
||||
Thank you for your contribution!
|
||||
|
||||
## Templates
|
||||
|
||||
@@ -6,3 +6,346 @@ npm run dev
|
||||
```
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The API server requires the following environment variables for configuration:
|
||||
|
||||
### Inngest Configuration
|
||||
|
||||
Required for the GET /jobs endpoint to list deployment jobs:
|
||||
|
||||
- **INNGEST_BASE_URL** - The base URL for the Inngest instance
|
||||
- Self-hosted: `http://localhost:8288`
|
||||
- Production: `https://dev-inngest.dokploy.com`
|
||||
|
||||
- **INNGEST_SIGNING_KEY** - The signing key for authenticating with Inngest
|
||||
|
||||
Optional configuration for filtering and pagination:
|
||||
|
||||
- **INNGEST_EVENTS_RECEIVED_AFTER** (optional) - An RFC3339 timestamp to filter events received after a specific date (e.g., `2024-01-01T00:00:00Z`). If unset, no date filter is applied.
|
||||
|
||||
- **INNGEST_JOBS_MAX_EVENTS** (optional) - Maximum number of events to fetch when listing jobs. Default is 100, maximum is 10000. Used for pagination with cursor.
|
||||
|
||||
### Lemon Squeezy Integration
|
||||
|
||||
- **LEMON_SQUEEZY_API_KEY** - API key for Lemon Squeezy integration
|
||||
- **LEMON_SQUEEZY_STORE_ID** - Store ID for Lemon Squeezy integration
|
||||
|
||||
### Docker Configuration
|
||||
|
||||
Optional configuration for customizing Docker daemon connections:
|
||||
|
||||
- **DOCKER_API_VERSION** (optional) - Specifies which Docker API version to use when connecting to the Docker daemon. If not set, the Docker client uses the default API version.
|
||||
|
||||
- **DOKPLOY_DOCKER_HOST** (optional) - Specifies the Docker daemon host to connect to. If not set, uses the default Docker socket connection.
|
||||
|
||||
- **DOKPLOY_DOCKER_PORT** (optional) - Specifies the port for connecting to the Docker daemon. If not set, uses the default port.
|
||||
|
||||
These variables allow advanced users to customize how the Dokploy API server connects to Docker, which can be useful for connecting to remote Docker daemons or using specific API versions.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET /jobs
|
||||
|
||||
Lists deployment jobs (Inngest runs) for a specified server.
|
||||
|
||||
**Query Parameters:**
|
||||
- `serverId` (required) - The ID of the server to list deployment jobs for
|
||||
|
||||
**Response:**
|
||||
Returns an array of deployment job objects with the same shape as BullMQ queue jobs:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"data": {},
|
||||
"timestamp": 0,
|
||||
"processedOn": 0,
|
||||
"finishedOn": 0,
|
||||
"failedReason": "string",
|
||||
"state": "string"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400` - serverId is not provided
|
||||
- `503` - INNGEST_BASE_URL is not configured
|
||||
- `200` - Empty array on other errors
|
||||
|
||||
This endpoint is used by the UI to display deployment queue information in the dashboard.
|
||||
|
||||
## Search Endpoints
|
||||
|
||||
The following search endpoints provide flexible querying capabilities with pagination support. All search endpoints respect member permissions, returning only resources the user has access to.
|
||||
|
||||
### application.search
|
||||
|
||||
Search applications across name, appName, description, repository, owner, and dockerImage fields.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term that searches across name, appName, description, repository, owner, and dockerImage
|
||||
- `name` (optional string) - Filter by application name
|
||||
- `appName` (optional string) - Filter by app name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `repository` (optional string) - Filter by repository
|
||||
- `owner` (optional string) - Filter by owner
|
||||
- `dockerImage` (optional string) - Filter by Docker image
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `environmentId` (optional string) - Filter by environment ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum number of results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"applicationId": "string",
|
||||
"name": "string",
|
||||
"appName": "string",
|
||||
"description": "string",
|
||||
"environmentId": "string",
|
||||
"applicationStatus": "string",
|
||||
"sourceType": "string",
|
||||
"createdAt": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### compose.search
|
||||
|
||||
Search compose services with filtering by name, appName, and description.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name, appName, description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `appName` (optional string) - Filter by app name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `environmentId` (optional string) - Filter by environment ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"composeId": "string",
|
||||
"name": "string",
|
||||
"appName": "string",
|
||||
"description": "string",
|
||||
"environmentId": "string",
|
||||
"composeStatus": "string",
|
||||
"sourceType": "string",
|
||||
"createdAt": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### environment.search
|
||||
|
||||
Search environments by name and description.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name and description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"environmentId": "string",
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"createdAt": "string",
|
||||
"env": "string",
|
||||
"projectId": "string",
|
||||
"isDefault": true
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### project.search
|
||||
|
||||
Search projects by name and description.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name and description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"projectId": "string",
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"createdAt": "string",
|
||||
"organizationId": "string",
|
||||
"env": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Database Service Search Endpoints
|
||||
|
||||
The following database services all share the same search interface:
|
||||
- **postgres.search**
|
||||
- **mysql.search**
|
||||
- **mariadb.search**
|
||||
- **mongo.search**
|
||||
- **redis.search**
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name, appName, description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `appName` (optional string) - Filter by app name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `environmentId` (optional string) - Filter by environment ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"postgresId": "string",
|
||||
"name": "string",
|
||||
"appName": "string",
|
||||
"description": "string",
|
||||
"environmentId": "string",
|
||||
"applicationStatus": "string",
|
||||
"createdAt": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
*Note: The response shape is similar across all database services, with the ID field varying (e.g., `mysqlId`, `mariadbId`, `mongoId`, `redisId`).*
|
||||
|
||||
**Search Behavior:**
|
||||
- All searches use case-insensitive pattern matching with wildcards
|
||||
- Results are ordered by creation date (descending)
|
||||
- Members only see services they have access to
|
||||
- Returns total count for pagination UI
|
||||
|
||||
## Whitelabeling Endpoints
|
||||
|
||||
The whitelabeling endpoints allow enterprise/self-hosted Dokploy instances to customize branding, logos, colors, and UI appearance. These endpoints are only available in self-hosted mode (not cloud).
|
||||
|
||||
### whitelabeling.get
|
||||
|
||||
Get the current whitelabeling configuration.
|
||||
|
||||
**Requirements:**
|
||||
- Enterprise license required
|
||||
- Only available for self-hosted (not cloud)
|
||||
|
||||
**Response:**
|
||||
Returns the whitelabeling configuration object or null if not configured.
|
||||
|
||||
```json
|
||||
{
|
||||
"appName": "string | null",
|
||||
"appDescription": "string | null",
|
||||
"logoUrl": "string | null",
|
||||
"faviconUrl": "string | null",
|
||||
"primaryColor": "string | null",
|
||||
"customCss": "string | null",
|
||||
"loginLogoUrl": "string | null",
|
||||
"supportUrl": "string | null",
|
||||
"docsUrl": "string | null",
|
||||
"errorPageTitle": "string | null",
|
||||
"errorPageDescription": "string | null",
|
||||
"metaTitle": "string | null",
|
||||
"footerText": "string | null"
|
||||
}
|
||||
```
|
||||
|
||||
### whitelabeling.update
|
||||
|
||||
Update the whitelabeling configuration.
|
||||
|
||||
**Requirements:**
|
||||
- Enterprise license required
|
||||
- Owner role required
|
||||
- Only available for self-hosted (not cloud)
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"whitelabelingConfig": {
|
||||
"appName": "string | null",
|
||||
"appDescription": "string | null",
|
||||
"logoUrl": "string | null",
|
||||
"faviconUrl": "string | null",
|
||||
"primaryColor": "string | null",
|
||||
"customCss": "string | null",
|
||||
"loginLogoUrl": "string | null",
|
||||
"supportUrl": "string | null",
|
||||
"docsUrl": "string | null",
|
||||
"errorPageTitle": "string | null",
|
||||
"errorPageDescription": "string | null",
|
||||
"metaTitle": "string | null",
|
||||
"footerText": "string | null"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### whitelabeling.reset
|
||||
|
||||
Reset whitelabeling configuration to default values (all fields set to null).
|
||||
|
||||
**Requirements:**
|
||||
- Enterprise license required
|
||||
- Owner role required
|
||||
- Only available for self-hosted (not cloud)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### whitelabeling.getPublic
|
||||
|
||||
Public endpoint to fetch whitelabeling configuration. This endpoint can be accessed without authentication, allowing the whitelabeling settings to be applied globally (including on the login page before auth).
|
||||
|
||||
**Requirements:**
|
||||
- No authentication required
|
||||
- Only available for self-hosted (not cloud)
|
||||
|
||||
**Response:**
|
||||
Returns the whitelabeling configuration object or null if not configured. Response shape is identical to `whitelabeling.get`.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { logger } from "./logger";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
|
||||
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
} from "@dokploy/server";
|
||||
import type { DeployJob } from "./schema";
|
||||
import type { DeployJob } from "./schema.js";
|
||||
|
||||
export const deploy = async (job: DeployJob) => {
|
||||
try {
|
||||
|
||||
@@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = {
|
||||
urlCallback: "",
|
||||
},
|
||||
},
|
||||
whitelabelingConfig: {
|
||||
appName: null,
|
||||
appDescription: null,
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
customCss: null,
|
||||
loginLogoUrl: null,
|
||||
supportUrl: null,
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
cleanupCacheOnPreviews: false,
|
||||
|
||||
@@ -45,10 +45,12 @@ import {
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type User = typeof authClient.$Infer.Session.user;
|
||||
|
||||
export const ImpersonationBar = () => {
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [isImpersonating, setIsImpersonating] = useState(false);
|
||||
@@ -180,7 +182,10 @@ export const ImpersonationBar = () => {
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4 px-4 md:px-20 w-full">
|
||||
<Logo className="w-10 h-10" />
|
||||
<Logo
|
||||
className="w-10 h-10"
|
||||
logoUrl={whitelabeling?.logoUrl || undefined}
|
||||
/>
|
||||
{!isImpersonating ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const organizationSchema = z.object({
|
||||
@@ -55,8 +54,6 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
const { mutateAsync, isPending } = organizationId
|
||||
? api.organization.update.useMutation()
|
||||
: api.organization.create.useMutation();
|
||||
const { refetch: refetchActiveOrganization } =
|
||||
authClient.useActiveOrganization();
|
||||
|
||||
const form = useForm<OrganizationFormValues>({
|
||||
resolver: zodResolver(organizationSchema),
|
||||
@@ -89,7 +86,7 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
utils.organization.all.invalidate();
|
||||
if (organizationId) {
|
||||
utils.organization.one.invalidate({ organizationId });
|
||||
refetchActiveOrganization();
|
||||
utils.organization.active.invalidate();
|
||||
}
|
||||
setOpen(false);
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { StatusTooltip } from "../shared/status-tooltip";
|
||||
|
||||
@@ -56,7 +55,7 @@ export const SearchCommand = () => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
const { data } = api.project.all.useQuery(undefined, {
|
||||
enabled: !!session,
|
||||
});
|
||||
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const AddGithubProvider = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
const { data } = api.user.get.useQuery();
|
||||
const [manifest, setManifest] = useState("");
|
||||
const [isOrganization, setIsOrganization] = useState(false);
|
||||
@@ -52,7 +52,7 @@ export const AddGithubProvider = () => {
|
||||
);
|
||||
|
||||
setManifest(manifest);
|
||||
}, [data?.id, activeOrganization?.id, session?.user?.id]);
|
||||
}, [activeOrganization?.id, session?.user?.id]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -98,8 +98,8 @@ export const AddGithubProvider = () => {
|
||||
<form
|
||||
action={
|
||||
isOrganization
|
||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
||||
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||
}
|
||||
method="post"
|
||||
>
|
||||
@@ -131,11 +131,7 @@ export const AddGithubProvider = () => {
|
||||
Unsure if you already have an app?
|
||||
</a>
|
||||
<Button
|
||||
disabled={
|
||||
(isOrganization && organizationName.length < 1) ||
|
||||
!activeOrganization?.id ||
|
||||
!session?.user?.id
|
||||
}
|
||||
disabled={isOrganization && organizationName.length < 1}
|
||||
type="submit"
|
||||
className="self-end"
|
||||
>
|
||||
|
||||
@@ -55,7 +55,7 @@ export const AddInvitation = () => {
|
||||
api.notification.getEmailProviders.useQuery();
|
||||
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const form = useForm<AddInvitation>({
|
||||
defaultValues: {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ShowUsers = () => {
|
||||
const { mutateAsync } = api.user.remove.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
import { GithubIcon } from "../icons/data-tools-icons";
|
||||
import { Logo } from "../shared/logo";
|
||||
import { Button } from "../ui/button";
|
||||
@@ -9,23 +10,28 @@ interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export const OnboardingLayout = ({ children }: Props) => {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const appDescription =
|
||||
whitelabeling?.appDescription ||
|
||||
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
|
||||
const logoUrl =
|
||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
|
||||
|
||||
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" />
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
href="/"
|
||||
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} />
|
||||
{appName}
|
||||
</Link>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg text-primary">
|
||||
“The Open Source alternative to Netlify, Vercel,
|
||||
Heroku.”
|
||||
</p>
|
||||
<p className="text-lg text-primary">{appDescription}</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
LogIn,
|
||||
type LucideIcon,
|
||||
Package,
|
||||
Palette,
|
||||
PieChart,
|
||||
Rocket,
|
||||
Server,
|
||||
@@ -422,6 +423,14 @@ const MENU: Menu = {
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Whitelabeling",
|
||||
url: "/dashboard/settings/whitelabeling",
|
||||
icon: Palette,
|
||||
// Only enabled for owners in non-cloud environments (enterprise)
|
||||
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
||||
},
|
||||
],
|
||||
|
||||
help: [
|
||||
@@ -445,38 +454,39 @@ const MENU: Menu = {
|
||||
function createMenuForAuthUser(opts: {
|
||||
auth?: AuthQueryOutput;
|
||||
isCloud: boolean;
|
||||
whitelabeling?: {
|
||||
docsUrl?: string | null;
|
||||
supportUrl?: string | null;
|
||||
} | null;
|
||||
}): Menu {
|
||||
const filterEnabled = <
|
||||
T extends {
|
||||
isEnabled?: (o: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean;
|
||||
},
|
||||
>(
|
||||
items: readonly T[],
|
||||
): T[] =>
|
||||
items.filter((item) =>
|
||||
!item.isEnabled
|
||||
? true
|
||||
: item.isEnabled({ auth: opts.auth, isCloud: opts.isCloud }),
|
||||
) as T[];
|
||||
|
||||
// Apply whitelabeling URL overrides to help items
|
||||
const helpItems = filterEnabled(MENU.help).map((item) => {
|
||||
if (opts.whitelabeling?.docsUrl && item.name === "Documentation") {
|
||||
return { ...item, url: opts.whitelabeling.docsUrl };
|
||||
}
|
||||
if (opts.whitelabeling?.supportUrl && item.name === "Support") {
|
||||
return { ...item, url: opts.whitelabeling.supportUrl };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
return {
|
||||
// Filter the home items based on the user's role and permissions
|
||||
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
|
||||
home: MENU.home.filter((item) =>
|
||||
!item.isEnabled
|
||||
? true
|
||||
: item.isEnabled({
|
||||
auth: opts.auth,
|
||||
isCloud: opts.isCloud,
|
||||
}),
|
||||
),
|
||||
// Filter the settings items based on the user's role and permissions
|
||||
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
|
||||
settings: MENU.settings.filter((item) =>
|
||||
!item.isEnabled
|
||||
? true
|
||||
: item.isEnabled({
|
||||
auth: opts.auth,
|
||||
isCloud: opts.isCloud,
|
||||
}),
|
||||
),
|
||||
// Filter the help items based on the user's role and permissions
|
||||
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
|
||||
help: MENU.help.filter((item) =>
|
||||
!item.isEnabled
|
||||
? true
|
||||
: item.isEnabled({
|
||||
auth: opts.auth,
|
||||
isCloud: opts.isCloud,
|
||||
}),
|
||||
),
|
||||
home: filterEnabled(MENU.home),
|
||||
settings: filterEnabled(MENU.settings),
|
||||
help: helpItems,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -546,7 +556,7 @@ function SidebarLogo() {
|
||||
const { state } = useSidebar();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: user } = api.user.get.useQuery();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
const {
|
||||
data: organizations,
|
||||
refetch,
|
||||
@@ -557,8 +567,7 @@ function SidebarLogo() {
|
||||
const { mutateAsync: setDefaultOrganization, isPending: isSettingDefault } =
|
||||
api.organization.setDefault.useMutation();
|
||||
const { isMobile } = useSidebar();
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const _utils = api.useUtils();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const { data: invitations, refetch: refetchInvitations } =
|
||||
api.user.getInvitations.useQuery();
|
||||
@@ -886,6 +895,10 @@ export default function Page({ children }: Props) {
|
||||
const pathname = usePathname();
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||
const { data: whitelabeling } = api.whitelabeling.get.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const includesProjects = pathname?.includes("/dashboard/project");
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
@@ -894,7 +907,7 @@ export default function Page({ children }: Props) {
|
||||
home: filteredHome,
|
||||
settings: filteredSettings,
|
||||
help,
|
||||
} = createMenuForAuthUser({ auth, isCloud: !!isCloud });
|
||||
} = createMenuForAuthUser({ auth, isCloud: !!isCloud, whitelabeling });
|
||||
|
||||
const activeItem = findActiveNavItem(
|
||||
[...filteredHome, ...filteredSettings],
|
||||
@@ -1142,6 +1155,11 @@ export default function Page({ children }: Props) {
|
||||
<SidebarMenuItem>
|
||||
<UserNav />
|
||||
</SidebarMenuItem>
|
||||
{whitelabeling?.footerText && (
|
||||
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
|
||||
{whitelabeling.footerText}
|
||||
</div>
|
||||
)}
|
||||
{dokployVersion && (
|
||||
<>
|
||||
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
interface WhitelabelingPreviewProps {
|
||||
config: {
|
||||
appName?: string;
|
||||
logoUrl?: string;
|
||||
footerText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
||||
const appName = config.appName || "Dokploy";
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Live Preview</CardTitle>
|
||||
<CardDescription>
|
||||
A quick preview of how your branding changes will look.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
{/* Simulated sidebar header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
|
||||
{config.logoUrl ? (
|
||||
<img
|
||||
src={config.logoUrl}
|
||||
alt="Preview Logo"
|
||||
className="size-8 rounded-sm object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-sm flex items-center justify-center bg-primary text-primary-foreground font-bold text-sm">
|
||||
{appName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-semibold text-sm">{appName}</span>
|
||||
</div>
|
||||
|
||||
{/* Simulated content area */}
|
||||
<div className="p-4 bg-background">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-2 w-16 rounded-full bg-primary" />
|
||||
<div className="h-2 w-24 rounded-full bg-muted" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="px-3 py-1.5 rounded-md text-xs bg-primary text-primary-foreground font-medium">
|
||||
Button
|
||||
</div>
|
||||
<div className="px-3 py-1.5 rounded-md text-xs border font-medium">
|
||||
Secondary
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simulated footer */}
|
||||
{config.footerText && (
|
||||
<div className="px-4 py-2 border-t text-xs text-muted-foreground text-center bg-sidebar">
|
||||
{config.footerText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import Head from "next/head";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export function WhitelabelingProvider() {
|
||||
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{config.metaTitle && <title>{config.metaTitle}</title>}
|
||||
{config.faviconUrl && <link rel="icon" href={config.faviconUrl} />}
|
||||
</Head>
|
||||
|
||||
{config.customCss && (
|
||||
<style
|
||||
id="whitelabeling-styles"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: config.customCss,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Loader2, RotateCcw } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { WhitelabelingPreview } from "./whitelabeling-preview";
|
||||
|
||||
const safeUrlField = z
|
||||
.string()
|
||||
.refine((val) => val === "" || /^https?:\/\//i.test(val), {
|
||||
message: "Only http:// and https:// URLs are allowed",
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
appName: z.string(),
|
||||
appDescription: z.string(),
|
||||
logoUrl: safeUrlField,
|
||||
faviconUrl: safeUrlField,
|
||||
customCss: z.string(),
|
||||
loginLogoUrl: safeUrlField,
|
||||
supportUrl: safeUrlField,
|
||||
docsUrl: safeUrlField,
|
||||
errorPageTitle: z.string(),
|
||||
errorPageDescription: z.string(),
|
||||
metaTitle: z.string(),
|
||||
footerText: z.string(),
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
const DEFAULT_CSS_TEMPLATE = `/* ============================================
|
||||
Dokploy Default Theme - CSS Variables
|
||||
Modify these values to customize your instance.
|
||||
============================================ */
|
||||
|
||||
/* ---------- Light Mode ---------- */
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
|
||||
--destructive: 0 84.2% 50.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
/* Charts */
|
||||
--chart-1: 173 58% 39%;
|
||||
--chart-2: 12 76% 61%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
/* ---------- Dark Mode ---------- */
|
||||
.dark {
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 4% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 4% 10%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 84.2% 50.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 4% 10%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
/* Charts */
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 340 75% 55%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 160 60% 45%;
|
||||
}
|
||||
|
||||
/* ---------- Custom Styles ---------- */
|
||||
/* Add your own CSS rules below */
|
||||
`;
|
||||
|
||||
export function WhitelabelingSettings() {
|
||||
const utils = api.useUtils();
|
||||
const {
|
||||
data,
|
||||
isPending: isLoading,
|
||||
refetch,
|
||||
} = api.whitelabeling.get.useQuery();
|
||||
|
||||
const { mutateAsync: updateWhitelabeling, isPending: isUpdating } =
|
||||
api.whitelabeling.update.useMutation();
|
||||
|
||||
const { mutateAsync: resetWhitelabeling, isPending: isResetting } =
|
||||
api.whitelabeling.reset.useMutation();
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
defaultValues: {
|
||||
appName: "",
|
||||
appDescription: "",
|
||||
logoUrl: "",
|
||||
faviconUrl: "",
|
||||
customCss: "",
|
||||
loginLogoUrl: "",
|
||||
supportUrl: "",
|
||||
docsUrl: "",
|
||||
errorPageTitle: "",
|
||||
errorPageDescription: "",
|
||||
metaTitle: "",
|
||||
footerText: "",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
appName: data.appName ?? "",
|
||||
appDescription: data.appDescription ?? "",
|
||||
logoUrl: data.logoUrl ?? "",
|
||||
faviconUrl: data.faviconUrl ?? "",
|
||||
customCss: data.customCss ?? "",
|
||||
loginLogoUrl: data.loginLogoUrl ?? "",
|
||||
supportUrl: data.supportUrl ?? "",
|
||||
docsUrl: data.docsUrl ?? "",
|
||||
errorPageTitle: data.errorPageTitle ?? "",
|
||||
errorPageDescription: data.errorPageDescription ?? "",
|
||||
metaTitle: data.metaTitle ?? "",
|
||||
footerText: data.footerText ?? "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
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 whitelabeling settings...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onSubmit = async (values: FormSchema) => {
|
||||
await updateWhitelabeling({
|
||||
whitelabelingConfig: {
|
||||
appName: values.appName || null,
|
||||
appDescription: values.appDescription || null,
|
||||
logoUrl: values.logoUrl || null,
|
||||
faviconUrl: values.faviconUrl || null,
|
||||
customCss: values.customCss || null,
|
||||
loginLogoUrl: values.loginLogoUrl || null,
|
||||
supportUrl: values.supportUrl || null,
|
||||
docsUrl: values.docsUrl || null,
|
||||
errorPageTitle: values.errorPageTitle || null,
|
||||
errorPageDescription: values.errorPageDescription || null,
|
||||
metaTitle: values.metaTitle || null,
|
||||
footerText: values.footerText || null,
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Whitelabeling settings updated");
|
||||
await refetch();
|
||||
await utils.whitelabeling.getPublic.invalidate();
|
||||
await utils.whitelabeling.get.invalidate();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error?.message || "Failed to update whitelabeling settings",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
await resetWhitelabeling()
|
||||
.then(async () => {
|
||||
toast.success("Whitelabeling settings reset to defaults");
|
||||
await refetch();
|
||||
await utils.whitelabeling.getPublic.invalidate();
|
||||
await utils.whitelabeling.get.invalidate();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error?.message || "Failed to reset whitelabeling settings");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{/* Branding Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Branding</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the application name, logos, and favicon to match your
|
||||
brand identity.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="appName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Dokploy" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Replaces "Dokploy" across the entire interface.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="appDescription"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="The Open Source alternative to Netlify, Vercel, Heroku."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Tagline shown on the login/onboarding pages. Defaults to
|
||||
the standard Dokploy description if empty.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/logo.svg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Main logo shown in the sidebar and header. Recommended
|
||||
size: 128x128px.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="loginLogoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login Page Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/login-logo.svg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Logo displayed on the login page. If empty, the main logo
|
||||
is used.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="faviconUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Favicon URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Browser tab icon. Supports .ico, .png, and .svg formats.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Appearance Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the look and feel of the application with custom CSS.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customCss"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Custom CSS</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
form.setValue("customCss", DEFAULT_CSS_TEMPLATE);
|
||||
}}
|
||||
>
|
||||
Load Default Styles
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="max-h-[350px] overflow-auto">
|
||||
<CodeEditor
|
||||
language="css"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="/* Click 'Load Default Styles' to start with the base theme variables */"
|
||||
lineWrapping
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Inject custom CSS styles globally. Click "Load Default
|
||||
Styles" to get the base theme CSS variables as a starting
|
||||
point.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Metadata & Links Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Metadata & Links</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the page title, footer text, and sidebar links.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metaTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Page Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Dokploy" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Browser tab title. Defaults to "Dokploy" if empty.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="footerText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Footer Text</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Powered by Your Company" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Custom text displayed in the footer area.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supportUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Support URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://support.example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Custom URL for the "Support" link in the sidebar.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="docsUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Documentation URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://docs.example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Custom URL for the "Documentation" link in the sidebar.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Pages Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Error Pages</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the error page messages shown to users.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="errorPageTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Error Page Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Something went wrong" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="errorPageDescription"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Error Page Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="We're sorry, but an unexpected error occurred. Please try again later."
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogAction
|
||||
title="Reset Whitelabeling"
|
||||
description="Are you sure you want to reset all whitelabeling settings to their defaults? This action cannot be undone."
|
||||
type="destructive"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<Button variant="outline" type="button" isLoading={isResetting}>
|
||||
<RotateCcw className="size-4 mr-2" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</DialogAction>
|
||||
|
||||
<Button type="submit" isLoading={isUpdating} disabled={isUpdating}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* Live Preview */}
|
||||
<WhitelabelingPreview config={form.watch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
type CompletionContext,
|
||||
type CompletionResult,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { yaml } from "@codemirror/lang-yaml";
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
import { properties } from "@codemirror/legacy-modes/mode/properties";
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { search, searchKeymap } from "@codemirror/search";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
|
||||
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
@@ -130,7 +132,7 @@ function dockerComposeComplete(
|
||||
interface Props extends ReactCodeMirrorProps {
|
||||
wrapperClassName?: string;
|
||||
disabled?: boolean;
|
||||
language?: "yaml" | "json" | "properties" | "shell";
|
||||
language?: "yaml" | "json" | "properties" | "shell" | "css";
|
||||
lineWrapping?: boolean;
|
||||
lineNumbers?: boolean;
|
||||
}
|
||||
@@ -155,13 +157,17 @@ export const CodeEditor = ({
|
||||
}}
|
||||
theme={resolvedTheme === "dark" ? githubDark : githubLight}
|
||||
extensions={[
|
||||
search(),
|
||||
keymap.of(searchKeymap),
|
||||
language === "yaml"
|
||||
? yaml()
|
||||
: language === "json"
|
||||
? json()
|
||||
: language === "shell"
|
||||
? StreamLanguage.define(shell)
|
||||
: StreamLanguage.define(properties),
|
||||
: language === "css"
|
||||
? css()
|
||||
: language === "shell"
|
||||
? StreamLanguage.define(shell)
|
||||
: StreamLanguage.define(properties),
|
||||
props.lineWrapping ? EditorView.lineWrapping : [],
|
||||
language === "yaml"
|
||||
? autocompletion({
|
||||
|
||||
1
apps/dokploy/drizzle/0148_futuristic_bullseye.sql
Normal file
1
apps/dokploy/drizzle/0148_futuristic_bullseye.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelingConfig" jsonb DEFAULT '{"appName":null,"appDescription":null,"logoUrl":null,"faviconUrl":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"metaTitle":null,"footerText":null}'::jsonb;
|
||||
7467
apps/dokploy/drizzle/meta/0148_snapshot.json
Normal file
7467
apps/dokploy/drizzle/meta/0148_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1037,6 +1037,13 @@
|
||||
"when": 1771830695385,
|
||||
"tag": "0147_right_lake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 148,
|
||||
"version": "7",
|
||||
"when": 1773129798212,
|
||||
"tag": "0148_futuristic_bullseye",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.28.3",
|
||||
"version": "v0.28.5",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -39,8 +39,6 @@
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"resend": "^6.0.2",
|
||||
"@better-auth/sso": "1.5.0-beta.16",
|
||||
"@ai-sdk/anthropic": "^3.0.44",
|
||||
"@ai-sdk/azure": "^3.0.30",
|
||||
"@ai-sdk/cohere": "^3.0.21",
|
||||
@@ -48,12 +46,15 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/sso": "1.5.0-beta.16",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/legacy-modes": "6.4.0",
|
||||
"@codemirror/view": "6.29.0",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@dokploy/trpc-openapi": "0.0.17",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
@@ -102,7 +103,6 @@
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.67.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^0.2.1",
|
||||
@@ -139,6 +139,9 @@
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-markdown": "^9.1.0",
|
||||
"recharts": "^2.15.3",
|
||||
"resend": "^6.0.2",
|
||||
"semver": "7.7.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.7.4",
|
||||
"ssh2": "1.15.0",
|
||||
@@ -154,12 +157,9 @@
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"yaml": "2.8.1",
|
||||
"zod": "^4.3.6",
|
||||
"zod-form-data": "^3.0.1",
|
||||
"semver": "7.7.3"
|
||||
"zod-form-data": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -171,6 +171,8 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
"@types/ws": "8.5.10",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ThemeProvider } from "next-themes";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||
import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
@@ -48,6 +49,7 @@ const MyApp = ({
|
||||
forcedTheme={Component.theme}
|
||||
>
|
||||
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
||||
<WhitelabelingProvider />
|
||||
<Toaster richColors />
|
||||
<SearchCommand />
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextPageContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
interface Props {
|
||||
statusCode: number;
|
||||
@@ -10,18 +11,20 @@ interface Props {
|
||||
|
||||
export default function Custom404({ statusCode, error }: Props) {
|
||||
const displayStatusCode = statusCode || 400;
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const logoUrl = whitelabeling?.logoUrl || undefined;
|
||||
const errorTitle = whitelabeling?.errorPageTitle;
|
||||
const errorDescription = whitelabeling?.errorPageDescription;
|
||||
|
||||
return (
|
||||
<div className="h-screen">
|
||||
<div className="max-w-[50rem] flex flex-col mx-auto size-full">
|
||||
<header className="mb-auto flex justify-center z-50 w-full py-4">
|
||||
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2"
|
||||
>
|
||||
<Logo />
|
||||
<span className="font-medium text-sm">Dokploy</span>
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo logoUrl={logoUrl} />
|
||||
<span className="font-medium text-sm">{appName}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -30,19 +33,18 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
<h1 className="block text-7xl font-bold text-primary sm:text-9xl">
|
||||
{displayStatusCode}
|
||||
</h1>
|
||||
{/* <AlertBlock className="max-w-xs mx-auto">
|
||||
<p className="text-muted-foreground">
|
||||
Oops, something went wrong.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Sorry, we couldn't find your page.
|
||||
</p>
|
||||
</AlertBlock> */}
|
||||
<p className="mt-3 text-muted-foreground">
|
||||
{statusCode === 404
|
||||
? "Sorry, we couldn't find your page."
|
||||
: "Oops, something went wrong."}
|
||||
{errorTitle
|
||||
? errorTitle
|
||||
: statusCode === 404
|
||||
? "Sorry, we couldn't find your page."
|
||||
: "Oops, something went wrong."}
|
||||
</p>
|
||||
{errorDescription && (
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{errorDescription}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-3 text-red-500">
|
||||
<p>{error.message}</p>
|
||||
@@ -80,13 +82,17 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
<footer className="mt-auto text-center py-5">
|
||||
<div className="max-w-[85rem] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-sm text-gray-500">
|
||||
<Link
|
||||
href="https://github.com/Dokploy/dokploy/issues"
|
||||
target="_blank"
|
||||
className="underline hover:text-primary transition-colors"
|
||||
>
|
||||
Submit Log in issue on Github
|
||||
</Link>
|
||||
{whitelabeling?.footerText ? (
|
||||
whitelabeling.footerText
|
||||
) : (
|
||||
<Link
|
||||
href="https://github.com/Dokploy/dokploy/issues"
|
||||
target="_blank"
|
||||
className="underline hover:text-primary transition-colors"
|
||||
>
|
||||
Submit Log in issue on Github
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -358,7 +358,8 @@ export default async function handler(
|
||||
const shouldCreateDeployment =
|
||||
action === "opened" ||
|
||||
action === "synchronize" ||
|
||||
action === "reopened";
|
||||
action === "reopened" ||
|
||||
action === "labeled";
|
||||
|
||||
const repository = githubBody?.repository?.name;
|
||||
const deploymentHash = githubBody?.pull_request?.head?.sha;
|
||||
|
||||
@@ -10,22 +10,29 @@ type Query = {
|
||||
state: string;
|
||||
installation_id: string;
|
||||
setup_action: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const { code, state, installation_id, userId }: Query = req.query as Query;
|
||||
const { code, state, installation_id }: Query = req.query as Query;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "Missing code parameter" });
|
||||
}
|
||||
const [action, value] = state?.split(":");
|
||||
// Value could be the organizationId or the githubProviderId
|
||||
const [action, ...rest] = state?.split(":");
|
||||
// For gh_init: rest[0] = organizationId, rest[1] = userId
|
||||
// For gh_setup: rest[0] = githubProviderId
|
||||
|
||||
if (action === "gh_init") {
|
||||
const organizationId = rest[0];
|
||||
const userId = rest[1] || (req.query.userId as string);
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "Missing userId parameter" });
|
||||
}
|
||||
|
||||
const octokit = new Octokit({});
|
||||
const { data } = await octokit.request(
|
||||
"POST /app-manifests/{code}/conversions",
|
||||
@@ -44,7 +51,7 @@ export default async function handler(
|
||||
githubWebhookSecret: data.webhook_secret,
|
||||
githubPrivateKey: data.pem,
|
||||
},
|
||||
value as string,
|
||||
organizationId as string,
|
||||
userId,
|
||||
);
|
||||
} else if (action === "gh_setup") {
|
||||
@@ -53,7 +60,7 @@ export default async function handler(
|
||||
.set({
|
||||
githubInstallationId: installation_id,
|
||||
})
|
||||
.where(eq(github.githubId, value as string))
|
||||
.where(eq(github.githubId, rest[0] as string))
|
||||
.returning();
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
export type Services = {
|
||||
serverId?: string | null;
|
||||
@@ -370,6 +371,8 @@ const EnvironmentPage = (
|
||||
{ projectId: selectedTargetProject },
|
||||
{ enabled: !!selectedTargetProject },
|
||||
);
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
|
||||
const emptyServices =
|
||||
!currentEnvironment ||
|
||||
@@ -777,7 +780,7 @@ const EnvironmentPage = (
|
||||
}
|
||||
if (success > 0) {
|
||||
toast.success(
|
||||
`${success} service${success !== 1 ? "s" : ""} deployed successfully`,
|
||||
`${success} service${success !== 1 ? "s" : ""} queued for deployment`,
|
||||
);
|
||||
}
|
||||
if (failed > 0) {
|
||||
@@ -871,7 +874,8 @@ const EnvironmentPage = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Environment: {currentEnvironment.name} | {projectData?.name} | Dokploy
|
||||
Environment: {currentEnvironment.name} | {projectData?.name} |{" "}
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState =
|
||||
| "projects"
|
||||
@@ -95,6 +96,8 @@ const Service = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.project?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -122,7 +125,8 @@ const Service = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Application: {data?.name} - {data?.environment.project.name} | Dokploy
|
||||
Application: {data?.name} - {data?.environment.project.name} |{" "}
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState =
|
||||
| "projects"
|
||||
@@ -84,6 +85,8 @@ const Service = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -111,7 +114,7 @@ const Service = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Compose: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Compose: {data?.name} - {data?.environment?.project?.name} | {appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||
|
||||
@@ -65,6 +66,8 @@ const Mariadb = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -94,7 +97,7 @@ const Mariadb = (
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |
|
||||
Dokploy
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
|
||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||
|
||||
@@ -64,6 +65,8 @@ const Mongo = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -91,7 +94,8 @@ const Mongo = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||
|
||||
@@ -63,6 +64,8 @@ const MySql = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -92,7 +95,7 @@ const MySql = (
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |
|
||||
Dokploy
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||
|
||||
@@ -63,6 +64,8 @@ const Postgresql = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -90,7 +93,8 @@ const Postgresql = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -44,6 +44,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState = "projects" | "monitoring" | "settings" | "advanced";
|
||||
|
||||
@@ -63,6 +64,8 @@ const Redis = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -90,7 +93,8 @@ const Redis = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
81
apps/dokploy/pages/dashboard/settings/whitelabeling.tsx
Normal file
81
apps/dokploy/pages/dashboard/settings/whitelabeling.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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 { WhitelabelingSettings } from "@/components/proprietary/whitelabeling/whitelabeling-settings";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
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 allows you to fully customize logos, colors, CSS, error pages, and more. Add a valid license to configure it.",
|
||||
ctaLabel: "Go to License",
|
||||
}}
|
||||
>
|
||||
<WhitelabelingSettings />
|
||||
</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, res } = ctx;
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (user.role !== "owner") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/settings/profile",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.user.get.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -58,6 +59,7 @@ interface Props {
|
||||
}
|
||||
export default function Home({ IS_CLOUD }: Props) {
|
||||
const router = useRouter();
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
||||
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
||||
@@ -216,7 +218,14 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
<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" />
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl ||
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
Sign in
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
@@ -82,6 +83,7 @@ const Invitation = ({
|
||||
userAlreadyExists,
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const { data } = api.user.getUserByToken.useQuery(
|
||||
{
|
||||
token,
|
||||
@@ -148,12 +150,15 @@ const Invitation = ({
|
||||
<div className="flex h-screen w-full items-center justify-center ">
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2"
|
||||
>
|
||||
<Logo className="size-12" />
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl ||
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
Invitation
|
||||
</CardTitle>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
@@ -77,6 +78,7 @@ interface Props {
|
||||
|
||||
const Register = ({ isCloud }: Props) => {
|
||||
const router = useRouter();
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<any>(null);
|
||||
@@ -123,12 +125,15 @@ const Register = ({ isCloud }: Props) => {
|
||||
<div className="flex w-full items-center justify-center ">
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2"
|
||||
>
|
||||
<Logo className="size-12" />
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl ||
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
{isCloud ? "Sign Up" : "Setup the server"}
|
||||
</CardTitle>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const loginSchema = z
|
||||
.object({
|
||||
@@ -53,6 +54,7 @@ interface Props {
|
||||
tokenResetPassword: string;
|
||||
}
|
||||
export default function Home({ tokenResetPassword }: Props) {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const [token, setToken] = useState<string | null>(tokenResetPassword);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -97,7 +99,14 @@ export default function Home({ tokenResetPassword }: Props) {
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<CardTitle className="text-2xl font-bold flex flex-row gap-2 items-center">
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo className="size-12" />
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl ||
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
Reset Password
|
||||
</CardTitle>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z
|
||||
@@ -42,6 +43,7 @@ type AuthResponse = {
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const [temp, _setTemp] = useState<AuthResponse>({
|
||||
is2FAEnabled: false,
|
||||
authId: "",
|
||||
@@ -81,8 +83,14 @@ export default function Home() {
|
||||
<div className="flex w-full items-center justify-center ">
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo />
|
||||
<span className="font-medium text-sm">Dokploy</span>
|
||||
<Logo
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined
|
||||
}
|
||||
/>
|
||||
<span className="font-medium text-sm">
|
||||
{whitelabeling?.appName || "Dokploy"}
|
||||
</span>
|
||||
</Link>
|
||||
<CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
|
||||
<CardDescription>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { organizationRouter } from "./routers/organization";
|
||||
import { patchRouter } from "./routers/patch";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||
import { portRouter } from "./routers/port";
|
||||
import { postgresRouter } from "./routers/postgres";
|
||||
import { previewDeploymentRouter } from "./routers/preview-deployment";
|
||||
@@ -87,6 +88,7 @@ export const appRouter = createTRPCRouter({
|
||||
organization: organizationRouter,
|
||||
licenseKey: licenseKeyRouter,
|
||||
sso: ssoRouter,
|
||||
whitelabeling: whitelabelingRouter,
|
||||
schedule: scheduleRouter,
|
||||
rollback: rollbackRouter,
|
||||
volumeBackups: volumeBackupsRouter,
|
||||
|
||||
@@ -69,8 +69,7 @@ export const mountRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(apiCreateMount)
|
||||
.mutation(async ({ input }) => {
|
||||
await createMount(input);
|
||||
return true;
|
||||
return await createMount(input);
|
||||
}),
|
||||
remove: protectedProcedure
|
||||
.input(apiRemoveMount)
|
||||
|
||||
@@ -355,4 +355,13 @@ export const organizationRouter = createTRPCRouter({
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
active: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (!ctx.session.activeOrganizationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await db.query.organization.findFirst({
|
||||
where: eq(organization.id, ctx.session.activeOrganizationId),
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
106
apps/dokploy/server/api/routers/proprietary/whitelabeling.ts
Normal file
106
apps/dokploy/server/api/routers/proprietary/whitelabeling.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
updateWebServerSettings,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { apiUpdateWhitelabeling } from "@/server/db/schema";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
enterpriseProcedure,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "../../trpc";
|
||||
|
||||
export const whitelabelingRouter = createTRPCRouter({
|
||||
get: protectedProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return null;
|
||||
}
|
||||
const settings = await getWebServerSettings();
|
||||
return settings?.whitelabelingConfig ?? null;
|
||||
}),
|
||||
|
||||
update: enterpriseProcedure
|
||||
.input(apiUpdateWhitelabeling)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Whitelabeling is not available in Cloud",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the owner can update whitelabeling settings",
|
||||
});
|
||||
}
|
||||
|
||||
await updateWebServerSettings({
|
||||
whitelabelingConfig: input.whitelabelingConfig,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
reset: enterpriseProcedure.mutation(async ({ ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Whitelabeling is not available in Cloud",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the owner can reset whitelabeling settings",
|
||||
});
|
||||
}
|
||||
|
||||
await updateWebServerSettings({
|
||||
whitelabelingConfig: {
|
||||
appName: null,
|
||||
appDescription: null,
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
customCss: null,
|
||||
loginLogoUrl: null,
|
||||
supportUrl: null,
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Public endpoint only for unauthenticated pages (login, register, error)
|
||||
// Returns only the fields needed for public pages
|
||||
getPublic: publicProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return null;
|
||||
}
|
||||
const settings = await getWebServerSettings();
|
||||
const config = settings?.whitelabelingConfig;
|
||||
if (!config) return null;
|
||||
|
||||
return {
|
||||
appName: config.appName,
|
||||
appDescription: config.appDescription,
|
||||
logoUrl: config.logoUrl,
|
||||
loginLogoUrl: config.loginLogoUrl,
|
||||
faviconUrl: config.faviconUrl,
|
||||
customCss: config.customCss,
|
||||
metaTitle: config.metaTitle,
|
||||
errorPageTitle: config.errorPageTitle,
|
||||
errorPageDescription: config.errorPageDescription,
|
||||
footerText: config.footerText,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -149,12 +149,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
// Check if port 8080 is already in use before enabling dashboard
|
||||
const portCheck = await checkPortInUse(8080, input.serverId);
|
||||
if (portCheck.isInUse) {
|
||||
const conflictingContainer = portCheck.conflictingContainer
|
||||
? ` by container "${portCheck.conflictingContainer}"`
|
||||
const conflictInfo = portCheck.conflictingContainer
|
||||
? ` by ${portCheck.conflictingContainer}`
|
||||
: "";
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
|
||||
message: `Port 8080 is already in use${conflictInfo}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
|
||||
});
|
||||
}
|
||||
newPorts.push({
|
||||
|
||||
@@ -101,6 +101,19 @@ export const userRouter = createTRPCRouter({
|
||||
|
||||
return memberResult;
|
||||
}),
|
||||
session: publicProcedure.query(async ({ ctx }) => {
|
||||
if (!ctx.user || !ctx.session || !ctx.session.activeOrganizationId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
user: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
session: {
|
||||
activeOrganizationId: ctx.session.activeOrganizationId,
|
||||
},
|
||||
};
|
||||
}),
|
||||
get: protectedProcedure.query(async ({ ctx }) => {
|
||||
const memberResult = await db.query.member.findFirst({
|
||||
where: and(
|
||||
|
||||
25
apps/dokploy/utils/hooks/use-whitelabeling.ts
Normal file
25
apps/dokploy/utils/hooks/use-whitelabeling.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
/**
|
||||
* Hook to access whitelabeling config for authenticated pages (dashboard, services, etc.).
|
||||
* Requires the user to be logged in.
|
||||
*/
|
||||
export function useWhitelabeling() {
|
||||
const { data, ...rest } = api.whitelabeling.get.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
return { config: data ?? null, ...rest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the public whitelabeling config.
|
||||
* Only for unauthenticated pages (login, register, error, invitation, password reset).
|
||||
*/
|
||||
export function useWhitelabelingPublic() {
|
||||
const { data, ...rest } = api.whitelabeling.getPublic.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
return { config: data ?? null, ...rest };
|
||||
}
|
||||
@@ -54,13 +54,13 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta
|
||||
WITH recent_metrics AS (
|
||||
SELECT metrics_json
|
||||
FROM container_metrics
|
||||
WHERE container_name = ?
|
||||
WHERE container_name = ? OR container_name LIKE ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
)
|
||||
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
||||
`
|
||||
rows, err := db.Query(query, containerName, limit)
|
||||
rows, err := db.Query(query, containerName, containerName+".%", limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,12 +90,12 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e
|
||||
WITH recent_metrics AS (
|
||||
SELECT metrics_json
|
||||
FROM container_metrics
|
||||
WHERE container_name = ?
|
||||
WHERE container_name = ? OR container_name LIKE ?
|
||||
ORDER BY timestamp DESC
|
||||
)
|
||||
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
||||
`
|
||||
rows, err := db.Query(query, containerName)
|
||||
rows, err := db.Query(query, containerName, containerName+".%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,8 +2,24 @@ import path from "node:path";
|
||||
import Docker from "dockerode";
|
||||
|
||||
export const IS_CLOUD = process.env.IS_CLOUD === "true";
|
||||
export const DOCKER_API_VERSION = process.env.DOCKER_API_VERSION;
|
||||
export const DOKPLOY_DOCKER_HOST = process.env.DOKPLOY_DOCKER_HOST;
|
||||
export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
|
||||
? Number(process.env.DOKPLOY_DOCKER_PORT)
|
||||
: undefined;
|
||||
|
||||
export const CLEANUP_CRON_JOB = "50 23 * * *";
|
||||
export const docker = new Docker();
|
||||
export const docker = new Docker({
|
||||
...(DOCKER_API_VERSION && {
|
||||
version: DOCKER_API_VERSION,
|
||||
}),
|
||||
...(DOKPLOY_DOCKER_HOST && {
|
||||
host: DOKPLOY_DOCKER_HOST,
|
||||
}),
|
||||
...(DOKPLOY_DOCKER_PORT && {
|
||||
port: DOKPLOY_DOCKER_PORT,
|
||||
}),
|
||||
});
|
||||
|
||||
// When not set, use the legacy default so 2FA remains working for users who
|
||||
// enabled it before BETTER_AUTH_SECRET was introduced .
|
||||
|
||||
@@ -365,12 +365,13 @@ const createSchema = createInsertSchema(applications, {
|
||||
previewPath: z.string().optional(),
|
||||
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||
previewRequireCollaboratorPermissions: z.boolean().optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
watchPaths: z.array(z.string()).optional().optional(),
|
||||
previewLabels: z.array(z.string()).optional(),
|
||||
cleanCache: z.boolean().optional(),
|
||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
enableSubmodules: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateApplication = createSchema.pick({
|
||||
@@ -433,13 +434,13 @@ export const apiSaveGithubProvider = createSchema
|
||||
owner: true,
|
||||
buildPath: true,
|
||||
githubId: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required()
|
||||
.extend({
|
||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||
});
|
||||
})
|
||||
.required()
|
||||
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||
|
||||
export const apiSaveGitlabProvider = createSchema
|
||||
.pick({
|
||||
@@ -451,10 +452,9 @@ export const apiSaveGitlabProvider = createSchema
|
||||
gitlabId: true,
|
||||
gitlabProjectId: true,
|
||||
gitlabPathNamespace: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||
|
||||
export const apiSaveBitbucketProvider = createSchema
|
||||
.pick({
|
||||
@@ -465,10 +465,9 @@ export const apiSaveBitbucketProvider = createSchema
|
||||
bitbucketRepositorySlug: true,
|
||||
bitbucketId: true,
|
||||
applicationId: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||
|
||||
export const apiSaveGiteaProvider = createSchema
|
||||
.pick({
|
||||
@@ -478,10 +477,9 @@ export const apiSaveGiteaProvider = createSchema
|
||||
giteaOwner: true,
|
||||
giteaRepository: true,
|
||||
giteaId: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||
|
||||
export const apiSaveDockerProvider = createSchema
|
||||
.pick({
|
||||
@@ -506,6 +504,7 @@ export const apiSaveGitProvider = createSchema
|
||||
.merge(
|
||||
createSchema.pick({
|
||||
customGitSSHKeyId: true,
|
||||
enableSubmodules: true,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -99,17 +99,15 @@ const createSchema = createInsertSchema(mounts, {
|
||||
mountPath: z.string().min(1),
|
||||
mountId: z.string().optional(),
|
||||
filePath: z.string().optional(),
|
||||
serviceType: z
|
||||
.enum([
|
||||
"application",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
"compose",
|
||||
])
|
||||
.default("application"),
|
||||
serviceType: z.enum([
|
||||
"application",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
"compose",
|
||||
]),
|
||||
});
|
||||
|
||||
export type ServiceType = NonNullable<
|
||||
|
||||
@@ -66,6 +66,36 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Whitelabeling Configuration (Enterprise / Proprietary)
|
||||
whitelabelingConfig: jsonb("whitelabelingConfig")
|
||||
.$type<{
|
||||
appName: string | null;
|
||||
appDescription: string | null;
|
||||
logoUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
customCss: string | null;
|
||||
loginLogoUrl: string | null;
|
||||
supportUrl: string | null;
|
||||
docsUrl: string | null;
|
||||
errorPageTitle: string | null;
|
||||
errorPageDescription: string | null;
|
||||
metaTitle: string | null;
|
||||
footerText: string | null;
|
||||
}>()
|
||||
.default({
|
||||
appName: null,
|
||||
appDescription: null,
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
customCss: null,
|
||||
loginLogoUrl: null,
|
||||
supportUrl: null,
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
}),
|
||||
// Cache Cleanup Configuration
|
||||
cleanupCacheApplications: boolean("cleanupCacheApplications")
|
||||
.notNull()
|
||||
@@ -154,6 +184,33 @@ export const apiUpdateDockerCleanup = z.object({
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
// Whitelabeling validation schemas
|
||||
const safeUrl = z
|
||||
.string()
|
||||
.refine((url) => /^https?:\/\//i.test(url), {
|
||||
message: "Only http:// and https:// URLs are allowed",
|
||||
})
|
||||
.nullable();
|
||||
|
||||
export const whitelabelingConfigSchema = z.object({
|
||||
appName: z.string().nullable(),
|
||||
appDescription: z.string().nullable(),
|
||||
logoUrl: safeUrl,
|
||||
faviconUrl: safeUrl,
|
||||
customCss: z.string().nullable(),
|
||||
loginLogoUrl: safeUrl,
|
||||
supportUrl: safeUrl,
|
||||
docsUrl: safeUrl,
|
||||
errorPageTitle: z.string().nullable(),
|
||||
errorPageDescription: z.string().nullable(),
|
||||
metaTitle: z.string().nullable(),
|
||||
footerText: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const apiUpdateWhitelabeling = z.object({
|
||||
whitelabelingConfig: whitelabelingConfigSchema,
|
||||
});
|
||||
|
||||
export const apiUpdateWebServerMonitoring = z.object({
|
||||
metricsConfig: z
|
||||
.object({
|
||||
|
||||
@@ -135,15 +135,25 @@ export const getTrustedOrigins = async () => {
|
||||
if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) {
|
||||
return trustedOriginsCache.data;
|
||||
}
|
||||
const trustedOrigins = await runQuery();
|
||||
trustedOriginsCache = {
|
||||
data: trustedOrigins,
|
||||
expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
|
||||
};
|
||||
return trustedOrigins;
|
||||
try {
|
||||
const trustedOrigins = await runQuery();
|
||||
trustedOriginsCache = {
|
||||
data: trustedOrigins,
|
||||
expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
|
||||
};
|
||||
return trustedOrigins;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch trusted origins:", error);
|
||||
return trustedOriginsCache?.data ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
return runQuery();
|
||||
try {
|
||||
return await runQuery();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch trusted origins:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getTrustedProviders = async () => {
|
||||
|
||||
@@ -117,12 +117,12 @@ export const createDeployment = async (
|
||||
>,
|
||||
) => {
|
||||
const application = await findApplicationById(deployment.applicationId);
|
||||
await removeLastTenDeployments(
|
||||
deployment.applicationId,
|
||||
"application",
|
||||
application.serverId,
|
||||
);
|
||||
try {
|
||||
await removeLastTenDeployments(
|
||||
deployment.applicationId,
|
||||
"application",
|
||||
application.serverId,
|
||||
);
|
||||
const serverId = application.buildServerId || application.serverId;
|
||||
|
||||
const { LOGS_PATH } = paths(!!serverId);
|
||||
@@ -200,13 +200,12 @@ export const createDeploymentPreview = async (
|
||||
const previewDeployment = await findPreviewDeploymentById(
|
||||
deployment.previewDeploymentId,
|
||||
);
|
||||
await removeLastTenDeployments(
|
||||
deployment.previewDeploymentId,
|
||||
"previewDeployment",
|
||||
previewDeployment?.application?.serverId,
|
||||
);
|
||||
try {
|
||||
await removeLastTenDeployments(
|
||||
deployment.previewDeploymentId,
|
||||
"previewDeployment",
|
||||
previewDeployment?.application?.serverId,
|
||||
);
|
||||
|
||||
const appName = `${previewDeployment.appName}`;
|
||||
const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
@@ -281,12 +280,12 @@ export const createDeploymentCompose = async (
|
||||
>,
|
||||
) => {
|
||||
const compose = await findComposeById(deployment.composeId);
|
||||
await removeLastTenDeployments(
|
||||
deployment.composeId,
|
||||
"compose",
|
||||
compose.serverId,
|
||||
);
|
||||
try {
|
||||
await removeLastTenDeployments(
|
||||
deployment.composeId,
|
||||
"compose",
|
||||
compose.serverId,
|
||||
);
|
||||
const { LOGS_PATH } = paths(!!compose.serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${compose.appName}-${formattedDateTime}.log`;
|
||||
@@ -369,8 +368,8 @@ export const createDeploymentBackup = async (
|
||||
} else if (backup.backupType === "compose") {
|
||||
serverId = backup.compose?.serverId;
|
||||
}
|
||||
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
|
||||
try {
|
||||
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
|
||||
const { LOGS_PATH } = paths(!!serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${backup.appName}-${formattedDateTime}.log`;
|
||||
@@ -439,12 +438,12 @@ export const createDeploymentSchedule = async (
|
||||
) => {
|
||||
const schedule = await findScheduleById(deployment.scheduleId);
|
||||
|
||||
const serverId =
|
||||
schedule.application?.serverId ||
|
||||
schedule.compose?.serverId ||
|
||||
schedule.server?.serverId;
|
||||
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
|
||||
try {
|
||||
const serverId =
|
||||
schedule.application?.serverId ||
|
||||
schedule.compose?.serverId ||
|
||||
schedule.server?.serverId;
|
||||
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
|
||||
const { SCHEDULES_PATH } = paths(!!serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${schedule.appName}-${formattedDateTime}.log`;
|
||||
@@ -515,14 +514,14 @@ export const createDeploymentVolumeBackup = async (
|
||||
) => {
|
||||
const volumeBackup = await findVolumeBackupById(deployment.volumeBackupId);
|
||||
|
||||
const serverId =
|
||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||
await removeLastTenDeployments(
|
||||
deployment.volumeBackupId,
|
||||
"volumeBackup",
|
||||
serverId,
|
||||
);
|
||||
try {
|
||||
const serverId =
|
||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||
await removeLastTenDeployments(
|
||||
deployment.volumeBackupId,
|
||||
"volumeBackup",
|
||||
serverId,
|
||||
);
|
||||
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`;
|
||||
@@ -601,24 +600,23 @@ export const removeDeployment = async (deploymentId: string) => {
|
||||
.then((result) => result[0]);
|
||||
|
||||
if (!deployment) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Deployment not found",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const command = `
|
||||
rm -f ${deployment.logPath};
|
||||
`;
|
||||
if (deployment.serverId) {
|
||||
await execAsyncRemote(deployment.serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
|
||||
const logPath = path.join(deployment.logPath);
|
||||
if (logPath && logPath !== ".") {
|
||||
const command = `rm -f ${logPath};`;
|
||||
if (deployment.serverId) {
|
||||
await execAsyncRemote(deployment.serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
}
|
||||
|
||||
return deployment;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Error creating the deployment";
|
||||
error instanceof Error ? error.message : "Error removing the deployment";
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message,
|
||||
@@ -686,34 +684,49 @@ const removeLastTenDeployments = async (
|
||||
if (serverId) {
|
||||
let command = "";
|
||||
for (const oldDeployment of deploymentsToDelete) {
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
}
|
||||
try {
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
}
|
||||
|
||||
if (logPath !== ".") {
|
||||
command += `
|
||||
rm -rf ${logPath};
|
||||
`;
|
||||
if (logPath && logPath !== ".") {
|
||||
command += `rm -rf ${logPath};`;
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
}
|
||||
|
||||
await execAsyncRemote(serverId, command);
|
||||
if (command) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
}
|
||||
} else {
|
||||
for (const oldDeployment of deploymentsToDelete) {
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
try {
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
}
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (
|
||||
logPath &&
|
||||
logPath !== "." &&
|
||||
existsSync(logPath) &&
|
||||
!oldDeployment.errorMessage
|
||||
) {
|
||||
await fsPromises.unlink(logPath);
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (
|
||||
existsSync(logPath) &&
|
||||
!oldDeployment.errorMessage &&
|
||||
logPath !== "."
|
||||
) {
|
||||
await fsPromises.unlink(logPath);
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ function shEscape(s: string | undefined): string {
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function safeDockerLoginCommand(
|
||||
export function safeDockerLoginCommand(
|
||||
registry: string | undefined,
|
||||
user: string | undefined,
|
||||
pass: string | undefined,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { findDeploymentById } from "./deployment";
|
||||
import type { Mount } from "./mount";
|
||||
import type { Port } from "./port";
|
||||
import type { Project } from "./project";
|
||||
import type { Registry } from "./registry";
|
||||
import { type Registry, safeDockerLoginCommand } from "./registry";
|
||||
|
||||
export const createRollback = async (
|
||||
input: z.infer<typeof createRollbackSchema>,
|
||||
@@ -111,7 +111,7 @@ const deleteRollbackImage = async (image: string, serverId?: string | null) => {
|
||||
const command = `docker image rm ${image} --force`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(command, serverId);
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
@@ -171,6 +171,23 @@ export const rollback = async (rollbackId: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
const dockerLoginForRegistry = async (
|
||||
registry: Registry,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
const loginCommand = safeDockerLoginCommand(
|
||||
registry.registryUrl,
|
||||
registry.username,
|
||||
registry.password,
|
||||
);
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, loginCommand);
|
||||
} else {
|
||||
await execAsync(loginCommand);
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackApplication = async (
|
||||
appName: string,
|
||||
image: string,
|
||||
@@ -188,6 +205,14 @@ const rollbackApplication = async (
|
||||
throw new Error("Full context is required for rollback");
|
||||
}
|
||||
|
||||
// Ensure Docker daemon is authenticated with the rollback registry
|
||||
// before updating the swarm service. The authconfig in CreateServiceOptions
|
||||
// alone is not sufficient — Docker Swarm also relies on the daemon's
|
||||
// cached credentials (~/.docker/config.json) to distribute auth to nodes.
|
||||
if (fullContext.rollbackRegistry) {
|
||||
await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId);
|
||||
}
|
||||
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
|
||||
// Use the same configuration as mechanizeDockerContainer
|
||||
|
||||
@@ -413,17 +413,38 @@ export const checkPortInUse = async (
|
||||
serverId?: string,
|
||||
): Promise<{ isInUse: boolean; conflictingContainer?: string }> => {
|
||||
try {
|
||||
const command = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
// Check if port is in use by a Docker container
|
||||
const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
|
||||
const { stdout: dockerOut } = serverId
|
||||
? await execAsyncRemote(serverId, dockerCommand)
|
||||
: await execAsync(dockerCommand);
|
||||
|
||||
const container = stdout.trim();
|
||||
const container = dockerOut.trim();
|
||||
|
||||
return {
|
||||
isInUse: !!container,
|
||||
conflictingContainer: container || undefined,
|
||||
};
|
||||
if (container) {
|
||||
return {
|
||||
isInUse: true,
|
||||
conflictingContainer: `container "${container}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if port is in use by a host-level service (non-Docker)
|
||||
// Dokploy runs inside a container, so we spawn an ephemeral container
|
||||
// with --net=host to share the host's network stack and use nc -z to
|
||||
// check if something is listening on the port
|
||||
const hostCommand = `docker run --rm --net=host busybox sh -c 'nc -z 0.0.0.0 ${port} 2>/dev/null && echo in_use || echo free'`;
|
||||
const { stdout: hostOut } = serverId
|
||||
? await execAsyncRemote(serverId, hostCommand)
|
||||
: await execAsync(hostCommand);
|
||||
|
||||
if (hostOut.includes("in_use")) {
|
||||
return {
|
||||
isInUse: true,
|
||||
conflictingContainer: "a host-level service",
|
||||
};
|
||||
}
|
||||
|
||||
return { isInUse: false };
|
||||
} catch (error) {
|
||||
console.error("Error checking port availability:", error);
|
||||
return { isInUse: false };
|
||||
|
||||
@@ -30,6 +30,18 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
||||
baseURL: config.apiUrl,
|
||||
});
|
||||
case "azure":
|
||||
// Azure OpenAI-compatible endpoints already include /v1 in the path.
|
||||
// Using createAzure with such URLs causes a doubled /v1//v1/ suffix.
|
||||
if (config.apiUrl.includes("/v1")) {
|
||||
return createOpenAICompatible({
|
||||
name: "azure",
|
||||
baseURL: config.apiUrl,
|
||||
headers: {
|
||||
"api-key": config.apiKey,
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return createAzure({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.apiUrl,
|
||||
|
||||
@@ -14,13 +14,14 @@ export const runComposeBackup = async (
|
||||
compose: Compose,
|
||||
backup: BackupSchedule,
|
||||
) => {
|
||||
const { environmentId, name } = compose;
|
||||
const { environmentId, name, appName } = compose;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix, databaseType } = backup;
|
||||
const { prefix, databaseType, serviceName } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
|
||||
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "Compose Backup",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "node:path";
|
||||
import { CLEANUP_CRON_JOB } from "@dokploy/server/constants";
|
||||
import { member } from "@dokploy/server/db/schema";
|
||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||
@@ -11,7 +10,7 @@ import { startLogCleanup } from "../access-log/handler";
|
||||
import { cleanupAll } from "../docker/utils";
|
||||
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getS3Credentials, scheduleBackup } from "./utils";
|
||||
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
|
||||
|
||||
export const initCronJobs = async () => {
|
||||
console.log("Setting up cron jobs....");
|
||||
@@ -107,6 +106,20 @@ export const initCronJobs = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getServiceAppName = (backup: BackupSchedule): string => {
|
||||
if (backup.compose?.appName) {
|
||||
return backup.serviceName
|
||||
? `${backup.compose.appName}_${backup.serviceName}`
|
||||
: backup.compose.appName;
|
||||
}
|
||||
const serviceAppName =
|
||||
backup.postgres?.appName ||
|
||||
backup.mysql?.appName ||
|
||||
backup.mariadb?.appName ||
|
||||
backup.mongo?.appName;
|
||||
return serviceAppName || backup.appName;
|
||||
};
|
||||
|
||||
export const keepLatestNBackups = async (
|
||||
backup: BackupSchedule,
|
||||
serverId?: string | null,
|
||||
@@ -117,18 +130,16 @@ export const keepLatestNBackups = async (
|
||||
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(backup.destination);
|
||||
const backupFilesPath = path.join(
|
||||
`:s3:${backup.destination.bucket}`,
|
||||
backup.prefix,
|
||||
);
|
||||
const appName = getServiceAppName(backup);
|
||||
const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
|
||||
|
||||
// --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
|
||||
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`;
|
||||
// when we pipe the above command with this one, we only get the list of files we want to delete
|
||||
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
|
||||
// this command deletes the files
|
||||
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}/{}
|
||||
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}/{}`;
|
||||
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}{}
|
||||
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
|
||||
|
||||
const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`;
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ export const runMariadbBackup = async (
|
||||
mariadb: Mariadb,
|
||||
backup: BackupSchedule,
|
||||
) => {
|
||||
const { environmentId, name } = mariadb;
|
||||
const { environmentId, name, appName } = mariadb;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "MariaDB Backup",
|
||||
|
||||
@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
||||
|
||||
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
||||
const { environmentId, name } = mongo;
|
||||
const { environmentId, name, appName } = mongo;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "MongoDB Backup",
|
||||
|
||||
@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
||||
|
||||
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||
const { environmentId, name } = mysql;
|
||||
const { environmentId, name, appName } = mysql;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "MySQL Backup",
|
||||
|
||||
@@ -14,7 +14,7 @@ export const runPostgresBackup = async (
|
||||
postgres: Postgres,
|
||||
backup: BackupSchedule,
|
||||
) => {
|
||||
const { name, environmentId } = postgres;
|
||||
const { name, environmentId, appName } = postgres;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
|
||||
@@ -26,7 +26,7 @@ export const runPostgresBackup = async (
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
@@ -31,7 +31,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
const { BASE_PATH } = paths();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
|
||||
const backupFileName = `webserver-backup-${timestamp}.zip`;
|
||||
const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
|
||||
const s3Path = `:s3:${destination.bucket}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
|
||||
|
||||
try {
|
||||
await execAsync(`mkdir -p ${tempDir}/filesystem`);
|
||||
@@ -67,7 +67,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
await execAsync(cleanupCommand);
|
||||
|
||||
await execAsync(
|
||||
`rsync -a --ignore-errors ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
||||
`rsync -a --ignore-errors --no-specials --no-devices ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
||||
);
|
||||
|
||||
writeStream.write("Copied filesystem to temp directory\n");
|
||||
|
||||
@@ -53,7 +53,7 @@ Compose Type: ${composeType} ✅`;
|
||||
|
||||
cd "${projectPath}";
|
||||
|
||||
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}` : ""}
|
||||
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""}
|
||||
env -i PATH="$PATH" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; }
|
||||
${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ export const randomizeComposeFile = async (
|
||||
) => {
|
||||
const compose = await findComposeById(composeId);
|
||||
const composeFile = compose.composeFile;
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = parse(composeFile, {
|
||||
maxAliasCount: 10000,
|
||||
}) as ComposeSpecification;
|
||||
|
||||
const randomSuffix = suffix || generateRandomHash();
|
||||
|
||||
|
||||
@@ -63,7 +63,9 @@ export const loadDockerCompose = async (
|
||||
|
||||
if (existsSync(path)) {
|
||||
const yamlStr = readFileSync(path, "utf8");
|
||||
const parsedConfig = parse(yamlStr) as ComposeSpecification;
|
||||
const parsedConfig = parse(yamlStr, {
|
||||
maxAliasCount: 10000,
|
||||
}) as ComposeSpecification;
|
||||
return parsedConfig;
|
||||
}
|
||||
return null;
|
||||
@@ -86,7 +88,9 @@ export const loadDockerComposeRemote = async (
|
||||
return null;
|
||||
}
|
||||
if (!stdout) return null;
|
||||
const parsedConfig = parse(stdout) as ComposeSpecification;
|
||||
const parsedConfig = parse(stdout, {
|
||||
maxAliasCount: 10000,
|
||||
}) as ComposeSpecification;
|
||||
return parsedConfig;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -211,7 +211,10 @@ export const testGiteaConnection = async (input: { giteaId: string }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = provider.giteaUrl.replace(/\/+$/, "");
|
||||
const baseUrl = (provider.giteaInternalUrl || provider.giteaUrl).replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
|
||||
// Use /user/repos to get authenticated user's repositories with pagination
|
||||
let allRepos = 0;
|
||||
@@ -268,7 +271,9 @@ export const getGiteaRepositories = async (giteaId?: string) => {
|
||||
await refreshGiteaToken(giteaId);
|
||||
const giteaProvider = await findGiteaById(giteaId);
|
||||
|
||||
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, "");
|
||||
const baseUrl = (
|
||||
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
// Use /user/repos to get authenticated user's repositories with pagination
|
||||
let allRepositories: any[] = [];
|
||||
@@ -333,7 +338,9 @@ export const getGiteaBranches = async (input: {
|
||||
|
||||
const giteaProvider = await findGiteaById(input.giteaId);
|
||||
|
||||
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, "");
|
||||
const baseUrl = (
|
||||
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
// Handle pagination for branches
|
||||
let allBranches: any[] = [];
|
||||
|
||||
@@ -214,10 +214,13 @@ export const getGitlabBranches = async (input: {
|
||||
const allBranches = [];
|
||||
let page = 1;
|
||||
const perPage = 100; // GitLab's max per page is 100
|
||||
const baseUrl = (
|
||||
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
while (true) {
|
||||
const branchesResponse = await fetch(
|
||||
`${gitlabProvider.gitlabUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
|
||||
`${baseUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${gitlabProvider.accessToken}`,
|
||||
@@ -292,10 +295,13 @@ export const validateGitlabProvider = async (gitlabProvider: Gitlab) => {
|
||||
const allProjects = [];
|
||||
let page = 1;
|
||||
const perPage = 100; // GitLab's max per page is 100
|
||||
const baseUrl = (
|
||||
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(
|
||||
`${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
|
||||
`${baseUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${gitlabProvider.accessToken}`,
|
||||
|
||||
@@ -69,6 +69,7 @@ export const restoreComposeBackup = async (
|
||||
},
|
||||
restoreType: composeType,
|
||||
rcloneCommand,
|
||||
backupFile: backupInput.backupFile,
|
||||
});
|
||||
|
||||
emit("Starting restore...");
|
||||
|
||||
@@ -30,7 +30,7 @@ export const getMongoRestoreCommand = (
|
||||
databaseUser: string,
|
||||
databasePassword: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive"`;
|
||||
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive --drop"`;
|
||||
};
|
||||
|
||||
export const getComposeSearchCommand = (
|
||||
|
||||
@@ -152,16 +152,13 @@ export const createRouterConfig = async (
|
||||
}
|
||||
|
||||
if ((entryPoint === "websecure" && https) || !https) {
|
||||
// redirects
|
||||
for (const redirect of redirects) {
|
||||
let middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
|
||||
if (domain.domainType === "preview") {
|
||||
middlewareName = `redirect-${appName.replace(
|
||||
/^preview-(.+)-[^-]+$/,
|
||||
"$1",
|
||||
)}-${redirect.uniqueConfigKey}`;
|
||||
// redirects - skip for preview deployments as wildcard subdomains
|
||||
// should not inherit parent redirect rules (e.g., www-redirect)
|
||||
if (domain.domainType !== "preview") {
|
||||
for (const redirect of redirects) {
|
||||
const middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
|
||||
routerConfig.middlewares?.push(middlewareName);
|
||||
}
|
||||
routerConfig.middlewares?.push(middlewareName);
|
||||
}
|
||||
|
||||
// security
|
||||
|
||||
@@ -4,6 +4,24 @@ import { findComposeById } from "@dokploy/server/services/compose";
|
||||
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
|
||||
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
|
||||
|
||||
export const getVolumeServiceAppName = (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
): string => {
|
||||
if (volumeBackup.compose?.appName) {
|
||||
return volumeBackup.serviceName
|
||||
? `${volumeBackup.compose.appName}_${volumeBackup.serviceName}`
|
||||
: volumeBackup.compose.appName;
|
||||
}
|
||||
const serviceAppName =
|
||||
volumeBackup.application?.appName ||
|
||||
volumeBackup.postgres?.appName ||
|
||||
volumeBackup.mysql?.appName ||
|
||||
volumeBackup.mariadb?.appName ||
|
||||
volumeBackup.mongo?.appName ||
|
||||
volumeBackup.redis?.appName;
|
||||
return serviceAppName || volumeBackup.appName;
|
||||
};
|
||||
|
||||
export const backupVolume = async (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
) => {
|
||||
@@ -12,8 +30,9 @@ export const backupVolume = async (
|
||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||
const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId);
|
||||
const destination = volumeBackup.destination;
|
||||
const s3AppName = getVolumeServiceAppName(volumeBackup);
|
||||
const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`;
|
||||
const rcloneFlags = getS3Credentials(volumeBackup.destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
|
||||
import { sendVolumeBackupNotifications } from "../notifications/volume-backup";
|
||||
import { backupVolume } from "./backup";
|
||||
import { backupVolume, getVolumeServiceAppName } from "./backup";
|
||||
|
||||
// Helper functions to extract project info from volume backup
|
||||
const getProjectName = (
|
||||
@@ -81,9 +81,9 @@ const cleanupOldVolumeBackups = async (
|
||||
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const normalizedPrefix = normalizeS3Path(prefix);
|
||||
const backupFilesPath = `:s3:${destination.bucket}/${normalizedPrefix}`;
|
||||
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" :s3:${destination.bucket}/${normalizedPrefix}`;
|
||||
const s3AppName = getVolumeServiceAppName(volumeBackup);
|
||||
const backupFilesPath = `:s3:${destination.bucket}/${s3AppName}/${normalizeS3Path(prefix || "")}`;
|
||||
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" ${backupFilesPath}`;
|
||||
const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`;
|
||||
const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
|
||||
const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`;
|
||||
@@ -131,14 +131,21 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
? "mongodb"
|
||||
: volumeBackup.serviceType;
|
||||
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "success",
|
||||
organizationId,
|
||||
});
|
||||
try {
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "success",
|
||||
organizationId,
|
||||
});
|
||||
} catch (notificationError) {
|
||||
console.error(
|
||||
"Failed to send volume backup success notification",
|
||||
notificationError,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||
const volumeBackupPath = path.join(
|
||||
@@ -160,14 +167,21 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
? "mongodb"
|
||||
: volumeBackup.serviceType;
|
||||
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "error",
|
||||
organizationId,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
try {
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "error",
|
||||
organizationId,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} catch (notificationError) {
|
||||
console.error(
|
||||
"Failed to send volume backup error notification",
|
||||
notificationError,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
} from "../utils/notifications/utils";
|
||||
import { sendEmailNotification } from "../utils/notifications/utils";
|
||||
export const sendEmail = async ({
|
||||
email,
|
||||
subject,
|
||||
@@ -26,26 +23,3 @@ export const sendEmail = async ({
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const sendDiscordNotificationWelcome = async (email: string) => {
|
||||
await sendDiscordNotification(
|
||||
{
|
||||
webhookUrl: process.env.DISCORD_WEBHOOK_URL || "",
|
||||
},
|
||||
{
|
||||
title: "New User Registered",
|
||||
color: 0x00ff00,
|
||||
fields: [
|
||||
{
|
||||
name: "Email",
|
||||
value: email,
|
||||
inline: true,
|
||||
},
|
||||
],
|
||||
timestamp: new Date(),
|
||||
footer: {
|
||||
text: "Dokploy User Registration Notification",
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
@@ -119,6 +119,9 @@ importers:
|
||||
'@codemirror/autocomplete':
|
||||
specifier: ^6.18.6
|
||||
version: 6.20.0
|
||||
'@codemirror/lang-css':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1
|
||||
'@codemirror/lang-json':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.2
|
||||
@@ -131,9 +134,12 @@ importers:
|
||||
'@codemirror/legacy-modes':
|
||||
specifier: 6.4.0
|
||||
version: 6.4.0
|
||||
'@codemirror/search':
|
||||
specifier: ^6.6.0
|
||||
version: 6.6.0
|
||||
'@codemirror/view':
|
||||
specifier: 6.29.0
|
||||
version: 6.29.0
|
||||
specifier: ^6.39.15
|
||||
version: 6.39.15
|
||||
'@dokploy/server':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/server
|
||||
@@ -241,10 +247,10 @@ importers:
|
||||
version: 11.10.0(typescript@5.9.3)
|
||||
'@uiw/codemirror-theme-github':
|
||||
specifier: ^4.23.12
|
||||
version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0)
|
||||
version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
|
||||
'@uiw/react-codemirror':
|
||||
specifier: ^4.23.12
|
||||
version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.29.0)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.15)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@xterm/addon-attach':
|
||||
specifier: 0.10.0
|
||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||
@@ -1261,6 +1267,9 @@ packages:
|
||||
'@codemirror/commands@6.10.2':
|
||||
resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
||||
|
||||
'@codemirror/lang-json@6.0.2':
|
||||
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
||||
|
||||
@@ -1285,9 +1294,6 @@ packages:
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
|
||||
|
||||
'@codemirror/view@6.29.0':
|
||||
resolution: {integrity: sha512-ED4ims4fkf7eOA+HYLVP8VVg3NMllt1FPm9PEJBfYFnidKlRITBaua38u68L1F60eNtw2YNcDN5jsIzhKZwWQA==}
|
||||
|
||||
'@codemirror/view@6.39.15':
|
||||
resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
|
||||
|
||||
@@ -1816,6 +1822,9 @@ packages:
|
||||
'@lezer/common@1.5.1':
|
||||
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
|
||||
|
||||
'@lezer/css@1.3.1':
|
||||
resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==}
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
|
||||
|
||||
@@ -8793,16 +8802,24 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.29.0
|
||||
'@codemirror/view': 6.39.15
|
||||
'@lezer/common': 1.5.1
|
||||
|
||||
'@codemirror/commands@6.10.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.29.0
|
||||
'@codemirror/view': 6.39.15
|
||||
'@lezer/common': 1.5.1
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@lezer/common': 1.5.1
|
||||
'@lezer/css': 1.3.1
|
||||
|
||||
'@codemirror/lang-json@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
@@ -8821,7 +8838,7 @@ snapshots:
|
||||
'@codemirror/language@6.12.1':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.29.0
|
||||
'@codemirror/view': 6.39.15
|
||||
'@lezer/common': 1.5.1
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
@@ -8851,15 +8868,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.29.0
|
||||
'@codemirror/view': 6.39.15
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@codemirror/view@6.29.0':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.4
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@codemirror/view@6.39.15':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.4
|
||||
@@ -9300,6 +9311,12 @@ snapshots:
|
||||
|
||||
'@lezer/common@1.5.1': {}
|
||||
|
||||
'@lezer/css@1.3.1':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.1
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.1
|
||||
@@ -12094,7 +12111,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.10.13
|
||||
|
||||
'@uiw/codemirror-extensions-basic-setup@4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0)':
|
||||
'@uiw/codemirror-extensions-basic-setup@4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/commands': 6.10.2
|
||||
@@ -12102,30 +12119,30 @@ snapshots:
|
||||
'@codemirror/lint': 6.9.4
|
||||
'@codemirror/search': 6.6.0
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.29.0
|
||||
'@codemirror/view': 6.39.15
|
||||
|
||||
'@uiw/codemirror-theme-github@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0)':
|
||||
'@uiw/codemirror-theme-github@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)':
|
||||
dependencies:
|
||||
'@uiw/codemirror-themes': 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0)
|
||||
'@uiw/codemirror-themes': 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
|
||||
transitivePeerDependencies:
|
||||
- '@codemirror/language'
|
||||
- '@codemirror/state'
|
||||
- '@codemirror/view'
|
||||
|
||||
'@uiw/codemirror-themes@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0)':
|
||||
'@uiw/codemirror-themes@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.29.0
|
||||
'@codemirror/view': 6.39.15
|
||||
|
||||
'@uiw/react-codemirror@4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.29.0)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
'@uiw/react-codemirror@4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.15)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@codemirror/commands': 6.10.2
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/theme-one-dark': 6.1.3
|
||||
'@codemirror/view': 6.29.0
|
||||
'@uiw/codemirror-extensions-basic-setup': 4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0)
|
||||
'@codemirror/view': 6.39.15
|
||||
'@uiw/codemirror-extensions-basic-setup': 4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
|
||||
codemirror: 6.0.2
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
@@ -12772,7 +12789,7 @@ snapshots:
|
||||
'@codemirror/lint': 6.9.4
|
||||
'@codemirror/search': 6.6.0
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.29.0
|
||||
'@codemirror/view': 6.39.15
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user