Compare commits

..

75 Commits

Author SHA1 Message Date
autofix-ci[bot]
d5163322fb [autofix.ci] apply automated fixes 2026-02-17 03:17:55 +00:00
Mauricio Siu
714849883e feat(notifications): add Microsoft Teams integration for notifications
- Introduced support for Microsoft Teams notifications, including the ability to create, update, and test connections for Teams notifications.
- Updated the notification schema to include Teams as a notification type.
- Added Teams icon and UI components for handling Teams notifications in the dashboard.
- Implemented backend logic for creating and updating Teams notifications, along with necessary database schema changes.
- Enhanced existing notification functionalities to support Teams notifications across various events (e.g., build success, failure, database backups).
2026-02-16 21:16:00 -06:00
Mauricio Siu
407ce3f425 Merge pull request #3425 from mcfdez/chore/add-devcontainer
chore: add DevContainer
2026-02-16 20:51:38 -06:00
Mauricio Siu
49a189fcbf Merge pull request #3728 from Dokploy/3722-deleted-an-environment-and-all-the-services-werent-deleted
feat(environment): add service check before environment deletion
2026-02-16 20:35:52 -06:00
Mauricio Siu
7e8d3b7162 feat(environment): add service check before environment deletion
- Implemented a new function to verify if an environment has active services before allowing its deletion. This prevents accidental deletion of environments that are still in use.
2026-02-16 20:35:31 -06:00
Mauricio Siu
24010af265 Merge pull request #3727 from Dokploy/3717-error-setting-a-number-of-replicas-of-a-dokploy-app
fix(cluster): ensure Replicas value is correctly converted to number …
2026-02-16 20:32:56 -06:00
Mauricio Siu
33192ce4d1 fix(cluster): ensure Replicas value is correctly converted to number in mode-form
- Updated the mode-form component to convert the Replicas value to a number only if it is defined and not an empty string, improving data handling for Replicated mode.
2026-02-16 20:32:29 -06:00
Mauricio Siu
02a695c6af chore(dokploy): update build-next command to use webpack
- Modified the build-next script in package.json to include the --webpack flag, enhancing the build process for the Dokploy application.
2026-02-16 14:50:08 -06:00
Mauricio Siu
e5f51fd7be chore(dependencies): add esbuild override in package.json and pnpm-lock.yaml
- Introduced an override for esbuild version 0.20.2 in both package.json and pnpm-lock.yaml to ensure consistent dependency resolution.
2026-02-16 13:35:24 -06:00
Mauricio Siu
620e4c4835 Merge pull request #3718 from Dokploy/feat/remove-internationalization
chore(dependencies): update zod version across multiple packages to 3…
2026-02-16 13:23:26 -06:00
Mauricio Siu
125c23e2c0 fix(ai): update TypeScript ignore comments for deep instantiation issue
- Changed `@ts-expect-error` to `@ts-ignore` in the suggestVariants function to address TypeScript's excessively deep instantiation warning related to Zod and AI SDK output.
2026-02-16 13:16:00 -06:00
autofix-ci[bot]
51e005701d [autofix.ci] apply automated fixes 2026-02-16 18:51:10 +00:00
Mauricio Siu
c04dd63db8 chore(dependencies): update ai-sdk packages and other dependencies
- Upgraded @ai-sdk dependencies to versions 3.0.44, 3.0.30, 3.0.21, 2.0.34, 3.0.20, and 3.0.29 in package.json files for both server and dokploy.
- Updated ai package to version 6.0.86 and ai-sdk-ollama to version 3.7.0.
- Updated swagger-ui-react to version 5.31.1.
- Added a new DEBUG-BUILD.md file for debugging build issues in the server package.
- Introduced tsconfig.server.no-decl.json to manage TypeScript compilation options without declaration files.
- Modified tsconfig.json to include .next directory for TypeScript compilation.
2026-02-16 12:50:34 -06:00
Mauricio Siu
4fd06b00a0 chore(dokploy): simplify build-next command in package.json
- Removed the unnecessary --webpack flag from the build-next script, streamlining the build process.
2026-02-16 02:23:35 -06:00
autofix-ci[bot]
1f9335ad5d [autofix.ci] apply automated fixes 2026-02-16 08:16:18 +00:00
Mauricio Siu
2cd3c27ae9 refactor(dashboard): replace localization strings with static text for certificate and port labels
- Updated the WebDomain and ManageTraefikPorts components to use static placeholders and labels instead of localization functions, improving readability and simplifying the codebase.
2026-02-16 02:15:53 -06:00
autofix-ci[bot]
53ae08cec4 [autofix.ci] apply automated fixes 2026-02-16 08:10:23 +00:00
Mauricio Siu
8aab8dd2a5 chore(dependencies): update zod version across multiple packages to 3.25.76 and remove unused i18next dependencies
- Updated zod version from 3.25.32 to 3.25.76 in pnpm-lock.yaml, package.json files for api, dokploy, schedules, and server.
- Removed i18next and related localization code from the dokploy application to streamline the codebase.
2026-02-16 02:09:33 -06:00
Mauricio Siu
e8bec0ae03 fix(compose): correct command string for docker-compose execution 2026-02-15 21:57:06 -06:00
Mauricio Siu
389a69484e refactor(sidebar): streamline organization dropdown menu interactions
- Simplified the organization selection process by enhancing the dropdown menu structure.
- Improved the layout for better visibility and usability, ensuring a smoother user experience when setting default organizations and deleting them.
- Added error handling for organization actions to provide user feedback on success or failure.
2026-02-13 00:25:02 -06:00
Mauricio Siu
f656e624f7 Merge pull request #3692 from vprudnikoff/fix/stack-rm-compose-directory-dependency
fix: prevent orphaned docker stacks when compose directory is missing
2026-02-13 00:24:12 -06:00
Mauricio Siu
f5635f6645 Merge pull request #3701 from Dokploy/3690-organization-picker-not-scrollable
feat(sidebar): enhance dropdown menu styling and organization display
2026-02-13 00:19:37 -06:00
Mauricio Siu
81a04d0777 feat(sidebar): enhance dropdown menu styling and organization display
- Updated the dropdown menu to have a maximum height and added overflow handling for better usability.
- Adjusted the layout of the organizations list to ensure proper display and scrolling behavior.
2026-02-13 00:19:08 -06:00
Mauricio Siu
b63c22a7df Merge pull request #3700 from Dokploy/feat/edit-sso-providers
Feat/edit sso providers
2026-02-13 00:17:39 -06:00
Mauricio Siu
05ad6d812c Merge branch 'canary' into feat/edit-sso-providers 2026-02-13 00:17:17 -06:00
Mauricio Siu
aa579977e3 feat(auth): update trusted providers configuration to use environment variable
- Replaced database query for trusted providers with an environment variable, allowing for more flexible configuration of SSO integrations.
2026-02-13 00:16:37 -06:00
Mauricio Siu
2788323e01 feat(sso): refactor SSO provider update logic
- Changed the update mechanism for SSO providers to use a new `updateSSOProvider` function, improving code clarity and maintainability.
- Updated the payload structure for OIDC and SAML configurations to directly use the input values instead of stringifying them.
- Enhanced the overall handling of SSO provider updates within the API router.
2026-02-13 00:15:05 -06:00
Mauricio Siu
3b74425d35 Merge pull request #3699 from Dokploy/copilot/fix-discord-notification-button
Fix decoration toggle reverting to enabled in Discord/Gotify notifications
2026-02-12 23:50:33 -06:00
autofix-ci[bot]
edbc98aea7 [autofix.ci] apply automated fixes 2026-02-13 05:50:01 +00:00
Mauricio Siu
60f5ab304a feat(sso): enhance SAML provider registration and editing experience
- Added support for editing existing SAML providers, allowing users to update issuer, domains, entry point, and certificate.
- Introduced a new function to parse SAML configuration from JSON.
- Updated the UI to reflect changes in the registration dialog based on whether the user is adding or editing a provider.
- Improved user feedback with success messages tailored for registration and updates.
- Added a new column `created_at` to the `sso_provider` table for better tracking of provider creation times.
2026-02-12 23:49:27 -06:00
Mauricio Siu
8291c6d835 feat(sso): enhance OIDC provider registration and editing functionality
- Added support for editing existing OIDC providers, allowing users to update issuer, domains, client settings, and scopes.
- Introduced a new query to fetch OIDC provider details for editing.
- Updated the UI to reflect changes in the registration dialog based on whether the user is adding or editing a provider.
- Improved error handling for domain conflicts during updates.
2026-02-12 23:35:17 -06:00
copilot-swe-agent[bot]
7928d117b3 Fix Discord and Gotify decoration button reverting bug
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-02-13 04:21:31 +00:00
copilot-swe-agent[bot]
eec4e21751 Initial plan 2026-02-13 04:18:39 +00:00
v_prudnikoff
343a84d6bc fix: prevent orphaned docker stacks when compose directory is missing
`docker stack rm` was chained after `cd` with `&&`, so if the compose
directory didn't exist the stack removal command never executed. This left
orphaned Docker services with occupied ports after deletion via the API.

Also removed a duplicate `execAsync` call that always ran outside the
`if/else` block regardless of `compose.serverId`.

Fixes #3691
2026-02-12 14:53:30 +00:00
Mauricio Siu
89416fef47 Merge pull request #3685 from Dokploy/feat/add-trusted-providers-dinamically
feat(auth): dynamically add trusted providers for account linking
2026-02-10 23:48:13 -06:00
Mauricio Siu
74d72f1494 feat(auth): dynamically add trusted providers for account linking
- Updated the account linking configuration to include trusted providers fetched from the database, enhancing flexibility in managing SSO integrations.
2026-02-10 23:47:21 -06:00
Mauricio Siu
a24dbe365a Merge pull request #3684 from Dokploy/fix/add-punycode
feat(traefik): add support for internationalized domain names (IDN)
2026-02-10 22:49:42 -06:00
Mauricio Siu
3b753ecfbf test(traefik): add tests for punycode conversion of Russian IDNs
- Added tests to verify the conversion of Russian Cyrillic domains and subdomains with IDN TLDs to punycode format, ensuring proper handling in router configurations.
- Confirmed that non-ASCII parts are correctly converted while ASCII parts remain unchanged.
2026-02-10 22:44:17 -06:00
Mauricio Siu
7184b7d4b2 feat(traefik): add support for internationalized domain names (IDN)
- Implemented a function to convert IDNs to ASCII punycode format, ensuring compatibility with Traefik requirements.
- Added tests to verify the conversion of IDNs and the handling of ASCII domains in router configurations.
2026-02-10 22:42:44 -06:00
Mauricio Siu
5c36ca3986 Merge pull request #3683 from Dokploy/3667-dokploy-update-from-ui-doesnt-work-but-states-success
fix(update-server): display release tag conditionally in server versi…
2026-02-10 18:43:22 -06:00
Mauricio Siu
3a3f3ab7d4 fix(update-server): display release tag conditionally in server version info
- Updated the server version display to conditionally show the release tag when it is either "canary" or "feature", enhancing clarity for users.
2026-02-10 18:40:53 -06:00
Mauricio Siu
1779a8a950 chore(package): bump version to v0.27.1 2026-02-10 18:35:04 -06:00
Mauricio Siu
a51a4b3e87 Merge pull request #3681 from Dokploy/3672-misleading-error-when-renaming-service-domain-still-bound-to-old-service-name
fix(docker): improve error messages for missing service names in doma…
2026-02-10 18:03:56 -06:00
Mauricio Siu
034d55d7cb fix(docker): improve error messages for missing service names in domain configuration
- Enhanced error handling in the addDomainToCompose function to provide more descriptive messages when a domain's service name is missing or when the service does not exist in the compose configuration. This improves debugging and user feedback.
2026-02-10 18:03:29 -06:00
Mauricio Siu
eeb7f00d05 Merge pull request #3680 from Dokploy/feat/add-trusted-origins-sso
Feat/add trusted origins sso
2026-02-10 18:01:17 -06:00
autofix-ci[bot]
1326d14a00 [autofix.ci] apply automated fixes 2026-02-10 23:59:10 +00:00
Mauricio Siu
59f843f8a0 fix(stripe): filter products to include only monthly and annual subscriptions
- Updated the Stripe API response to return only the monthly and annual subscription products.
- Enhanced the product listing logic to filter out unnecessary products, improving data handling in the application.
2026-02-10 17:55:50 -06:00
Mauricio Siu
fe807ae2a6 feat(sso): implement management for trusted origins in SSO settings
- Added functionality to add, edit, and remove trusted origins for SSO callbacks.
- Introduced new API mutations for managing trusted origins.
- Enhanced the SSO settings UI to include a dialog for managing trusted origins, with appropriate state handling and user feedback via toast notifications.
2026-02-10 17:52:41 -06:00
Mauricio Siu
744ebab15a refactor(deployments): enhance deployment worker and queue handling for cloud environments
- Refactored the deployment worker to create a no-op worker when Redis is disabled (e.g., IS_CLOUD), preventing BullMQ connection errors.
- Updated queue initialization to use a no-op queue in cloud environments, ensuring compatibility and stability.
- Improved error handling and logging for job processing in the deployment worker.
2026-02-10 03:11:33 -06:00
Mauricio Siu
17da1d5b3c fix: Update LICENSE_KEY_URL for production environment
- Changed the production license key URL from "https://licenses.dokploy.com" to "https://licenses-api.dokploy.com" for improved API access.
2026-02-10 00:31:40 -06:00
Mauricio Siu
f7613d9375 Merge pull request #3664 from AlexDev404/fix/break-project-description-properly
fix: Update text breaking so that it breaks words properly
2026-02-10 00:14:36 -06:00
Mauricio Siu
a43ad106f2 Merge pull request #3665 from Dokploy/3652-fixrepository-loading
refactor(dokploy): improve repository selection UI for version contro…
2026-02-10 00:13:52 -06:00
autofix-ci[bot]
0e26c5023b [autofix.ci] apply automated fixes 2026-02-10 06:13:46 +00:00
autofix-ci[bot]
f4a4530481 [autofix.ci] apply automated fixes 2026-02-10 06:12:28 +00:00
Mauricio Siu
00dc3fae11 refactor(dokploy): improve repository selection UI for version control providers
- Updated repository selection logic across Bitbucket, Gitea, GitHub, and GitLab components to display a placeholder when no repository is selected.
- Enhanced loading state messages for better user experience, ensuring users are prompted to select an account before loading repositories.
- Cleaned up conditional rendering for loading states and account selection prompts in the UI.
2026-02-10 00:11:39 -06:00
Mauricio Siu
1da23f8888 Merge pull request #3650 from Dokploy/feat/add-linking-accounts-cloud-version
Feat/add linking accounts cloud version
2026-02-09 13:29:12 -06:00
Immanuel Daviel A. Garcia
bee4e4639c amend: Apply the proper fix 2026-02-09 13:10:50 -06:00
Immanuel Daviel A. Garcia
bd5b27ad51 fix: Update text breaking so that it breaks words properly 2026-02-09 12:48:28 -06:00
Mauricio Siu
b391abfd5c feat(dokploy): add product IDs for monthly and annual subscriptions in Stripe integration
- Introduced PRODUCT_MONTHLY_ID and PRODUCT_ANNUAL_ID constants to manage subscription product IDs.
- Updated the Stripe API call to fetch only the specified subscription products, enhancing performance and clarity in product management.
2026-02-09 02:42:15 -06:00
autofix-ci[bot]
21a6657e00 [autofix.ci] apply automated fixes 2026-02-09 08:33:23 +00:00
Mauricio Siu
d348ad5556 fix(dokploy): remove console logs from linking account component
- Eliminated unnecessary console log statements in the LinkingAccount component to clean up the code and improve performance.
- Ensured that the account listing functionality remains intact while enhancing code readability.
2026-02-09 02:21:37 -06:00
Mauricio Siu
5d8b7b9b99 feat(dokploy): implement linking account feature for social providers
- Added a new component for linking Google and GitHub accounts to user profiles.
- Integrated account linking functionality with the authentication client, allowing users to link and unlink their social accounts.
- Updated the profile settings page to conditionally display the linking account component based on cloud settings.
- Enhanced error handling and loading states for a better user experience.
2026-02-09 02:21:20 -06:00
Mauricio Siu
f5fa39b97e refactor(dokploy): restrict license key access to owners only and enhance validation
- Updated the license key settings to ensure only users with the "owner" role can access certain functionalities.
- Modified the license key activation input validation to require a non-empty string.
- Improved error handling for network issues when validating license keys, providing clearer feedback to users.
- Adjusted the dashboard settings to redirect non-owner users appropriately.
2026-02-09 01:15:35 -06:00
Mauricio Siu
0a3a90c4e9 Merge pull request #3541 from gentslava/feat/docker-compose-pull
Update docker-compose command to always pull images (reopened)
2026-02-09 00:18:14 -06:00
Mauricio Siu
f440df343a Merge pull request #3593 from fernandogiacomino/canary
Replace logo.svg with updated SVG design
2026-02-09 00:14:26 -06:00
Mauricio Siu
4ec282b2f3 Merge pull request #3648 from Dokploy/ulimits-at-0a401843
Ulimits at 0a401843
2026-02-08 23:40:40 -06:00
Mauricio Siu
c039e638a6 refactor(dokploy): reorganize imports and simplify ulimitsSwarm assignment
- Moved the Tooltip imports to a more appropriate location for better readability.
- Simplified the assignment of ulimitsSwarm to ensure it directly accesses the data property.
2026-02-08 23:36:20 -06:00
Mauricio Siu
65ffc63da4 feat(dokploy): add ulimitsSwarm column to multiple database tables and update journal
- Introduced a new column "ulimitsSwarm" of type json to the "application", "mariadb", "mongo", "mysql", "postgres", and "redis" tables.
- Added a corresponding entry in the journal for version 7 to track this migration.
2026-02-08 23:31:22 -06:00
Mauricio Siu
5ba120567f Merge branch 'canary' into ulimits-at-0a401843 2026-02-08 23:30:14 -06:00
Mauricio Siu
8a335789b3 chore: remove deprecated SQL migration and associated journal entry for ulimits configuration 2026-02-08 23:30:05 -06:00
Fernando Giacomino
de2579401c Replace logo.svg with updated SVG design
Updated logo.svg with polished stroke and centered space for better fit on apps.
2026-02-04 11:10:13 -03:00
Vyacheslav Scherbinin
6c90075a64 feat(compose): update docker-compose command to always pull images 2026-01-28 13:35:29 +07:00
autofix-ci[bot]
5ca4d8366e [autofix.ci] apply automated fixes 2026-01-19 08:59:20 +01:00
Marc Fernandez
cc49db63da chore: add DevContainer 2026-01-19 08:59:19 +01:00
nurikk
0a401843f8 core: add ulimits configuration for Docker Swarm deployments
Users deploying to Docker Swarm can now configure resource ulimits
(nofile, nproc, etc.) to prevent applications from hitting system
limits that cause crashes or degraded performance.
2026-01-08 21:57:58 +00:00
108 changed files with 26775 additions and 3964 deletions

21
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Dockerfile for DevContainer
FROM node:20.16.0-bullseye-slim
# Install essential packages
RUN apt-get update && apt-get install -y \
curl \
bash \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up PNPM
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Create workspace directory
WORKDIR /workspaces/dokploy
# Set up user permissions
USER node

View File

@@ -0,0 +1,53 @@
{
"name": "Dokploy development container",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/git:1": {
"ppa": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.20"
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-json",
"biomejs.biome",
"golang.go",
"redhat.vscode-xml",
"github.vscode-github-actions",
"github.copilot",
"github.copilot-chat"
]
}
},
"forwardPorts": [3000, 5432, 6379],
"portsAttributes": {
"3000": {
"label": "Dokploy App",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "silent"
},
"6379": {
"label": "Redis",
"onAutoForward": "silent"
}
},
"remoteUser": "node",
"workspaceFolder": "/workspaces/dokploy",
"runArgs": ["--name", "dokploy-devcontainer"]
}

5
.gitignore vendored
View File

@@ -43,7 +43,4 @@ yarn-error.log*
*.pem
.db
# Development environment
.devcontainer
.db

View File

@@ -20,7 +20,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"redis": "4.7.0",
"zod": "^3.25.32"
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^20.16.0",

View File

@@ -147,6 +147,7 @@ const baseApp: ApplicationNested = {
dockerContextPath: null,
rollbackActive: false,
stopGracePeriodSwarm: null,
ulimitsSwarm: null,
};
describe("unzipDrop using real zip files", () => {

View File

@@ -6,6 +6,7 @@ type MockCreateServiceOptions = {
TaskTemplate?: {
ContainerSpec?: {
StopGracePeriod?: number;
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>;
};
};
[key: string]: unknown;
@@ -57,6 +58,7 @@ const createApplication = (
},
replicas: 1,
stopGracePeriodSwarm: 0n,
ulimitsSwarm: null,
serverId: "server-id",
...overrides,
}) as unknown as ApplicationNested;
@@ -110,4 +112,50 @@ describe("mechanizeDockerContainer", () => {
"StopGracePeriod",
);
});
it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => {
const ulimits = [
{ Name: "nofile", Soft: 10000, Hard: 20000 },
{ Name: "nproc", Soft: 4096, Hard: 8192 },
];
const application = createApplication({ ulimitsSwarm: ulimits });
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
if (!call) {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits);
});
it("omits Ulimits when ulimitsSwarm is null", async () => {
const application = createApplication({ ulimitsSwarm: null });
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
if (!call) {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
});
it("omits Ulimits when ulimitsSwarm is an empty array", async () => {
const application = createApplication({ ulimitsSwarm: [] });
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
if (!call) {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
});
});

View File

@@ -125,6 +125,7 @@ const baseApp: ApplicationNested = {
username: null,
dockerContextPath: null,
stopGracePeriodSwarm: null,
ulimitsSwarm: null,
};
const baseDomain: Domain = {
@@ -274,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt");
});
/** IDN/Punycode */
test("Internationalized domain name is converted to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "тест.рф" },
"web",
);
// тест.рф in punycode is xn--e1aybc.xn--p1ai
expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)");
expect(router.rule).not.toContain("тест.рф");
});
test("ASCII domain remains unchanged", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "example.com" },
"web",
);
expect(router.rule).toContain("Host(`example.com`)");
});
test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "сайт.ru" },
"web",
);
// сайт in punycode is xn--80aswg
expect(router.rule).toContain("Host(`xn--80aswg.ru`)");
expect(router.rule).not.toContain("сайт");
});
test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "app.тест.рф" },
"web",
);
// app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai
expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)");
expect(router.rule).not.toContain("тест.рф");
});

View File

@@ -105,7 +105,14 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
const modeData =
formData.type === "Replicated"
? { Replicated: { Replicas: formData.Replicas } }
? {
Replicated: {
Replicas:
formData.Replicas !== undefined && formData.Replicas !== ""
? Number(formData.Replicas)
: undefined,
},
}
: { Global: {} };
await mutateAsync({

View File

@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { InfoIcon } from "lucide-react";
import { InfoIcon, Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -21,10 +21,18 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
createConverter,
NumberInputWithSteps,
} from "@/components/ui/number-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
@@ -50,13 +58,36 @@ const memoryConverter = createConverter(1024 * 1024, (mb) => {
: `${formatNumber(mb)} MB`;
});
const ulimitSchema = z.object({
Name: z.string().min(1, "Name is required"),
Soft: z.coerce.number().int().min(-1, "Must be >= -1"),
Hard: z.coerce.number().int().min(-1, "Must be >= -1"),
});
const addResourcesSchema = z.object({
memoryReservation: z.string().optional(),
cpuLimit: z.string().optional(),
memoryLimit: z.string().optional(),
cpuReservation: z.string().optional(),
ulimitsSwarm: z.array(ulimitSchema).optional(),
});
const ULIMIT_PRESETS = [
{ value: "nofile", label: "nofile (Open Files)" },
{ value: "nproc", label: "nproc (Processes)" },
{ value: "memlock", label: "memlock (Locked Memory)" },
{ value: "stack", label: "stack (Stack Size)" },
{ value: "core", label: "core (Core File Size)" },
{ value: "cpu", label: "cpu (CPU Time)" },
{ value: "data", label: "data (Data Segment)" },
{ value: "fsize", label: "fsize (File Size)" },
{ value: "locks", label: "locks (File Locks)" },
{ value: "msgqueue", label: "msgqueue (Message Queues)" },
{ value: "nice", label: "nice (Nice Priority)" },
{ value: "rtprio", label: "rtprio (Real-time Priority)" },
{ value: "sigpending", label: "sigpending (Pending Signals)" },
];
export type ServiceType =
| "postgres"
| "mongo"
@@ -107,10 +138,16 @@ export const ShowResources = ({ id, type }: Props) => {
cpuReservation: "",
memoryLimit: "",
memoryReservation: "",
ulimitsSwarm: [],
},
resolver: zodResolver(addResourcesSchema),
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "ulimitsSwarm",
});
useEffect(() => {
if (data) {
form.reset({
@@ -118,6 +155,7 @@ export const ShowResources = ({ id, type }: Props) => {
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
ulimitsSwarm: data?.ulimitsSwarm || [],
});
}
}, [data, form, form.reset]);
@@ -134,6 +172,10 @@ export const ShowResources = ({ id, type }: Props) => {
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
ulimitsSwarm:
formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0
? formData.ulimitsSwarm
: null,
})
.then(async () => {
toast.success("Resources Updated");
@@ -325,6 +367,145 @@ export const ShowResources = ({ id, type }: Props) => {
}}
/>
</div>
{/* Ulimits Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FormLabel className="text-base">Ulimits</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>
Set resource limits for the container. Each ulimit has
a soft limit (warning threshold) and hard limit
(maximum allowed). Use -1 for unlimited.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({ Name: "nofile", Soft: 65535, Hard: 65535 })
}
>
<Plus className="h-4 w-4 mr-1" />
Add Ulimit
</Button>
</div>
{fields.length > 0 && (
<div className="space-y-3">
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
>
<FormField
control={form.control}
name={`ulimitsSwarm.${index}.Name`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-xs">Type</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select ulimit" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ULIMIT_PRESETS.map((preset) => (
<SelectItem
key={preset.value}
value={preset.value}
>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ulimitsSwarm.${index}.Soft`}
render={({ field }) => (
<FormItem className="w-32">
<FormLabel className="text-xs">
Soft Limit
</FormLabel>
<FormControl>
<Input
type="number"
min={-1}
placeholder="65535"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ulimitsSwarm.${index}.Hard`}
render={({ field }) => (
<FormItem className="w-32">
<FormLabel className="text-xs">
Hard Limit
</FormLabel>
<FormControl>
<Input
type="number"
min={-1}
placeholder="65535"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-6 text-destructive hover:text-destructive"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No ulimits configured. Click &quot;Add Ulimit&quot; to set
resource limits.
</p>
)}
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save

View File

@@ -245,13 +245,13 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
{!field.value.owner
? "Select repository"
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name
: "Select repository"}
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -263,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
{!bitbucketId ? (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a Bitbucket account first
</span>
) : isLoadingRepositories ? (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
) : null}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>

View File

@@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
{!field.value.owner
? "Select repository"
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo: GiteaRepository) =>
repo.name === field.value.repo,
)?.name
: "Select repository"}
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -277,11 +277,15 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
{!giteaId ? (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a Gitea account first
</span>
) : isLoadingRepositories ? (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
) : null}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>

View File

@@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
{!field.value.owner
? "Select repository"
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name
: "Select repository"}
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -251,11 +251,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
{!githubId ? (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a GitHub account first
</span>
) : isLoadingRepositories ? (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
) : null}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>

View File

@@ -254,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
{!field.value.owner
? "Select repository"
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name
: "Select repository"}
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
{!gitlabId ? (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a GitLab account first
</span>
) : isLoadingRepositories ? (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
) : null}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>

View File

@@ -247,13 +247,13 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
{!field.value.owner
? "Select repository"
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name
: "Select repository"}
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -265,11 +265,15 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
{!bitbucketId ? (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a Bitbucket account first
</span>
) : isLoadingRepositories ? (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
) : null}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>

View File

@@ -244,13 +244,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
{!field.value.owner
? "Select repository"
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name
: "Select repository"}
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
@@ -261,11 +261,15 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
{!giteaId ? (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a Gitea account first
</span>
) : isLoadingRepositories ? (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
) : null}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>

View File

@@ -234,13 +234,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
{!field.value.owner
? "Select repository"
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name
: "Select repository"}
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -252,11 +252,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
{!githubId ? (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a GitHub account first
</span>
) : isLoadingRepositories ? (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
) : null}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>

View File

@@ -256,13 +256,13 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
{!field.value.owner
? "Select repository"
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name
: "Select repository"}
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -274,11 +274,15 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
{!gitlabId ? (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a GitLab account first
</span>
) : isLoadingRepositories ? (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
) : null}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>

View File

@@ -430,7 +430,7 @@ export const ShowProjects = () => {
</DropdownMenu>
) : null}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
<span className="flex flex-col gap-1.5 ">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
@@ -439,7 +439,7 @@ export const ShowProjects = () => {
</span>
</div>
<span className="text-sm font-medium text-muted-foreground break-all">
<span className="text-sm font-medium text-muted-foreground break-normal">
{project.description}
</span>

View File

@@ -0,0 +1,245 @@
"use client";
import { Link2, Loader2, Unlink } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { authClient } from "@/lib/auth-client";
const LINKING_CALLBACK_URL = "/dashboard/settings/profile";
const TRUSTED_PROVIDERS = ["google", "github"] as const;
type SocialProvider = (typeof TRUSTED_PROVIDERS)[number];
type AccountItem = {
providerId: string;
accountId?: string;
};
function providerLabel(providerId: string): string {
return providerId.charAt(0).toUpperCase() + providerId.slice(1);
}
export function LinkingAccount() {
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [accountsLoading, setAccountsLoading] = useState(true);
const [linkingProvider, setLinkingProvider] = useState<SocialProvider | null>(
null,
);
const [unlinkingProviderId, setUnlinkingProviderId] = useState<string | null>(
null,
);
const fetchAccounts = useCallback(async () => {
setAccountsLoading(true);
try {
const { data } = await authClient.listAccounts();
const list = Array.isArray(data)
? data
: ((data && typeof data === "object" && "accounts" in data
? (data as { accounts?: AccountItem[] }).accounts
: null) ?? []);
setAccounts(Array.isArray(list) ? list : []);
} catch {
setAccounts([]);
} finally {
setAccountsLoading(false);
}
}, []);
useEffect(() => {
fetchAccounts();
}, [fetchAccounts]);
const linkedProviderIds = new Set(accounts.map((a) => a.providerId));
const socialAccounts = accounts.filter((a) =>
TRUSTED_PROVIDERS.includes(a.providerId as SocialProvider),
);
const handleLinkSocial = async (provider: SocialProvider) => {
setLinkingProvider(provider);
try {
const { error } = await authClient.linkSocial({
provider,
callbackURL: LINKING_CALLBACK_URL,
});
if (error) {
toast.error(error.message ?? "Failed to link account");
setLinkingProvider(null);
return;
}
} catch (err) {
toast.error(
"Failed to link account",
err instanceof Error ? { description: err.message } : undefined,
);
setLinkingProvider(null);
}
};
const handleUnlink = async (providerId: string, accountId?: string) => {
setUnlinkingProviderId(providerId);
try {
const { error } = await authClient.unlinkAccount({
providerId,
...(accountId && { accountId }),
});
if (error) {
toast.error(error.message ?? "Failed to unlink account");
return;
}
toast.success("Account unlinked");
await fetchAccounts();
} catch (err) {
toast.error(
"Failed to unlink account",
err instanceof Error ? { description: err.message } : undefined,
);
} finally {
setUnlinkingProviderId(null);
}
};
const canUnlink = accounts.length > 1;
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<div className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<Link2 className="size-6 text-muted-foreground self-center" />
Linking account
</CardTitle>
<CardDescription>
Link your Google or GitHub account to sign in with them.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 py-8 border-t">
{/* Linked accounts */}
<div className="space-y-2">
<p className="text-sm font-medium">Linked accounts</p>
{accountsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : socialAccounts.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No social accounts linked yet.
</p>
) : (
<ul className="space-y-2">
{socialAccounts.map((acc) => (
<li
key={acc.accountId ?? acc.providerId}
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
>
<span className="font-medium">
{providerLabel(acc.providerId)}
</span>
{canUnlink && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() =>
handleUnlink(acc.providerId, acc.accountId)
}
disabled={unlinkingProviderId === acc.providerId}
isLoading={unlinkingProviderId === acc.providerId}
>
{unlinkingProviderId === acc.providerId ? (
<Loader2 className="size-4 animate-spin" />
) : (
<>
<Unlink className="mr-1.5 size-4" />
Unlink
</>
)}
</Button>
)}
</li>
))}
</ul>
)}
</div>
<p className="text-sm text-muted-foreground">
Click a provider below to link it to your account. You will be
redirected to complete the flow.
</p>
<div className="flex flex-wrap gap-3">
{!linkedProviderIds.has("google") && (
<Button
variant="outline"
type="button"
className="min-w-[180px]"
onClick={() => handleLinkSocial("google")}
disabled={!!linkingProvider}
isLoading={linkingProvider === "google"}
>
{linkingProvider === "google" ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<svg viewBox="0 0 24 24" className="mr-2 size-4">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
)}
Link with Google
</Button>
)}
{!linkedProviderIds.has("github") && (
<Button
variant="outline"
type="button"
className="min-w-[180px]"
onClick={() => handleLinkSocial("github")}
disabled={!!linkingProvider}
isLoading={linkingProvider === "github"}
>
{linkingProvider === "github" ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<svg
viewBox="0 0 24 24"
className="mr-2 size-4"
fill="currentColor"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
)}
Link with GitHub
</Button>
)}
</div>
</CardContent>
</div>
</Card>
);
}

View File

@@ -18,6 +18,7 @@ import {
PushoverIcon,
ResendIcon,
SlackIcon,
TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { Button } from "@/components/ui/button";
@@ -164,6 +165,12 @@ export const notificationSchema = z.discriminatedUnion("type", [
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("teams"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
]);
export const notificationsMap = {
@@ -183,6 +190,10 @@ export const notificationsMap = {
icon: <LarkIcon className="text-muted-foreground" />,
label: "Lark",
},
teams: {
icon: <TeamsIcon className="text-muted-foreground" />,
label: "Microsoft Teams",
},
email: {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
@@ -244,6 +255,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testNtfyConnection.useMutation();
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
api.notification.testLarkConnection.useMutation();
const { mutateAsync: testTeamsConnection, isLoading: isLoadingTeams } =
api.notification.testTeamsConnection.useMutation();
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
api.notification.testCustomConnection.useMutation();
@@ -278,6 +291,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
const teamsMutation = notificationId
? api.notification.updateTeams.useMutation()
: api.notification.createTeams.useMutation();
const pushoverMutation = notificationId
? api.notification.updatePushover.useMutation()
: api.notification.createPushover.useMutation();
@@ -353,7 +369,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.discord?.webhookUrl,
decoration: notification.discord?.decoration || undefined,
decoration: notification.discord?.decoration ?? undefined,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
@@ -400,7 +416,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
appToken: notification.gotify?.appToken,
decoration: notification.gotify?.decoration || undefined,
decoration: notification.gotify?.decoration ?? undefined,
priority: notification.gotify?.priority,
serverUrl: notification.gotify?.serverUrl,
name: notification.name,
@@ -435,6 +451,19 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "teams") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.teams?.webhookUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "custom") {
form.reset({
appBuildError: notification.appBuildError,
@@ -488,6 +517,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
teams: teamsMutation,
custom: customMutation,
pushover: pushoverMutation,
};
@@ -630,6 +660,20 @@ export const HandleNotifications = ({ notificationId }: Props) => {
larkId: notification?.larkId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "teams") {
promise = teamsMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
teamsId: notification?.teamsId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "custom") {
// Convert headers array to object
const headersRecord =
@@ -1465,6 +1509,32 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "teams" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://xxx.webhook.office.com/webhookb2/..."
{...field}
/>
</FormControl>
<FormDescription>
Incoming Webhook URL from a Teams channel. Add an
Incoming Webhook in your channel settings to get the
URL.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === "pushover" && (
<>
<FormField
@@ -1780,6 +1850,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark ||
isLoadingTeams ||
isLoadingCustom ||
isLoadingPushover
}
@@ -1841,6 +1912,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
await testLarkConnection({
webhookUrl: data.webhookUrl,
});
} else if (data.type === "teams") {
await testTeamsConnection({
webhookUrl: data.webhookUrl,
});
} else if (data.type === "custom") {
const headersRecord =
data.headers && data.headers.length > 0

View File

@@ -7,6 +7,7 @@ import {
NtfyIcon,
ResendIcon,
SlackIcon,
TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -37,7 +38,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email, Resend, Lark.
Telegram, Teams, Email, Resend, Lark.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -112,6 +113,11 @@ export const ShowNotifications = () => {
<LarkIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.notificationType === "teams" && (
<div className="flex items-center justify-center rounded-lg">
<TeamsIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.name}
</span>

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Palette, User } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -73,7 +72,6 @@ export const ProfileForm = () => {
isError,
error,
} = api.user.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
@@ -157,10 +155,10 @@ export const ProfileForm = () => {
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<User className="size-6 text-muted-foreground self-center" />
{t("settings.profile.title")}
Account
</CardTitle>
<CardDescription>
{t("settings.profile.description")}
Change the details of your profile here.
</CardDescription>
</div>
@@ -213,12 +211,9 @@ export const ProfileForm = () => {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.profile.email")}</FormLabel>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder={t("settings.profile.email")}
{...field}
/>
<Input placeholder="Email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -233,7 +228,7 @@ export const ProfileForm = () => {
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
placeholder="Current Password"
{...field}
value={field.value || ""}
/>
@@ -247,13 +242,11 @@ export const ProfileForm = () => {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("settings.profile.password")}
</FormLabel>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
placeholder="Password"
{...field}
value={field.value || ""}
/>
@@ -268,9 +261,7 @@ export const ProfileForm = () => {
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("settings.profile.avatar")}
</FormLabel>
<FormLabel>Avatar</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(e) => {
@@ -454,7 +445,7 @@ export const ProfileForm = () => {
<div className="flex items-center justify-end gap-2">
<Button type="submit" isLoading={isUpdating}>
{t("settings.common.save")}
Save
</Button>
</div>
</form>

View File

@@ -1,4 +1,3 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
import { Button } from "@/components/ui/button";
@@ -17,7 +16,6 @@ import { TerminalModal } from "../../web-server/terminal-modal";
import { GPUSupportModal } from "../gpu-support-modal";
export const ShowDokployActions = () => {
const { t } = useTranslation("settings");
const { mutateAsync: reloadServer, isLoading } =
api.settings.reloadServer.useMutation();
@@ -30,13 +28,11 @@ export const ShowDokployActions = () => {
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={isLoading}>
<Button isLoading={isLoading} variant="outline">
{t("settings.server.webServer.server.label")}
Server
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -51,17 +47,17 @@ export const ShowDokployActions = () => {
}}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.reload")}</span>
<span>Reload</span>
</DropdownMenuItem>
<TerminalModal serverId="local">
<span>{t("settings.common.enterTerminal")}</span>
<span>Terminal</span>
</TerminalModal>
<ShowModalLogs appName="dokploy">
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
{t("settings.server.webServer.watchLogs")}
View Logs
</DropdownMenuItem>
</ShowModalLogs>
<GPUSupportModal />
@@ -70,7 +66,7 @@ export const ShowDokployActions = () => {
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
{t("settings.server.webServer.updateServerIp")}
Update Server IP
</DropdownMenuItem>
</UpdateServerIp>

View File

@@ -1,4 +1,3 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -16,7 +15,6 @@ interface Props {
serverId?: string;
}
export const ShowStorageActions = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
api.settings.cleanAll.useMutation();
@@ -64,13 +62,11 @@ export const ShowStorageActions = ({ serverId }: Props) => {
}
variant="outline"
>
{t("settings.server.webServer.storage.label")}
Space
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="start">
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -87,9 +83,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanUnusedImages")}
</span>
<span>Clean unused images</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
@@ -105,9 +99,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanUnusedVolumes")}
</span>
<span>Clean unused volumes</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -124,9 +116,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanStoppedContainers")}
</span>
<span>Clean stopped containers</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -143,9 +133,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanDockerBuilder")}
</span>
<span>Clean Docker Builder & System</span>
</DropdownMenuItem>
{!serverId && (
<DropdownMenuItem
@@ -160,9 +148,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanMonitoring")}
</span>
<span>Clean Monitoring</span>
</DropdownMenuItem>
)}
@@ -180,7 +166,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>{t("settings.server.webServer.storage.cleanAll")}</span>
<span>Clean all</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>

View File

@@ -1,4 +1,3 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -22,7 +21,6 @@ interface Props {
serverId?: string;
}
export const ShowTraefikActions = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
api.settings.reloadTraefik.useMutation();
@@ -75,13 +73,11 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
}
variant="outline"
>
{t("settings.server.webServer.traefik.label")}
Traefik
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -100,7 +96,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
className="cursor-pointer"
disabled={isReloadHealthCheckExecuting}
>
<span>{t("settings.server.webServer.reload")}</span>
<span>Reload</span>
</DropdownMenuItem>
<ShowModalLogs
appName="dokploy-traefik"
@@ -111,7 +107,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
{t("settings.server.webServer.watchLogs")}
View Logs
</DropdownMenuItem>
</ShowModalLogs>
<EditTraefikEnv serverId={serverId}>
@@ -119,7 +115,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.traefik.modifyEnv")}</span>
<span>Modify Environment</span>
</DropdownMenuItem>
</EditTraefikEnv>
@@ -176,7 +172,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
<span>Additional Port Mappings</span>
</DropdownMenuItem>
</ManageTraefikPorts>
</DropdownMenuGroup>

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -63,8 +62,6 @@ interface Props {
}
export const HandleServers = ({ serverId, asButton = false }: Props) => {
const { t } = useTranslation("settings");
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
@@ -365,7 +362,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="ipAddress"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.ipAddress")}</FormLabel>
<FormLabel>IP Address</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
@@ -379,7 +376,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.port")}</FormLabel>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
placeholder="22"
@@ -409,7 +406,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.username")}</FormLabel>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>

View File

@@ -13,7 +13,6 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -52,7 +51,6 @@ import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
export const ShowServers = () => {
const { t } = useTranslation("settings");
const router = useRouter();
const query = router.query;
const { data, refetch, isLoading } = api.server.all.useQuery();

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { GlobeIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -66,7 +65,6 @@ const addServerDomain = z
type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { t } = useTranslation("settings");
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { mutateAsync, isLoading } =
api.settings.assignDomainServer.useMutation();
@@ -119,10 +117,10 @@ export const WebDomain = () => {
<div className="flex flex-col gap-1">
<CardTitle className="text-xl flex flex-row gap-2">
<GlobeIcon className="size-6 text-muted-foreground self-center" />
{t("settings.server.domain.title")}
Server Domain
</CardTitle>
<CardDescription>
{t("settings.server.domain.description")}
Add a domain to your server application.
</CardDescription>
</div>
</CardHeader>
@@ -151,9 +149,7 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem>
<FormLabel>
{t("settings.server.domain.form.domain")}
</FormLabel>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input
className="w-full"
@@ -173,9 +169,7 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem>
<FormLabel>
{t("settings.server.domain.form.letsEncryptEmail")}
</FormLabel>
<FormLabel>Let's Encrypt Email</FormLabel>
<FormControl>
<Input
className="w-full"
@@ -216,32 +210,20 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
Let's Encrypt
</SelectItem>
</SelectContent>
</Select>
@@ -254,7 +236,7 @@ export const WebDomain = () => {
<div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit">
{t("settings.common.save")}
Save
</Button>
</div>
</form>

View File

@@ -1,5 +1,4 @@
import { ServerIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import {
Card,
CardContent,
@@ -15,7 +14,6 @@ import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
const { t } = useTranslation("settings");
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
@@ -29,18 +27,16 @@ export const WebServer = () => {
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ServerIcon className="size-6 text-muted-foreground self-center" />
{t("settings.server.webServer.title")}
Web Server
</CardTitle>
<CardDescription>
{t("settings.server.webServer.description")}
</CardDescription>
<CardDescription>Reload or clean the web server.</CardDescription>
</CardHeader>
{/* <CardHeader>
<CardTitle className="text-xl">
{t("settings.server.webServer.title")}
Web Server
</CardTitle>
<CardDescription>
{t("settings.server.webServer.description")}
Reload or clean the web server.
</CardDescription>
</CardHeader> */}
<CardContent className="space-y-6 py-6 border-t">

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
@@ -52,8 +51,6 @@ interface Props {
}
const LocalServerConfig = ({ onSave }: Props) => {
const { t } = useTranslation("settings");
const form = useForm<Schema>({
defaultValues: getLocalServerData(),
resolver: zodResolver(Schema),
@@ -77,9 +74,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
<div className="flex flex-row items-center gap-2 justify-between w-full">
<div className="flex flex-row gap-2 items-center">
<Settings className="h-4 w-4" />
<span className="dark:hover:text-white">
{t("settings.terminal.connectionSettings")}
</span>
<span className="dark:hover:text-white">Connection settings</span>
</div>
</div>
</AccordionTrigger>
@@ -96,7 +91,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.port")}</FormLabel>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
{...field}
@@ -124,7 +119,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.username")}</FormLabel>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
@@ -142,7 +137,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
className="ml-auto"
disabled={!form.formState.isDirty}
>
{t("settings.common.save")}
Save
</Button>
</AccordionContent>
</AccordionItem>

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import type React from "react";
import { useEffect, useState } from "react";
@@ -56,7 +55,6 @@ const TraefikPortsSchema = z.object({
type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const form = useForm<TraefikPortsForm>({
@@ -84,7 +82,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
successMessage: "Ports updated successfully",
onSuccess: () => {
refetchPorts();
setOpen(false);
@@ -129,14 +127,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
{t("settings.server.webServer.traefik.managePorts")}
Additional Port Mappings
</DialogTitle>
<DialogDescription className="text-base w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
{t(
"settings.server.webServer.traefik.managePortsDescription",
)}
Add or remove additional ports for Traefik
<span className="text-sm text-muted-foreground">
{fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "}
configured
@@ -179,9 +175,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.targetPort",
)}
Target Port
</FormLabel>
<FormControl>
<Input
@@ -210,9 +204,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.publishedPort",
)}
Published Port
</FormLabel>
<FormControl>
<Input

View File

@@ -135,7 +135,9 @@ export const UpdateServer = ({
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{dokployVersion} | {releaseTag}
{dokployVersion}{" "}
{(releaseTag === "canary" || releaseTag === "feature") &&
`(${releaseTag})`}
</span>
</div>
)}

View File

@@ -88,6 +88,35 @@ export const DiscordIcon = ({ className }: Props) => {
</svg>
);
};
export const TeamsIcon = ({ className }: Props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="26"
height="36"
viewBox="0 0 512 476"
className={cn("size-9", className)}
>
<g>
<rect x="116" y="50" width="280" height="276" rx="64" fill="#6264A7" />
<rect x="236" y="138" width="180" height="224" rx="60" fill="#5059C9" />
<circle cx="122" cy="332" r="80" fill="#B2B4D3" />
<circle cx="370" cy="364" r="64" fill="#A6A7DC" />
<text
x="180"
y="270"
fill="#fff"
font-family="Segoe UI, Arial, sans-serif"
font-size="110"
font-weight="bold"
>
T
</text>
</g>
</svg>
);
};
export const LarkIcon = ({ className }: Props) => {
return (
<svg

View File

@@ -404,8 +404,7 @@ const MENU: Menu = {
url: "/dashboard/settings/license",
icon: Key,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -631,135 +630,137 @@ function SidebarLogo() {
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="rounded-lg"
className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
<DropdownMenuLabel className="text-xs text-muted-foreground shrink-0">
Organizations
</DropdownMenuLabel>
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<div
className="flex flex-row justify-between"
key={org.name}
>
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="w-full gap-2 p-2"
<div className="overflow-y-auto overflow-x-hidden min-h-0 -mx-1 px-1">
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<div
className="flex flex-row justify-between"
key={org.name}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{org.name}
</div>
</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
</DropdownMenuItem>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success("Default organization updated");
})
.catch((error) => {
toast.error(
error?.message ||
"Error setting default organization",
);
});
});
window.location.reload();
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
className="w-full gap-2 p-2"
>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{org.name}
</div>
</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
</div>
</DropdownMenuItem>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success("Default organization updated");
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
.catch((error) => {
toast.error(
error?.message ||
"Error setting default organization",
);
});
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
</div>
</div>
</div>
);
})}
);
})}
</div>
{(user?.role === "owner" ||
user?.role === "admin" ||
isCloud) && (

View File

@@ -10,18 +10,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { authClient } from "@/lib/auth-client";
import { Languages } from "@/lib/languages";
import { getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import useLocale from "@/utils/hooks/use-locale";
import { ModeToggle } from "../ui/modeToggle";
import { SidebarMenuButton } from "../ui/sidebar";
@@ -32,7 +23,6 @@ export const UserNav = () => {
const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { locale, setLocale } = useLocale();
// const { mutateAsync } = api.auth.logout.useMutation();
return (
@@ -155,39 +145,19 @@ export const UserNav = () => {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<div className="flex items-center justify-between px-2 py-1.5">
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out
</DropdownMenuItem>
<div className="w-32">
<Select
onValueChange={setLocale}
defaultValue={locale}
value={locale}
>
<SelectTrigger>
<SelectValue placeholder="Select Language" />
</SelectTrigger>
<SelectContent>
{Object.values(Languages).map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -166,7 +166,12 @@ export function LicenseKeySettings() {
{!haveValidLicenseKey && (
<Button
variant="secondary"
disabled={isSaving || isValidating || isDeactivating}
disabled={
isSaving ||
isValidating ||
isDeactivating ||
!licenseKey.trim()
}
isLoading={isActivating}
onClick={async () => {
try {

View File

@@ -2,9 +2,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { FieldArrayPath } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { useFieldArray, useForm, useWatch } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,6 +28,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const DEFAULT_SCOPES = ["openid", "email", "profile"];
@@ -58,6 +59,7 @@ const oidcProviderSchema = z.object({
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
interface RegisterOidcDialogProps {
providerId?: string;
children: React.ReactNode;
}
@@ -70,16 +72,86 @@ const formDefaultValues = {
scopes: [...DEFAULT_SCOPES],
};
export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
function parseOidcConfig(oidcConfig: string | null): {
clientId?: string;
clientSecret?: string;
scopes?: string[];
} | null {
if (!oidcConfig) return null;
try {
const parsed = JSON.parse(oidcConfig) as {
clientId?: string;
clientSecret?: string;
scopes?: string[];
};
return {
clientId: parsed.clientId,
clientSecret: parsed.clientSecret,
scopes: Array.isArray(parsed.scopes) ? parsed.scopes : undefined,
};
} catch {
return null;
}
}
export function RegisterOidcDialog({
providerId,
children,
}: RegisterOidcDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const { data } = api.sso.one.useQuery(
{ providerId: providerId ?? "" },
{ enabled: !!providerId && open },
);
const registerMutation = api.sso.register.useMutation();
const updateMutation = api.sso.update.useMutation();
const isEdit = !!providerId;
const mutateAsync = isEdit
? updateMutation.mutateAsync
: registerMutation.mutateAsync;
const isLoading = isEdit
? updateMutation.isLoading
: registerMutation.isLoading;
const form = useForm<OidcProviderForm>({
resolver: zodResolver(oidcProviderSchema),
defaultValues: formDefaultValues,
});
const watchedProviderId = useWatch({
control: form.control,
name: "providerId",
defaultValue: "",
});
const baseURL = useUrl();
useEffect(() => {
if (!data || !open) return;
const domains = data.domain
? data.domain
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [""];
if (domains.length === 0) domains.push("");
const oidc = parseOidcConfig(data.oidcConfig);
form.reset({
providerId: data.providerId,
issuer: data.issuer,
domains,
clientId: oidc?.clientId ?? "",
clientSecret: oidc?.clientSecret ?? "",
scopes:
oidc?.scopes && oidc.scopes.length > 0
? oidc.scopes
: [...DEFAULT_SCOPES],
});
}, [data, open, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<OidcProviderForm>,
@@ -130,7 +202,11 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
},
});
toast.success("OIDC provider registered successfully");
toast.success(
isEdit
? "OIDC provider updated successfully"
: "OIDC provider registered successfully",
);
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
@@ -146,11 +222,13 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Register OIDC provider</DialogTitle>
<DialogTitle>
{isEdit ? "Update OIDC provider" : "Register OIDC provider"}
</DialogTitle>
<DialogDescription>
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
from the issuer URL when possible.
{isEdit
? "Change issuer, domains, client settings or scopes. Provider ID cannot be changed."
: "Add any OIDC-compliant identity provider (e.g. Okta, Azure AD, Google Workspace, Auth0, Keycloak). Discovery will fill endpoints from the issuer URL when possible."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -162,11 +240,28 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input placeholder="e.g. okta or my-idp" {...field} />
<Input
placeholder="e.g. okta or my-idp"
{...field}
readOnly={isEdit}
className={isEdit ? "bg-muted" : undefined}
/>
</FormControl>
<FormDescription>
Unique identifier; used in callback URL path.
{isEdit && " Cannot be changed when editing."}
</FormDescription>
{baseURL && (
<div className="rounded-md bg-muted px-3 py-2 text-xs">
<p className="font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</p>
<p className="mt-0.5 break-all font-mono">
{baseURL}/api/auth/sso/callback/
{watchedProviderId?.trim() || "..."}
</p>
</div>
)}
<FormMessage />
</FormItem>
)}
@@ -341,7 +436,7 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
Register provider
{isEdit ? "Update provider" : "Register provider"}
</Button>
</DialogFooter>
</form>

View File

@@ -3,7 +3,12 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
import {
type FieldArrayPath,
useFieldArray,
useForm,
useWatch,
} from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,6 +33,7 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const domainsArraySchema = z
.array(z.string().trim())
@@ -58,6 +64,7 @@ const samlProviderSchema = z.object({
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
interface RegisterSamlDialogProps {
providerId?: string;
children: React.ReactNode;
}
@@ -70,24 +77,83 @@ const formDefaultValues: SamlProviderForm = {
idpMetadataXml: "",
};
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
function parseSamlConfig(samlConfig: string | null): {
entryPoint?: string;
cert?: string;
idpMetadataXml?: string;
} | null {
if (!samlConfig) return null;
try {
const parsed = JSON.parse(samlConfig) as {
entryPoint?: string;
cert?: string;
idpMetadata?: { metadata?: string };
};
return {
entryPoint: parsed.entryPoint,
cert: parsed.cert,
idpMetadataXml: parsed.idpMetadata?.metadata,
};
} catch {
return null;
}
}
export function RegisterSamlDialog({
providerId,
children,
}: RegisterSamlDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const [baseURL, setBaseURL] = useState("");
const { data } = api.sso.one.useQuery(
{ providerId: providerId ?? "" },
{ enabled: !!providerId && open },
);
const registerMutation = api.sso.register.useMutation();
const updateMutation = api.sso.update.useMutation();
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const isEdit = !!providerId;
const mutateAsync = isEdit
? updateMutation.mutateAsync
: registerMutation.mutateAsync;
const isLoading = isEdit
? updateMutation.isLoading
: registerMutation.isLoading;
const baseURL = useUrl();
const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues,
});
useEffect(() => {
if (!data || !open) return;
const domains = data.domain
? data.domain
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [""];
if (domains.length === 0) domains.push("");
const saml = parseSamlConfig(data.samlConfig);
form.reset({
providerId: data.providerId,
issuer: data.issuer,
domains,
entryPoint: saml?.entryPoint ?? "",
cert: saml?.cert ?? "",
idpMetadataXml: saml?.idpMetadataXml ?? "",
});
}, [data, open, form]);
const watchedProviderId = useWatch({
control: form.control,
name: "providerId",
defaultValue: "",
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<SamlProviderForm>,
@@ -133,7 +199,11 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
},
});
toast.success("SAML provider registered successfully");
toast.success(
isEdit
? "SAML provider updated successfully"
: "SAML provider registered successfully",
);
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
@@ -149,10 +219,13 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Register SAML provider</DialogTitle>
<DialogTitle>
{isEdit ? "Update SAML provider" : "Register SAML provider"}
</DialogTitle>
<DialogDescription>
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
OneLogin). You need the IdP&apos;s SSO URL and signing certificate.
{isEdit
? "Change issuer, domains, entry point or certificate. Provider ID cannot be changed."
: "Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML, OneLogin). You need the IdP's SSO URL and signing certificate."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -167,8 +240,26 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
<Input
placeholder="e.g. okta-saml or azure-saml"
{...field}
readOnly={isEdit}
className={isEdit ? "bg-muted" : undefined}
/>
</FormControl>
{isEdit && (
<FormDescription>
Cannot be changed when editing.
</FormDescription>
)}
{baseURL && (
<div className="rounded-md bg-muted px-3 py-2 text-xs">
<p className="font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</p>
<p className="mt-0.5 break-all font-mono">
{baseURL}/api/auth/sso/saml2/callback/
{watchedProviderId?.trim() || "..."}
</p>
</div>
)}
<FormMessage />
</FormItem>
)}
@@ -317,7 +408,7 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
Register provider
{isEdit ? "Update provider" : "Register provider"}
</Button>
</DialogFooter>
</form>

View File

@@ -1,7 +1,15 @@
"use client";
import { Eye, Loader2, LogIn, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import {
Eye,
Loader2,
LogIn,
Pencil,
Plus,
Shield,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -21,7 +29,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
@@ -67,29 +77,108 @@ export const SSOSettings = () => {
const utils = api.useUtils();
const [detailsProvider, setDetailsProvider] =
useState<ProviderForDetails | null>(null);
const [baseURL, setBaseURL] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const baseURL = useUrl();
const [manageOriginsOpen, setManageOriginsOpen] = useState(false);
const [editingOrigin, setEditingOrigin] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState("");
const [newOriginInput, setNewOriginInput] = useState("");
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
const { data: userData } = api.user.get.useQuery(undefined, {
enabled: manageOriginsOpen,
});
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
api.sso.deleteProvider.useMutation();
const { mutateAsync: addTrustedOrigin, isLoading: isAddingOrigin } =
api.sso.addTrustedOrigin.useMutation();
const { mutateAsync: removeTrustedOrigin, isLoading: isRemovingOrigin } =
api.sso.removeTrustedOrigin.useMutation();
const { mutateAsync: updateTrustedOrigin, isLoading: isUpdatingOrigin } =
api.sso.updateTrustedOrigin.useMutation();
const trustedOrigins = userData?.user?.trustedOrigins ?? [];
const handleAddOrigin = async () => {
const value = newOriginInput.trim();
if (!value) return;
try {
await addTrustedOrigin({ origin: value });
toast.success("Trusted origin added");
setNewOriginInput("");
await utils.user.get.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to add trusted origin",
);
}
};
const handleRemoveOrigin = async (origin: string) => {
try {
await removeTrustedOrigin({ origin });
toast.success("Trusted origin removed");
if (editingOrigin === origin) setEditingOrigin(null);
await utils.user.get.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to remove trusted origin",
);
}
};
const handleStartEdit = (origin: string) => {
setEditingOrigin(origin);
setEditingValue(origin);
};
const handleSaveEdit = async () => {
if (editingOrigin == null || !editingValue.trim()) {
setEditingOrigin(null);
return;
}
try {
await updateTrustedOrigin({
oldOrigin: editingOrigin,
newOrigin: editingValue.trim(),
});
toast.success("Trusted origin updated");
setEditingOrigin(null);
setEditingValue("");
await utils.user.get.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to update trusted origin",
);
}
};
const handleCancelEdit = () => {
setEditingOrigin(null);
setEditingValue("");
};
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<LogIn className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<LogIn className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
</div>
<CardDescription>
Configure OIDC or SAML identity providers for enterprise sign-in.
Users can sign in with their organization&apos;s IdP.
</CardDescription>
</div>
<CardDescription>
Configure OIDC or SAML identity providers for enterprise sign-in.
Users can sign in with their organization&apos;s IdP.
</CardDescription>
<Button
variant="outline"
size="sm"
onClick={() => setManageOriginsOpen(true)}
className="shrink-0"
>
<Shield className="mr-2 size-4" />
Manage origins
</Button>
</div>
{isLoading ? (
@@ -177,6 +266,22 @@ export const SSOSettings = () => {
<Eye className="mr-1 size-3" />
View details
</Button>
{isOidc && (
<RegisterOidcDialog providerId={provider.providerId}>
<Button variant="ghost" size="sm">
<Pencil className="mr-1 size-3" />
Edit
</Button>
</RegisterOidcDialog>
)}
{isSaml && (
<RegisterSamlDialog providerId={provider.providerId}>
<Button variant="ghost" size="sm">
<Pencil className="mr-1 size-3" />
Edit
</Button>
</RegisterSamlDialog>
)}
<DialogAction
title="Remove SSO provider"
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
@@ -256,8 +361,7 @@ export const SSOSettings = () => {
<DialogHeader>
<DialogTitle>SSO provider details</DialogTitle>
<DialogDescription>
View-only. To change settings, remove this provider and add it
again with the new values.
Use Edit to change provider settings (OIDC or SAML).
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
@@ -366,6 +470,128 @@ export const SSOSettings = () => {
)}
</DialogContent>
</Dialog>
<Dialog open={manageOriginsOpen} onOpenChange={setManageOriginsOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="size-5" />
Trusted origins
</DialogTitle>
<DialogDescription>
Manage allowed origins for SSO callbacks. Add, edit, or remove
origins for your account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<span className="text-sm font-medium">Current origins</span>
{trustedOrigins.length === 0 ? (
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
No trusted origins yet. Add one below.
</p>
) : (
<ul className="flex flex-col gap-2">
{trustedOrigins.map((origin) => (
<li
key={origin}
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
>
{editingOrigin === origin ? (
<>
<Input
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
placeholder="https://..."
className="flex-1 font-mono text-sm"
autoFocus
/>
<Button
size="sm"
onClick={handleSaveEdit}
disabled={!editingValue.trim() || isUpdatingOrigin}
>
Save
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCancelEdit}
>
Cancel
</Button>
</>
) : (
<>
<span className="flex-1 break-all font-mono text-sm">
{origin}
</span>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={() => handleStartEdit(origin)}
>
<Pencil className="size-3.5" />
</Button>
<DialogAction
title="Remove trusted origin"
description={`Remove "${origin}" from trusted origins?`}
type="destructive"
onClick={async () => handleRemoveOrigin(origin)}
>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-destructive hover:text-destructive"
disabled={isRemovingOrigin}
>
<Trash2 className="size-3.5" />
</Button>
</DialogAction>
</>
)}
</li>
))}
</ul>
)}
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Add trusted origin</span>
<div className="flex gap-2">
<Input
value={newOriginInput}
onChange={(e) => setNewOriginInput(e.target.value)}
placeholder="https://example.com"
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleAddOrigin();
}
}}
/>
<Button
size="sm"
onClick={handleAddOrigin}
disabled={!newOriginInput.trim() || isAddingOrigin}
>
<Plus className="mr-1 size-4" />
Add
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setManageOriginsOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,6 @@
ALTER TABLE "application" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "ulimitsSwarm" json;

View File

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

View File

@@ -0,0 +1,8 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'teams';--> statement-breakpoint
CREATE TABLE "teams" (
"teamsId" text PRIMARY KEY NOT NULL,
"webhookUrl" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "teamsId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_teamsId_teams_teamsId_fk" FOREIGN KEY ("teamsId") REFERENCES "public"."teams"("teamsId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -995,6 +995,27 @@
"when": 1770490719123,
"tag": "0141_plain_earthquake",
"breakpoints": true
},
{
"idx": 142,
"version": "7",
"when": 1770615019498,
"tag": "0142_outstanding_tusk",
"breakpoints": true
},
{
"idx": 143,
"version": "7",
"when": 1770961667210,
"tag": "0143_brown_ultron",
"breakpoints": true
},
{
"idx": 144,
"version": "7",
"when": 1771297084611,
"tag": "0144_odd_gunslinger",
"breakpoints": true
}
]
}

View File

@@ -1,29 +0,0 @@
/**
* Sorted list based off of population of the country / speakers of the language.
*/
export const Languages = {
english: { code: "en", name: "English" },
spanish: { code: "es", name: "Español" },
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
portuguese: { code: "pt-br", name: "Português" },
russian: { code: "ru", name: "Русский" },
japanese: { code: "ja", name: "日本語" },
german: { code: "de", name: "Deutsch" },
korean: { code: "ko", name: "한국어" },
french: { code: "fr", name: "Français" },
turkish: { code: "tr", name: "Türkçe" },
italian: { code: "it", name: "Italiano" },
polish: { code: "pl", name: "Polski" },
ukrainian: { code: "uk", name: "Українська" },
persian: { code: "fa", name: "فارسی" },
dutch: { code: "nl", name: "Nederlands" },
indonesian: { code: "id", name: "Bahasa Indonesia" },
kazakh: { code: "kz", name: "Қазақ" },
norwegian: { code: "no", name: "Norsk" },
azerbaijani: { code: "az", name: "Azərbaycan" },
malayalam: { code: "ml", name: "മലയാളം" },
};
export type Language = keyof typeof Languages;
export type LanguageCode = (typeof Languages)[keyof typeof Languages]["code"];

View File

@@ -10,15 +10,6 @@ const nextConfig = {
ignoreBuildErrors: true,
},
transpilePackages: ["@dokploy/server"],
/**
* If you are using `appDir` then you must comment the below `i18n` config out.
*
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ["en"],
defaultLocale: "en",
},
async headers() {
return [
{

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.27.0",
"version": "v0.27.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -41,13 +41,13 @@
"dependencies": {
"resend": "^6.0.2",
"@better-auth/sso": "1.4.18",
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/cohere": "^2.0.4",
"@ai-sdk/deepinfra": "^1.0.10",
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/deepinfra": "^2.0.34",
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
@@ -95,8 +95,8 @@
"@xterm/addon-clipboard": "0.1.0",
"@xterm/xterm": "^5.5.0",
"adm-zip": "^0.5.16",
"ai": "^5.0.17",
"ai-sdk-ollama": "^0.5.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.4.18",
"bl": "6.0.11",
@@ -113,7 +113,6 @@
"drizzle-orm": "^0.41.0",
"drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"lodash": "4.17.21",
@@ -121,7 +120,6 @@
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.1.6",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
@@ -139,7 +137,6 @@
"react-day-picker": "8.10.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.56.4",
"react-i18next": "^15.5.2",
"react-markdown": "^9.1.0",
"recharts": "^2.15.3",
"slugify": "^1.6.6",
@@ -147,7 +144,7 @@
"ssh2": "1.15.0",
"stripe": "17.2.0",
"superjson": "^2.2.2",
"swagger-ui-react": "^5.22.0",
"swagger-ui-react": "^5.31.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"toml": "3.0.0",
@@ -156,7 +153,7 @@
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^3.25.32",
"zod": "^3.25.76",
"zod-form-data": "^2.0.7",
"semver": "7.7.3"
},

View File

@@ -4,13 +4,11 @@ import type { NextPage } from "next";
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import { appWithTranslation } from "next-i18next";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { Toaster } from "@/components/ui/sonner";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
const inter = Inter({ subsets: ["latin"] });
@@ -58,14 +56,4 @@ const MyApp = ({
);
};
export default api.withTRPC(
appWithTranslation(MyApp, {
i18n: {
defaultLocale: "en",
locales: Object.values(Languages).map((language) => language.code),
localeDetection: false,
},
fallbackLng: "en",
keySeparator: false,
}),
);
export default api.withTRPC(MyApp);

View File

@@ -6,7 +6,6 @@ import superjson from "superjson";
import { AiForm } from "@/components/dashboard/settings/ai-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -26,7 +25,6 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
const locale = getLocale(req.cookies);
const helpers = createServerSideHelpers({
router: appRouter,
@@ -55,7 +53,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -1,4 +1,4 @@
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
@@ -7,7 +7,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { LicenseKeySettings } from "@/components/proprietary/license-keys/license-key";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -35,7 +34,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
@@ -45,7 +43,7 @@ export async function getServerSideProps(
},
};
}
if (user.role === "member") {
if (user.role !== "owner") {
return {
redirect: {
permanent: true,
@@ -70,7 +68,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -4,25 +4,24 @@ import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account";
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
// const { data: isCloud } = api.settings.isCloud.useQuery();
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<ProfileForm />
{isCloud && <LinkingAccount />}
{(data?.canAccessToAPI ||
data?.role === "owner" ||
data?.role === "admin") && <ShowApiKeys />}
{/* {isCloud && <RemoveSelfAccount />} */}
</div>
</div>
);
@@ -37,7 +36,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = getLocale(req.cookies);
const { user, session } = await validateRequest(req);
const helpers = createServerSideHelpers({
@@ -67,7 +65,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -10,7 +10,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
const { data: user } = api.user.get.useQuery();
@@ -42,7 +41,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
if (IS_CLOUD) {
return {
redirect: {
@@ -85,7 +83,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -6,7 +6,6 @@ import superjson from "superjson";
import { ShowServers } from "@/components/dashboard/settings/servers/show-servers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -25,7 +24,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(req);
if (!user) {
return {
@@ -61,7 +59,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -8,7 +8,6 @@ import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-featu
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -43,7 +42,6 @@ Page.getLayout = (page: ReactElement) => {
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
@@ -78,7 +76,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -8,6 +8,7 @@ import {
createPushoverNotification,
createResendNotification,
createSlackNotification,
createTeamsNotification,
createTelegramNotification,
findNotificationById,
getWebServerSettings,
@@ -23,6 +24,7 @@ import {
sendResendNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
updateCustomNotification,
updateDiscordNotification,
@@ -33,6 +35,7 @@ import {
updatePushoverNotification,
updateResendNotification,
updateSlackNotification,
updateTeamsNotification,
updateTelegramNotification,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -55,6 +58,7 @@ import {
apiCreatePushover,
apiCreateResend,
apiCreateSlack,
apiCreateTeams,
apiCreateTelegram,
apiFindOneNotification,
apiTestCustomConnection,
@@ -66,6 +70,7 @@ import {
apiTestPushoverConnection,
apiTestResendConnection,
apiTestSlackConnection,
apiTestTeamsConnection,
apiTestTelegramConnection,
apiUpdateCustom,
apiUpdateDiscord,
@@ -76,6 +81,7 @@ import {
apiUpdatePushover,
apiUpdateResend,
apiUpdateSlack,
apiUpdateTeams,
apiUpdateTelegram,
notifications,
server,
@@ -413,6 +419,7 @@ export const notificationRouter = createTRPCRouter({
custom: true,
lark: true,
pushover: true,
teams: true,
},
orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -705,6 +712,61 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createTeams: adminProcedure
.input(apiCreateTeams)
.mutation(async ({ input, ctx }) => {
try {
return await createTeamsNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateTeams: adminProcedure
.input(apiUpdateTeams)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateTeamsNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw error;
}
}),
testTeamsConnection: adminProcedure
.input(apiTestTeamsConnection)
.mutation(async ({ input }) => {
try {
await sendTeamsNotification(input, {
title: "🤚 Test Notification",
facts: [{ name: "Message", value: "Hi, From Dokploy 👋" }],
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
createPushover: adminProcedure
.input(apiCreatePushover)
.mutation(async ({ input, ctx }) => {

View File

@@ -12,7 +12,7 @@ import {
export const licenseKeyRouter = createTRPCRouter({
activate: adminProcedure
.input(z.object({ licenseKey: z.string() }))
.input(z.object({ licenseKey: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
try {
const currentUserId = ctx.user.id;
@@ -74,6 +74,13 @@ export const licenseKeyRouter = createTRPCRouter({
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to validate a license key",
});
}
if (!currentUser.licenseKey) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -164,6 +171,13 @@ export const licenseKeyRouter = createTRPCRouter({
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to get enterprise settings",
});
}
return {
enableEnterpriseFeatures: !!currentUser.enableEnterpriseFeatures,
licenseKey: currentUser.licenseKey ?? "",
@@ -200,6 +214,13 @@ export const licenseKeyRouter = createTRPCRouter({
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to update enterprise settings",
});
}
await db
.update(user)
.set({

View File

@@ -55,9 +55,128 @@ export const ssoRouter = createTRPCRouter({
samlConfig: true,
organizationId: true,
},
orderBy: [asc(ssoProvider.createdAt)],
});
return providers;
}),
one: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const provider = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
providerId: true,
issuer: true,
domain: true,
oidcConfig: true,
samlConfig: true,
organizationId: true,
},
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to access it",
});
}
return provider;
}),
update: enterpriseProcedure
.input(ssoProviderBodySchema)
.mutation(async ({ ctx, input }) => {
const existing = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
issuer: true,
domain: true,
},
});
if (!existing) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to update it",
});
}
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: { providerId: true, domain: true },
});
for (const provider of providers) {
if (provider.providerId === input.providerId) continue;
const providerDomains = provider.domain
.split(",")
.map((d) => d.trim().toLowerCase());
for (const domain of input.domains) {
if (providerDomains.includes(domain)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Domain ${domain} is already registered for another provider`,
});
}
}
}
const issuerChanged =
normalizeTrustedOrigin(existing.issuer) !==
normalizeTrustedOrigin(input.issuer);
if (issuerChanged) {
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: { trustedOrigins: true },
});
const trustedOrigins = currentUser?.trustedOrigins ?? [];
const newOrigin = normalizeTrustedOrigin(input.issuer);
const isInTrustedOrigins = trustedOrigins.some(
(o) => o.toLowerCase() === newOrigin.toLowerCase(),
);
if (!isInTrustedOrigins) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"The new Issuer URL is not in your trusted origins list. Please add it in Manage origins before saving.",
});
}
}
const domain = input.domains.join(",");
const updateBody: {
issuer: string;
domain: string;
oidcConfig?: (typeof input)["oidcConfig"];
samlConfig?: (typeof input)["samlConfig"];
} = {
issuer: input.issuer,
domain,
};
if (input.oidcConfig != null) {
updateBody.oidcConfig = input.oidcConfig;
}
if (input.samlConfig != null) {
updateBody.samlConfig = input.samlConfig;
}
await auth.updateSSOProvider({
params: { providerId: input.providerId },
body: updateBody,
headers: requestToHeaders(ctx.req),
});
return { success: true };
}),
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
@@ -102,24 +221,6 @@ export const ssoRouter = createTRPCRouter({
});
}
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
if (currentUser?.trustedOrigins) {
const issuerOrigin = normalizeTrustedOrigin(providerToDelete.issuer);
const updatedOrigins = currentUser.trustedOrigins.filter(
(origin) => origin.toLowerCase() !== issuerOrigin.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: updatedOrigins })
.where(eq(user.id, ctx.session.userId));
}
return { success: true };
}),
register: enterpriseProcedure
@@ -147,25 +248,6 @@ export const ssoRouter = createTRPCRouter({
}
}
const domain = input.domains.join(",");
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
const existingOrigins = currentUser?.trustedOrigins || [];
const issuerOrigin = normalizeTrustedOrigin(input.issuer);
const newOrigins = Array.from(
new Set([...existingOrigins, issuerOrigin]),
);
await db
.update(user)
.set({ trustedOrigins: newOrigins })
.where(eq(user.id, ctx.session.userId));
await auth.registerSSOProvider({
body: {
@@ -177,4 +259,65 @@ export const ssoRouter = createTRPCRouter({
});
return { success: true };
}),
addTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const normalized = normalizeTrustedOrigin(input.origin);
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: { trustedOrigins: true },
});
const existing = currentUser?.trustedOrigins || [];
if (existing.some((o) => o.toLowerCase() === normalized.toLowerCase())) {
return { success: true };
}
const next = Array.from(new Set([...existing, normalized]));
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ctx.session.userId));
return { success: true };
}),
removeTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const normalized = normalizeTrustedOrigin(input.origin);
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: { trustedOrigins: true },
});
const existing = currentUser?.trustedOrigins || [];
const next = existing.filter(
(o) => o.toLowerCase() !== normalized.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ctx.session.userId));
return { success: true };
}),
updateTrustedOrigin: enterpriseProcedure
.input(
z.object({
oldOrigin: z.string().min(1),
newOrigin: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const oldNorm = normalizeTrustedOrigin(input.oldOrigin);
const newNorm = normalizeTrustedOrigin(input.newOrigin);
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: { trustedOrigins: true },
});
const existing = currentUser?.trustedOrigins || [];
const next = existing.map((o) =>
o.toLowerCase() === oldNorm.toLowerCase() ? newNorm : o,
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ctx.session.userId));
return { success: true };
}),
});

View File

@@ -7,7 +7,12 @@ import {
import { TRPCError } from "@trpc/server";
import Stripe from "stripe";
import { z } from "zod";
import { getStripeItems, WEBSITE_URL } from "@/server/utils/stripe";
import {
getStripeItems,
PRODUCT_ANNUAL_ID,
PRODUCT_MONTHLY_ID,
WEBSITE_URL,
} from "@/server/utils/stripe";
import { adminProcedure, createTRPCRouter } from "../trpc";
export const stripeRouter = createTRPCRouter({
@@ -24,9 +29,15 @@ export const stripeRouter = createTRPCRouter({
active: true,
});
const filteredProducts = products.data.filter((product) => {
return (
product.id === PRODUCT_MONTHLY_ID || product.id === PRODUCT_ANNUAL_ID
);
});
if (!stripeCustomerId) {
return {
products: products.data,
products: filteredProducts,
subscriptions: [],
};
}
@@ -38,7 +49,7 @@ export const stripeRouter = createTRPCRouter({
});
return {
products: products.data,
products: filteredProducts,
subscriptions: subscriptions.data,
};
}),

View File

@@ -2,6 +2,7 @@ import {
deployApplication,
deployCompose,
deployPreviewApplication,
IS_CLOUD,
rebuildApplication,
rebuildCompose,
rebuildPreviewApplication,
@@ -13,70 +14,83 @@ import { type Job, Worker } from "bullmq";
import type { DeploymentJob } from "./queue-types";
import { redisConfig } from "./redis-connection";
export const deploymentWorker = new Worker(
"deployments",
async (job: Job<DeploymentJob>) => {
try {
if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "running");
const createDeploymentWorker = () =>
new Worker(
"deployments",
async (job: Job<DeploymentJob>) => {
try {
if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "running");
if (job.data.type === "redeploy") {
await rebuildApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
if (job.data.type === "redeploy") {
await rebuildApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "deploy") {
await deployApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else if (job.data.applicationType === "compose") {
await updateCompose(job.data.composeId, {
composeStatus: "running",
});
} else if (job.data.type === "deploy") {
await deployApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
if (job.data.type === "deploy") {
await deployCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "redeploy") {
await rebuildCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else if (job.data.applicationType === "application-preview") {
await updatePreviewDeployment(job.data.previewDeploymentId, {
previewStatus: "running",
});
}
} else if (job.data.applicationType === "compose") {
await updateCompose(job.data.composeId, {
composeStatus: "running",
});
if (job.data.type === "deploy") {
await deployCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "redeploy") {
await rebuildCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else if (job.data.applicationType === "application-preview") {
await updatePreviewDeployment(job.data.previewDeploymentId, {
previewStatus: "running",
});
if (job.data.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
} else if (job.data.type === "deploy") {
await deployPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
if (job.data.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
} else if (job.data.type === "deploy") {
await deployPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
}
}
} catch (error) {
console.log("Error", error);
}
} catch (error) {
console.log("Error", error);
}
},
{
autorun: false,
connection: redisConfig,
},
);
},
{
autorun: false,
connection: redisConfig,
},
);
/** No-op worker when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
const noopWorker = {
run: () => Promise.resolve(),
close: () => Promise.resolve(),
cancelJob: () => Promise.resolve(),
cancelAllJobs: () => Promise.resolve(),
};
export const deploymentWorker = !IS_CLOUD
? createDeploymentWorker()
: (noopWorker as unknown as Worker<DeploymentJob>);

View File

@@ -1,15 +1,26 @@
import { IS_CLOUD } from "@dokploy/server";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import type { Job } from "bullmq";
import { Queue } from "bullmq";
import { deploymentWorker } from "./deployments-queue";
import { redisConfig } from "./redis-connection";
const myQueue = new Queue("deployments", {
connection: redisConfig,
/** No-op queue when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
const createNoopQueue = () => ({
getJobs: () => Promise.resolve([] as Job[]),
add: () =>
Promise.resolve({ id: "noop", remove: () => Promise.resolve() } as Job),
close: () => Promise.resolve(),
on: () => {},
});
const myQueue = !IS_CLOUD
? new Queue("deployments", { connection: redisConfig })
: (createNoopQueue() as unknown as Queue);
export const getJobsByApplicationId = async (applicationId: string) => {
const jobs = await myQueue.getJobs();
return jobs.filter((job) => job?.data?.applicationId === applicationId);
@@ -20,19 +31,21 @@ export const getJobsByComposeId = async (composeId: string) => {
return jobs.filter((job) => job?.data?.composeId === composeId);
};
process.on("SIGTERM", () => {
myQueue.close();
process.exit(0);
});
if (!IS_CLOUD) {
process.on("SIGTERM", () => {
myQueue.close();
process.exit(0);
});
myQueue.on("error", (error) => {
if ((error as any).code === "ECONNREFUSED") {
console.error(
"Make sure you have installed Redis and it is running.",
error,
);
}
});
myQueue.on("error", (error) => {
if ((error as any).code === "ECONNREFUSED") {
console.error(
"Make sure you have installed Redis and it is running.",
error,
);
}
});
}
export const cleanQueuesByApplication = async (applicationId: string) => {
const jobs = await myQueue.getJobs(["waiting", "delayed"]);

View File

@@ -1,5 +1,20 @@
import { getPublicIpWithFallback, LICENSE_KEY_URL } from "@dokploy/server";
const LICENSE_SERVER_UNREACHABLE =
"Could not reach the license server. Check your connection or try again later.";
function isNetworkError(error: unknown): boolean {
if (error instanceof Error) {
if (error.message === "fetch failed") return true;
const cause = (error as Error & { cause?: { code?: string } }).cause;
const code = cause?.code;
return (
code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT"
);
}
return false;
}
export const validateLicenseKey = async (licenseKey: string) => {
try {
const ip = await getPublicIpWithFallback();
@@ -22,6 +37,9 @@ export const validateLicenseKey = async (licenseKey: string) => {
console.error(
error instanceof Error ? error.message : "Failed to validate license key",
);
if (isNetworkError(error)) {
throw new Error(LICENSE_SERVER_UNREACHABLE);
}
throw error;
}
};
@@ -48,6 +66,9 @@ export const activateLicenseKey = async (licenseKey: string) => {
console.error(
error instanceof Error ? error.message : "Failed to activate license key",
);
if (isNetworkError(error)) {
throw new Error(LICENSE_SERVER_UNREACHABLE);
}
throw error;
}
};
@@ -76,6 +97,9 @@ export const deactivateLicenseKey = async (licenseKey: string) => {
? error.message
: "Failed to deactivate license key",
);
if (isNetworkError(error)) {
throw new Error(LICENSE_SERVER_UNREACHABLE);
}
throw error;
}
};

View File

@@ -3,9 +3,12 @@ export const WEBSITE_URL =
? "http://localhost:3000"
: process.env.SITE_URL;
const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!; // $4.00
export const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!; // $4.00
const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!; // $7.99
export const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!; // $7.99
export const PRODUCT_MONTHLY_ID = process.env.PRODUCT_MONTHLY_ID!;
export const PRODUCT_ANNUAL_ID = process.env.PRODUCT_ANNUAL_ID!;
export const getStripeItems = (serverQuantity: number, isAnnual: boolean) => {
const items = [];

View File

@@ -39,8 +39,7 @@
"**/*.js",
".next/types/**/*.ts",
"env.js",
"next.config.mjs",
"next-i18next.config.mjs"
"next.config.mjs"
],
"exclude": [
"node_modules",

View File

@@ -1,16 +0,0 @@
import Cookies from "js-cookie";
import type { LanguageCode } from "@/lib/languages";
export default function useLocale() {
const currentLocale = (Cookies.get("DOKPLOY_LOCALE") ?? "en") as LanguageCode;
const setLocale = (locale: LanguageCode) => {
Cookies.set("DOKPLOY_LOCALE", locale, { expires: 365 });
window.location.reload();
};
return {
locale: currentLocale,
setLocale,
};
}

View File

@@ -1,23 +0,0 @@
import type { NextApiRequestCookies } from "next/dist/server/api-utils";
export function getLocale(cookies: NextApiRequestCookies) {
const locale = cookies.DOKPLOY_LOCALE ?? "en";
return locale;
}
import { serverSideTranslations as originalServerSideTranslations } from "next-i18next/serverSideTranslations";
import { Languages } from "@/lib/languages";
export const serverSideTranslations = (
locale: string,
namespaces = ["common"],
) =>
originalServerSideTranslations(locale, namespaces, {
fallbackLng: "en",
keySeparator: false,
i18n: {
defaultLocale: "en",
locales: Object.values(Languages).map((language) => language.code),
localeDetection: false,
},
});

View File

@@ -20,7 +20,7 @@
"pino-pretty": "11.2.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"zod": "^3.25.32"
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^20.16.0",

View File

@@ -43,5 +43,10 @@
"resolutions": {
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0"
},
"pnpm": {
"overrides": {
"esbuild": "0.20.2"
}
}
}

View File

@@ -0,0 +1,27 @@
# Debug build OOM orden para probar
Ejecuta desde `packages/server` (o `pnpm --filter=@dokploy/server run <script>` desde la raíz).
1. **`pnpm run build:debug:noEmit`**
Solo typecheck, no escribe archivos.
- Si hace **OOM** → el problema es el análisis de tipos (ej. zod u otras libs).
- Si **pasa** → el problema está en emit (JS o `.d.ts`).
2. **`pnpm run build:debug:noEmit:8gb`**
Mismo que el anterior pero con 8GB de heap.
- Si con 8GB **pasa** y sin 8GB **no** → el typecheck necesita más memoria.
3. **`pnpm run build:debug:noDecl`**
Compila solo JS (sin `declaration`).
- Si hace **OOM** → el problema es emitir JS.
- Si **pasa** → el problema es generar `.d.ts`.
4. **`pnpm run build:debug:declOnly`**
Solo genera declaraciones (`.d.ts`).
- Si hace **OOM** → el cuello de botella son las declaraciones.
5. **`pnpm run build:debug:full`**
Build completo con `--extendedDiagnostics` (imprime estadísticas al final).
- Para ver en qué paso se va la memoria si no has localizado antes.
Con eso sabes si el OOM viene de: typecheck, emit JS o emit declarations, y puedes elegir fix (más memoria, esbuild para JS, o no emitir declarations).

View File

@@ -30,13 +30,13 @@
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/cohere": "^2.0.4",
"@ai-sdk/deepinfra": "^1.0.10",
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/deepinfra": "^2.0.34",
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/utils": "0.3.0",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.1.3",
@@ -44,11 +44,11 @@
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@react-email/components": "^0.0.21",
"@better-auth/sso":"1.4.18",
"@better-auth/sso": "1.4.18",
"@trpc/server": "^10.45.2",
"adm-zip": "^0.5.16",
"ai": "^5.0.17",
"ai-sdk-ollama": "^0.5.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.4.18",
"bl": "6.0.11",
@@ -81,11 +81,11 @@
"ssh2": "1.15.0",
"toml": "3.0.0",
"ws": "8.16.0",
"zod": "^3.25.32",
"zod": "^3.25.76",
"semver": "7.7.3"
},
"devDependencies": {
"@better-auth/cli": "1.4.18",
"@better-auth/cli": "1.4.18",
"@types/semver": "7.7.1",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
@@ -115,4 +115,4 @@
"node": "^20.16.0",
"pnpm": ">=9.12.0"
}
}
}

View File

@@ -43,6 +43,8 @@ import {
type ServiceModeSwarm,
ServiceModeSwarmSchema,
triggerType,
type UlimitsSwarm,
UlimitsSwarmSchema,
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
@@ -172,6 +174,7 @@ export const applications = pgTable("application", {
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
//
replicas: integer("replicas").default(1).notNull(),
applicationStatus: applicationStatus("applicationStatus")
@@ -364,6 +367,7 @@ const createSchema = createInsertSchema(applications, {
cleanCache: z.boolean().optional(),
stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});
export const apiCreateApplication = createSchema.pick({

View File

@@ -23,6 +23,8 @@ import {
RestartPolicySwarmSchema,
type ServiceModeSwarm,
ServiceModeSwarmSchema,
type UlimitsSwarm,
UlimitsSwarmSchema,
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
@@ -67,6 +69,7 @@ export const mariadb = pgTable("mariadb", {
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
replicas: integer("replicas").default(1).notNull(),
createdAt: text("createdAt")
.notNull()
@@ -141,6 +144,7 @@ const createSchema = createInsertSchema(mariadb, {
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});
export const apiCreateMariaDB = createSchema.pick({

View File

@@ -30,6 +30,8 @@ import {
RestartPolicySwarmSchema,
type ServiceModeSwarm,
ServiceModeSwarmSchema,
type UlimitsSwarm,
UlimitsSwarmSchema,
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
@@ -70,6 +72,7 @@ export const mongo = pgTable("mongo", {
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
replicas: integer("replicas").default(1).notNull(),
createdAt: text("createdAt")
.notNull()
@@ -138,6 +141,7 @@ const createSchema = createInsertSchema(mongo, {
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});
export const apiCreateMongo = createSchema.pick({

View File

@@ -23,6 +23,8 @@ import {
RestartPolicySwarmSchema,
type ServiceModeSwarm,
ServiceModeSwarmSchema,
type UlimitsSwarm,
UlimitsSwarmSchema,
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
@@ -65,6 +67,7 @@ export const mysql = pgTable("mysql", {
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
replicas: integer("replicas").default(1).notNull(),
createdAt: text("createdAt")
.notNull()
@@ -138,6 +141,7 @@ const createSchema = createInsertSchema(mysql, {
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});
export const apiCreateMySql = createSchema.pick({

View File

@@ -23,6 +23,7 @@ export const notificationType = pgEnum("notificationType", [
"pushover",
"custom",
"lark",
"teams",
]);
export const notifications = pgTable("notification", {
@@ -72,6 +73,9 @@ export const notifications = pgTable("notification", {
pushoverId: text("pushoverId").references(() => pushover.pushoverId, {
onDelete: "cascade",
}),
teamsId: text("teamsId").references(() => teams.teamsId, {
onDelete: "cascade",
}),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
@@ -179,6 +183,14 @@ export const pushover = pgTable("pushover", {
expire: integer("expire"),
});
export const teams = pgTable("teams", {
teamsId: text("teamsId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
webhookUrl: text("webhookUrl").notNull(),
});
export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, {
fields: [notifications.slackId],
@@ -220,6 +232,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.pushoverId],
references: [pushover.pushoverId],
}),
teams: one(teams, {
fields: [notifications.teamsId],
references: [teams.teamsId],
}),
organization: one(organization, {
fields: [notifications.organizationId],
references: [organization.id],
@@ -507,6 +523,32 @@ export const apiTestLarkConnection = apiCreateLark.pick({
webhookUrl: true,
});
export const apiCreateTeams = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
webhookUrl: z.string().min(1),
})
.required();
export const apiUpdateTeams = apiCreateTeams.partial().extend({
notificationId: z.string().min(1),
teamsId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestTeamsConnection = apiCreateTeams.pick({
webhookUrl: true,
});
export const apiCreatePushover = notificationsSchema
.pick({
appBuildError: true,

View File

@@ -23,6 +23,8 @@ import {
RestartPolicySwarmSchema,
type ServiceModeSwarm,
ServiceModeSwarmSchema,
type UlimitsSwarm,
UlimitsSwarmSchema,
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
@@ -65,6 +67,7 @@ export const postgres = pgTable("postgres", {
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
replicas: integer("replicas").default(1).notNull(),
createdAt: text("createdAt")
.notNull()
@@ -132,6 +135,7 @@ const createSchema = createInsertSchema(postgres, {
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});
export const apiCreatePostgres = createSchema.pick({

View File

@@ -22,6 +22,8 @@ import {
RestartPolicySwarmSchema,
type ServiceModeSwarm,
ServiceModeSwarmSchema,
type UlimitsSwarm,
UlimitsSwarmSchema,
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
@@ -64,6 +66,7 @@ export const redis = pgTable("redis", {
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
replicas: integer("replicas").default(1).notNull(),
environmentId: text("environmentId")
@@ -120,6 +123,7 @@ const createSchema = createInsertSchema(redis, {
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});
export const apiCreateRedis = createSchema.pick({

View File

@@ -86,6 +86,14 @@ export interface EndpointSpecSwarm {
Ports?: EndpointPortConfigSwarm[] | undefined;
}
export interface UlimitSwarm {
Name: string;
Soft: number;
Hard: number;
}
export type UlimitsSwarm = UlimitSwarm[];
export const HealthCheckSwarmSchema = z
.object({
Test: z.array(z.string()).optional(),
@@ -189,3 +197,13 @@ export const EndpointSpecSwarmSchema = z
Ports: z.array(EndpointPortConfigSwarmSchema).optional(),
})
.strict();
export const UlimitSwarmSchema = z
.object({
Name: z.string().min(1),
Soft: z.number().int().min(-1),
Hard: z.number().int().min(-1),
})
.strict();
export const UlimitsSwarmSchema = z.array(UlimitSwarmSchema);

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { z } from "zod";
import { organization } from "./account";
import { user } from "./user";
@@ -15,6 +15,7 @@ export const ssoProvider = pgTable("sso_provider", {
onDelete: "cascade",
}),
domain: text("domain").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({

View File

@@ -18,6 +18,8 @@ import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
const trustedProviders = process.env?.TRUSTED_PROVIDERS?.split(",") || [];
const { handler, api } = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
@@ -43,6 +45,14 @@ const { handler, api } = betterAuth({
},
}
: {}),
account: {
accountLinking: {
enabled: true,
trustedProviders: ["github", "google", ...(trustedProviders || [])],
allowDifferentEmails: true,
},
},
appName: "Dokploy",
socialProviders: {
github: {
@@ -337,6 +347,7 @@ export const auth = {
handler,
createApiKey: api.createApiKey,
registerSSOProvider: api.registerSSOProvider,
updateSSOProvider: api.updateSSOProvider,
};
export const validateRequest = async (request: IncomingMessage) => {

View File

@@ -2,13 +2,31 @@ import { db } from "@dokploy/server/db";
import { ai } from "@dokploy/server/db/schema";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateObject } from "ai";
import { generateText, Output } from "ai";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findServerById } from "./server";
import { getWebServerSettings } from "./web-server-settings";
interface SuggestionItem {
id: string;
name: string;
shortDescription: string;
description: string;
}
interface SuggestionsOutput {
suggestions: SuggestionItem[];
}
interface DockerOutput {
dockerCompose: string;
envVariables: Array<{ name: string; value: string }>;
domains: Array<{ host: string; port: number; serviceName: string }>;
configFiles?: Array<{ content: string; filePath: string }>;
}
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
const aiSettings = await db.query.ai.findMany({
where: eq(ai.organizationId, organizationId),
@@ -60,7 +78,7 @@ interface Props {
}
export const suggestVariants = async ({
organizationId,
organizationId: _organizationId,
aiId,
input,
serverId,
@@ -90,173 +108,177 @@ export const suggestVariants = async ({
ip = "127.0.0.1";
}
const { object } = await generateObject({
model,
output: "object",
schema: z.object({
suggestions: z.array(
z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
),
}),
prompt: `
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
- Generate different deployment VARIANTS of that SAME application
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
- The name MUST include the specific application name the user mentioned
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
- Suggest different open source projects that fulfill that need
- Each suggestion should be a different tool/platform that solves the same problem
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
- The name should be the actual project name
Return your response as a JSON object with the following structure:
{
"suggestions": [
{
"id": "project-or-variant-slug",
"name": "Project Name or Variant Name",
"shortDescription": "Brief one-line description",
"description": "Detailed description"
}
]
}
Important rules for the response:
1. Use slug format for the id field (lowercase, hyphenated)
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
8. All suggestions should be installable in docker and have docker compose support
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
User wants to create a new project with the following details:
${input}
`,
const suggestionsSchema = z.object({
suggestions: z.array(
z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
),
});
const suggestionsResult = await generateText({
model,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: suggestionsSchema }),
prompt: `
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
- Generate different deployment VARIANTS of that SAME application
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
- The name MUST include the specific application name the user mentioned
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
- Suggest different open source projects that fulfill that need
- Each suggestion should be a different tool/platform that solves the same problem
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
- The name should be the actual project name
Return your response as a JSON object with the following structure:
{
"suggestions": [
{
"id": "project-or-variant-slug",
"name": "Project Name or Variant Name",
"shortDescription": "Brief one-line description",
"description": "Detailed description"
}
]
}
Important rules for the response:
1. Use slug format for the id field (lowercase, hyphenated)
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
8. All suggestions should be installable in docker and have docker compose support
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
User wants to create a new project with the following details:
${input}
`,
});
const object = suggestionsResult.output as SuggestionsOutput | undefined;
if (object?.suggestions?.length) {
const dockerSchema = z.object({
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
}),
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
}),
),
configFiles: z
.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
)
.optional(),
});
const result = [];
for (const suggestion of object.suggestions) {
try {
const { object: docker } = await generateObject({
const dockerResult = await generateText({
model,
output: "object",
schema: z.object({
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
}),
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
}),
),
configFiles: z
.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
)
.optional(),
}),
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: dockerSchema }),
prompt: `
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
Return your response as a JSON object with this structure:
{
"dockerCompose": "yaml string here",
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
}
Note: configFiles is optional - only include it if configuration files are absolutely required.
Follow these rules:
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
2. Use complex values for passwords/secrets variables
3. Don't set container_name field in services
4. Don't set version field in the docker compose
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
7. Make sure all required services are defined in the docker-compose
Return your response as a JSON object with this structure:
{
"dockerCompose": "yaml string here",
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
}
Docker Image Rules (CRITICAL):
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
- image: chatwoot/chatwoot:latest
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
- build:
context: .
dockerfile: Dockerfile
Note: configFiles is optional - only include it if configuration files are absolutely required.
Volume Mounting and Configuration Rules:
1. DO NOT create configuration files unless the service CANNOT work without them
2. Most services can work with just environment variables - USE THEM FIRST
3. Ask yourself: "Can this be configured with an environment variable instead?"
4. If and ONLY IF a config file is absolutely required:
- Keep it minimal with only critical settings
- Use "../files/" prefix for all mounts
- Format: "../files/folder:/container/path"
5. DO NOT add configuration files for:
- Default configurations that work out of the box
- Settings that can be handled by environment variables
- Proxy or routing configurations (these are handled elsewhere)
Follow these rules:
Environment Variables Rules:
1. For the envVariables array, provide ACTUAL example values, not placeholders
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
4. ONLY include environment variables that are actually used in the docker-compose
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
6. Do not include environment variables for services that don't exist in the docker-compose
For each service that needs to be exposed to the internet:
1. Define a domain configuration with:
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
User's original request: ${input}
Project details:
${suggestion?.description}
`,
Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
2. Use complex values for passwords/secrets variables
3. Don't set container_name field in services
4. Don't set version field in the docker compose
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
7. Make sure all required services are defined in the docker-compose
Docker Image Rules (CRITICAL):
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
- image: chatwoot/chatwoot:latest
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
- build:
context: .
dockerfile: Dockerfile
Volume Mounting and Configuration Rules:
1. DO NOT create configuration files unless the service CANNOT work without them
2. Most services can work with just environment variables - USE THEM FIRST
3. Ask yourself: "Can this be configured with an environment variable instead?"
4. If and ONLY IF a config file is absolutely required:
- Keep it minimal with only critical settings
- Use "../files/" prefix for all mounts
- Format: "../files/folder:/container/path"
5. DO NOT add configuration files for:
- Default configurations that work out of the box
- Settings that can be handled by environment variables
- Proxy or routing configurations (these are handled elsewhere)
Environment Variables Rules:
1. For the envVariables array, provide ACTUAL example values, not placeholders
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
4. ONLY include environment variables that are actually used in the docker-compose
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
6. Do not include environment variables for services that don't exist in the docker-compose
For each service that needs to be exposed to the internet:
1. Define a domain configuration with:
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
User's original request: ${input}
Project details:
${suggestion?.description}
`,
});
if (!!docker && !!docker.dockerCompose) {
const docker = dockerResult.output as DockerOutput | undefined;
if (docker?.dockerCompose) {
result.push({
...suggestion,
...docker,

View File

@@ -395,16 +395,14 @@ export const removeCompose = async (
if (compose.composeType === "stack") {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;
cd ${projectPath} && docker stack rm ${compose.appName} && rm -rf ${projectPath}`;
docker stack rm ${compose.appName};
rm -rf ${projectPath}`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, command);
} else {
await execAsync(command);
}
await execAsync(command, {
cwd: projectPath,
});
} else {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;

View File

@@ -101,6 +101,20 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
return projectEnvironments;
};
const environmentHasServices = (
env: Awaited<ReturnType<typeof findEnvironmentById>>,
) => {
return (
(env.applications?.length ?? 0) > 0 ||
(env.compose?.length ?? 0) > 0 ||
(env.mariadb?.length ?? 0) > 0 ||
(env.mongo?.length ?? 0) > 0 ||
(env.mysql?.length ?? 0) > 0 ||
(env.postgres?.length ?? 0) > 0 ||
(env.redis?.length ?? 0) > 0
);
};
export const deleteEnvironment = async (environmentId: string) => {
const currentEnvironment = await findEnvironmentById(environmentId);
if (currentEnvironment.isDefault) {
@@ -109,6 +123,13 @@ export const deleteEnvironment = async (environmentId: string) => {
message: "You cannot delete the default environment",
});
}
if (environmentHasServices(currentEnvironment)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Cannot delete environment: it has active services. Delete all services first.",
});
}
const deletedEnvironment = await db
.delete(environments)
.where(eq(environments.environmentId, environmentId))

View File

@@ -9,6 +9,7 @@ import {
type apiCreatePushover,
type apiCreateResend,
type apiCreateSlack,
type apiCreateTeams,
type apiCreateTelegram,
type apiUpdateCustom,
type apiUpdateDiscord,
@@ -19,6 +20,7 @@ import {
type apiUpdatePushover,
type apiUpdateResend,
type apiUpdateSlack,
type apiUpdateTeams,
type apiUpdateTelegram,
custom,
discord,
@@ -30,6 +32,7 @@ import {
pushover,
resend,
slack,
teams,
telegram,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
@@ -796,6 +799,7 @@ export const findNotificationById = async (notificationId: string) => {
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
if (!notification) {
@@ -905,6 +909,96 @@ export const updateLarkNotification = async (
});
};
export const createTeamsNotification = async (
input: typeof apiCreateTeams._type,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newTeams = await tx
.insert(teams)
.values({
webhookUrl: input.webhookUrl,
})
.returning()
.then((value) => value[0]);
if (!newTeams) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting teams",
});
}
const newDestination = await tx
.insert(notifications)
.values({
teamsId: newTeams.teamsId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "teams",
organizationId: organizationId,
serverThreshold: input.serverThreshold,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updateTeamsNotification = async (
input: typeof apiUpdateTeams._type,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
organizationId: input.organizationId,
serverThreshold: input.serverThreshold,
})
.where(eq(notifications.notificationId, input.notificationId))
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error Updating notification",
});
}
await tx
.update(teams)
.set({
webhookUrl: input.webhookUrl,
})
.where(eq(teams.teamsId, input.teamsId))
.returning()
.then((value) => value[0]);
return newDestination;
});
};
export const updateNotificationById = async (
notificationId: string,
notificationData: Partial<Notification>,

View File

@@ -220,6 +220,7 @@ const rollbackApplication = async (
RollbackConfig,
UpdateConfig,
Networks,
Ulimits,
} = generateConfigContainer(fullContext as ApplicationNested);
const bindsMount = generateBindMounts(mounts);
@@ -254,6 +255,7 @@ const rollbackApplication = async (
Args: ["-c", command],
}
: {}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -110,6 +110,7 @@ export const mechanizeDockerContainer = async (
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(application);
const bindsMount = generateBindMounts(mounts);
@@ -142,7 +143,7 @@ export const mechanizeDockerContainer = async (
args.length > 0 && {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -7,7 +7,7 @@ import { user as userSchema } from "../../db/schema/user";
export const LICENSE_KEY_URL =
process.env.NODE_ENV === "development"
? "http://localhost:4002"
: "https://licenses.dokploy.com";
: "https://licenses-api.dokploy.com";
export const initEnterpriseBackupCronJobs = async () => {
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {

View File

@@ -48,6 +48,7 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(mariadb);
const resources = calculateResources({
memoryLimit,
@@ -83,7 +84,7 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
args.length > 0 && {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -94,6 +94,7 @@ ${command ?? "wait $MONGOD_PID"}`;
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(mongo);
const resources = calculateResources({
@@ -139,7 +140,7 @@ ${command ?? "wait $MONGOD_PID"}`;
!replicaSets && {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -54,6 +54,7 @@ export const buildMysql = async (mysql: MysqlNested) => {
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(mysql);
const resources = calculateResources({
memoryLimit,
@@ -89,7 +90,7 @@ export const buildMysql = async (mysql: MysqlNested) => {
args.length > 0 && {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -47,6 +47,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(postgres);
const resources = calculateResources({
memoryLimit,
@@ -82,7 +83,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
args.length > 0 && {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -45,6 +45,7 @@ export const buildRedis = async (redis: RedisNested) => {
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(redis);
const resources = calculateResources({
memoryLimit,
@@ -87,6 +88,7 @@ export const buildRedis = async (redis: RedisNested) => {
Command: ["/bin/sh"],
Args: ["-c", `redis-server --requirepass ${databasePassword}`],
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -164,10 +164,12 @@ export const addDomainToCompose = async (
for (const domain of domains) {
const { serviceName, https } = domain;
if (!serviceName) {
throw new Error("Service name not found");
throw new Error(`Domain "${domain.host}" is missing a service name`);
}
if (!result?.services?.[serviceName]) {
throw new Error(`The service ${serviceName} not found in the compose`);
throw new Error(
`Domain "${domain.host}" is attached to service "${serviceName}" which does not exist in the compose`,
);
}
const httpLabels = createDomainLabels(appName, domain, "web");

View File

@@ -508,6 +508,7 @@ export const generateConfigContainer = (
networkSwarm,
stopGracePeriodSwarm,
endpointSpecSwarm,
ulimitsSwarm,
} = application;
const sanitizedStopGracePeriodSwarm =
@@ -584,6 +585,10 @@ export const generateConfigContainer = (
})) || [],
},
}),
...(ulimitsSwarm &&
ulimitsSwarm.length > 0 && {
Ulimits: ulimitsSwarm,
}),
};
};

View File

@@ -14,6 +14,7 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -52,6 +53,7 @@ export const sendBuildErrorNotifications = async ({
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -67,6 +69,7 @@ export const sendBuildErrorNotifications = async ({
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -382,6 +385,26 @@ export const sendBuildErrorNotifications = async ({
`Project: ${projectName}\nApplication: ${applicationName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}\nError: ${errorMessage}`,
);
}
if (teams) {
const limitCharacter = 800;
const truncatedErrorMessage = errorMessage.substring(0, limitCharacter);
await sendTeamsNotification(teams, {
title: "⚠️ Build Failed",
facts: [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Type", value: applicationType },
{ name: "Date", value: format(date, "PP pp") },
{ name: "Error Message", value: truncatedErrorMessage },
],
potentialAction: {
type: "Action.OpenUrl",
title: "View Build Details",
url: buildLink,
},
});
}
} catch (error) {
console.log(error);
}

View File

@@ -15,6 +15,7 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -55,6 +56,7 @@ export const sendBuildSuccessNotifications = async ({
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -70,6 +72,7 @@ export const sendBuildSuccessNotifications = async ({
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -396,6 +399,24 @@ export const sendBuildSuccessNotifications = async ({
`Project: ${projectName}\nApplication: ${applicationName}\nEnvironment: ${environmentName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: "✅ Build Success",
facts: [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Environment", value: environmentName },
{ name: "Type", value: applicationType },
{ name: "Date", value: format(date, "PP pp") },
],
potentialAction: {
type: "Action.OpenUrl",
title: "View Build Details",
url: buildLink,
},
});
}
} catch (error) {
console.log(error);
}

View File

@@ -14,6 +14,7 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -52,6 +53,7 @@ export const sendDatabaseBackupNotifications = async ({
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -67,6 +69,7 @@ export const sendDatabaseBackupNotifications = async ({
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -410,6 +413,30 @@ export const sendDatabaseBackupNotifications = async ({
`Project: ${projectName}\nApplication: ${applicationName}\nDatabase: ${databaseType}\nDatabase Name: ${databaseName}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
if (teams) {
const facts = [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Database Type", value: databaseType },
{ name: "Database Name", value: databaseName },
{ name: "Date", value: format(date, "PP pp") },
{
name: "Status",
value: type === "success" ? "Successful" : "Failed",
},
];
if (type === "error" && errorMessage) {
facts.push({ name: "Error", value: errorMessage.substring(0, 500) });
}
await sendTeamsNotification(teams, {
title:
type === "success"
? "✅ Database Backup Successful"
: "❌ Database Backup Failed",
facts,
});
}
} catch (error) {
console.log(error);
}

View File

@@ -14,6 +14,7 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -39,6 +40,7 @@ export const sendDockerCleanupNotifications = async (
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -54,6 +56,7 @@ export const sendDockerCleanupNotifications = async (
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -262,6 +265,16 @@ export const sendDockerCleanupNotifications = async (
`Date: ${date.toLocaleString()}\nMessage: ${message}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: "✅ Docker Cleanup",
facts: [
{ name: "Date", value: format(date, "PP pp") },
{ name: "Message", value: message },
],
});
}
} catch (error) {
console.log(error);
}

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