Compare commits

..

58 Commits

Author SHA1 Message Date
Mauricio Siu
9067452a38 feat: add role presets for custom role management
- Introduced predefined role presets with default permissions for viewer, developer, deployer, and devops roles to streamline custom role creation.
- Enhanced the UI to allow users to start from a preset role, improving the user experience in managing custom roles.
- Updated imports and adjusted component formatting for better readability.
2026-03-17 23:33:45 -06:00
Mauricio Siu
1fa4d5b2ba refactor: improve formatting and readability in billing and users components
- Enhanced code readability by adjusting formatting in the ShowBilling component, ensuring consistent line breaks and indentation.
- Updated the ShowUsers component to improve the layout of the warning message for users with custom roles without a valid license, maintaining clarity in the alert presentation.
2026-03-17 23:17:30 -06:00
Mauricio Siu
bade36ea9d feat: add alert for users with custom roles without a valid license
- Introduced an AlertBlock to notify users with custom roles that a valid Enterprise license is required for those roles to function.
- Implemented logic to check for users assigned to custom roles and display a warning if the license is invalid.
2026-03-17 23:16:17 -06:00
Mauricio Siu
0c22041623 refactor: update billing component to manage server quantities for hobby and startup tiers
- Replaced single server quantity state with separate states for hobby and startup server quantities.
- Adjusted calculations and UI elements to reflect the new state management for each tier.
- Ensured proper handling of server quantity in pricing calculations and button states.
2026-03-17 23:11:50 -06:00
Mauricio Siu
cccee05173 Merge pull request #4023 from Dokploy/4021-discord-error-notifications-fail-due-to-content-exceeding-max-embed-length
fix: truncate error message in backup notifications to 1010 characters
2026-03-17 22:47:35 -06:00
Mauricio Siu
9f9c8fccf2 Update packages/server/src/utils/notifications/database-backup.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-17 22:47:26 -06:00
Mauricio Siu
ad2e53a67a fix: truncate error message in backup notifications to 1010 characters
- Updated the error message formatting in both database and volume backup notification functions to limit the displayed message length, ensuring better readability and preventing overflow.
2026-03-17 22:17:36 -06:00
Mauricio Siu
00f3853bd7 chore: remove settings.json file for command permissions in Claude
- Deleted the settings.json file that defined permissions for various Bash commands and the default mode for Claude.
2026-03-17 18:19:37 -06:00
Mauricio Siu
2880327e94 feat: add settings configuration for command permissions in Claude
- Introduced a new settings.json file to define permissions for various Bash commands and set the default mode to bypassPermissions.
- Updated the version in package.json to v0.28.7.
2026-03-17 18:18:04 -06:00
Mauricio Siu
827b84f57e Merge pull request #4001 from WalidDevIO/fix/volume-backup-turn-off
fix(volume-backups): restart container before S3 upload in volume backup
2026-03-17 08:53:02 -06:00
Mauricio Siu
11aa8fe0c5 Update packages/server/src/utils/volume-backups/backup.ts 2026-03-17 08:51:31 -06:00
autofix-ci[bot]
b9ac720d99 [autofix.ci] apply automated fixes 2026-03-17 06:25:03 +00:00
Mauricio Siu
77b0ff7bbf Merge pull request #4016 from Dokploy/4003-first-application-deploy-to-swarm-worker-fails-with-unauthorized-no-such-image-retry-succeeds
fix: handle optional authConfig in mechanizeDockerContainer function
2026-03-17 00:18:14 -06:00
Mauricio Siu
e7af2c0ebd fix: handle optional authConfig in mechanizeDockerContainer function
- Updated the mechanizeDockerContainer function to conditionally use authConfig when creating a Docker service, ensuring proper service creation based on authentication settings.
2026-03-17 00:17:51 -06:00
Mauricio Siu
6a1bedb90f Merge pull request #4015 from Dokploy/3971-abnormal-webserver-backup-file-size-increase-500-kb-4-gb-overnight
fix: exclude volume-backups from web server backup rsync command
2026-03-16 23:27:16 -06:00
Mauricio Siu
a2f142174b fix: exclude volume-backups from web server backup rsync command
- Updated the rsync command in the runWebServerBackup function to exclude the 'volume-backups/' directory, ensuring that unnecessary data is not copied during the backup process.
2026-03-16 23:26:33 -06:00
Mauricio Siu
f4ce304a04 Merge pull request #4013 from Dokploy/3983-custom-database-docker-image-reset-to-default-for-any-unrelated-change
feat: add optional dockerImage field to database schemas
2026-03-16 16:20:18 -06:00
Mauricio Siu
bb521f3e7e feat: add optional dockerImage field to database schemas
- Updated MariaDB, MongoDB, MySQL, PostgreSQL, and Redis schemas to include an optional dockerImage field for enhanced configuration flexibility.
2026-03-16 16:19:37 -06:00
Mauricio Siu
baaa470234 Merge pull request #4012 from Dokploy/3979-collapsed-sidebar-state-has-usability-and-visual-issues
3979 collapsed sidebar state has usability and visual issues
2026-03-16 15:34:05 -06:00
autofix-ci[bot]
4871520dbb [autofix.ci] apply automated fixes 2026-03-16 21:33:40 +00:00
Mauricio Siu
dad49ec96f refactor: move TimeBadge to BreadcrumbSidebar for conditional rendering
- Removed TimeBadge from the ShowProjects component and integrated it into the BreadcrumbSidebar.
- Added a query to determine if the environment is cloud-based, allowing for conditional display of the TimeBadge.
- Updated layout in BreadcrumbSidebar for improved spacing and organization.
2026-03-16 15:32:59 -06:00
Mauricio Siu
ce4e37c75b refactor: simplify sidebar state handling
- Replaced direct state checks with a derived variable `isCollapsed` for better readability and maintainability.
- Updated class names and conditions in the SidebarLogo component to use the new `isCollapsed` variable.
- Adjusted overflow behavior in Sidebar and SidebarContent components for improved layout management.
2026-03-16 15:29:25 -06:00
Mauricio Siu
c317ec39cb Merge pull request #3977 from azizbecha/fix/watch-path-tooltip-submit
fix: prevent Watch Paths tooltip button from submitting the form
2026-03-16 14:55:35 -06:00
Mauricio Siu
a4e9c6e890 feat: implement audit logs and custom role management components
- Added new components for displaying and managing audit logs, including a data table and filters for user actions.
- Introduced a custom roles management interface, allowing users to create and modify roles with specific permissions.
- Updated permission checks to ensure proper access control for audit logs and custom roles.
- Refactored existing components to integrate new functionality and improve user experience.
2026-03-16 11:13:24 -06:00
Mauricio Siu
72fb85f616 Merge pull request #4009 from Dokploy/feat/add-custom-roles
feat: add comprehensive permission tests and enhance permission check…
2026-03-16 01:12:30 -06:00
Mauricio Siu
1e7a6f2071 refactor: update custom role handling in API
- Replaced the delete operation with an update for organization roles, ensuring existing roles are modified instead of removed.
- Adjusted the return value to reflect the updated role instead of a newly created entry.
- Reintroduced the audit logging functionality for role updates.
2026-03-15 23:33:20 -06:00
autofix-ci[bot]
5ffd664570 [autofix.ci] apply automated fixes 2026-03-16 05:02:48 +00:00
Mauricio Siu
947100c041 refactor: replace existing organization_role and audit_log tables with new definitions
- Deleted the old SQL files for organization_role and audit_log.
- Introduced new SQL file defining organization_role and audit_log with updated foreign key constraints and indexes.
- Updated metadata snapshots to reflect the new table structures and relationships.
- Adjusted access control permissions for backup and notification operations to include update capabilities.
2026-03-15 23:02:23 -06:00
autofix-ci[bot]
5410a56638 [autofix.ci] apply automated fixes 2026-03-15 22:43:40 +00:00
Mauricio Siu
8127dc4536 feat: add comprehensive permission tests and enhance permission checks in components
- Introduced new test files for permission checks, including `check-permission.test.ts`, `enterprise-only-resources.test.ts`, `resolve-permissions.test.ts`, and `service-access.test.ts`.
- Implemented permission checks in various components to ensure actions are gated by user permissions, including `ShowTraefikConfig`, `UpdateTraefikConfig`, `ShowVolumes`, `ShowDomains`, and others.
- Enhanced the logic for displaying UI elements based on user permissions, ensuring that only authorized users can access or modify resources.
2026-03-15 16:42:48 -06:00
EL OUAZIZI Walid
2f37235aea fix(volume-backups): restart container before S3 upload in volume backup 2026-03-15 06:46:33 +01:00
Aziz Becha
290267bca4 fix: prevent Watch Paths tooltip button from submitting the form 2026-03-12 01:18:00 +01:00
Mauricio Siu
8eace173b9 Merge pull request #3969 from Dokploy/refactor/upgrade-better-auth
chore: update better-auth dependencies to version 1.5.4 and refactor …
2026-03-10 16:30:23 -06:00
Mauricio Siu
c9a9ed8164 Merge pull request #3967 from desen94/fix/invalidate-notification-cache-on-edit
fix: invalidate notification.one query cache on update
2026-03-10 16:29:03 -06:00
Mauricio Siu
30428053e8 chore: update better-auth dependencies to version 1.5.4 and refactor imports in auth-client and auth modules 2026-03-10 16:25:45 -06:00
Волков Дмитрий Сергеевич
1c0dbbcfd6 fix: invalidate notification.one query cache on update
When editing a notification, only the notification.all query cache was
invalidated. The notification.one query retained stale data, causing
the edit form to display previous values on subsequent edits.
2026-03-10 23:16:54 +05:00
Mauricio Siu
178f4fbdf7 fix: update Docker API version constant to use DOKPLOY environment variable 2026-03-10 10:12:00 -06:00
Mauricio Siu
2c07a4b2e3 Bump version from v0.28.5 to v0.28.6 2026-03-10 10:02:53 -06:00
Mauricio Siu
75a797097b Merge pull request #3952 from jirkavrba/copy-webhook-url
feat(deployments): Add option to copy webhook url by clicking on it
2026-03-10 10:00:04 -06:00
Mauricio Siu
2879816e41 Merge pull request #3962 from Dokploy/3955-bug-typeerror-invalid-url-with-dockersock-preventing-any-deployments-when-building-dockerfiles-on-version-v0285
feat: update Docker configuration to use DOKPLOY environment variables
2026-03-10 02:12:02 -06:00
Mauricio Siu
3501996b9e feat: update Docker configuration to use DOKPLOY environment variables 2026-03-10 02:11:36 -06:00
Mauricio Siu
47556a6486 Merge pull request #3960 from Dokploy/3956-preview-deployments-with-previewlabels-fail-due-to-webhook-race-condition
feat: add support for 'labeled' action in GitHub deployment handler
2026-03-10 02:09:40 -06:00
Mauricio Siu
e554adc376 feat: add support for 'labeled' action in GitHub deployment handler 2026-03-10 02:09:16 -06:00
Mauricio Siu
1804b935f6 Merge pull request #3959 from Dokploy/feat/add-new-whitelabeling
Feat/add new whitelabeling
2026-03-10 02:07:19 -06:00
Mauricio Siu
985c9102da refactor: remove primaryColor from whitelabeling settings and related components for cleaner configuration 2026-03-10 02:03:34 -06:00
Mauricio Siu
2e03cf3d48 refactor: implement safe URL validation for whitelabeling settings in both client and server schemas 2026-03-10 00:55:01 -06:00
Mauricio Siu
33532d3cf7 refactor: update whitelabeling hooks and API usage for improved access control and consistency 2026-03-10 00:47:30 -06:00
autofix-ci[bot]
a6999b1cf2 [autofix.ci] apply automated fixes 2026-03-10 06:32:56 +00:00
Mauricio Siu
f5d18d6f9b refactor: replace adminProcedure with enterpriseProcedure in whitelabeling router for enhanced access control 2026-03-10 00:32:08 -06:00
Mauricio Siu
e3ff7ef3e3 feat: add whitelabelingConfig column to webServerSettings table and update related metadata 2026-03-10 00:28:52 -06:00
Mauricio Siu
b84bc9b7c6 feat: implement whitelabeling features including settings, preview, and provider components 2026-03-10 00:27:58 -06:00
Jiří Vrba
de201d0b0a Add aria-label to webhook URL badge 2026-03-09 10:00:08 +01:00
autofix-ci[bot]
6866e2b63a [autofix.ci] apply automated fixes 2026-03-09 08:49:06 +00:00
Jiří Vrba
3e4a1b92eb Code review fixes 2026-03-09 09:48:37 +01:00
Jiří Vrba
b9ca6ea9db Code review fixes 2026-03-09 09:38:00 +01:00
Jiří Vrba
f1d4543d5e Code review fixes 2026-03-09 09:33:30 +01:00
autofix-ci[bot]
d8c7c1eaf4 [autofix.ci] apply automated fixes 2026-03-09 08:28:35 +00:00
Jiří Vrba
4330d7bd99 feat(deployments): Add option to copy webhook url by clicking on it 2026-03-09 09:25:41 +01:00
468 changed files with 68324 additions and 12058 deletions

21
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Dockerfile for DevContainer
FROM node:24.4.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@10.22.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"]
}

View File

@@ -13,6 +13,17 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set tag and version
id: meta-cloud
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
@@ -25,8 +36,7 @@ jobs:
context: .
file: ./Dockerfile.cloud
push: true
tags: |
siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
tags: ${{ steps.meta-cloud.outputs.tags }}
platforms: linux/amd64
build-args: |
NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }}
@@ -40,6 +50,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set tag and version
id: meta-schedule
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
@@ -52,8 +72,7 @@ jobs:
context: .
file: ./Dockerfile.schedule
push: true
tags: |
siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
tags: ${{ steps.meta-schedule.outputs.tags }}
platforms: linux/amd64
build-and-push-server-image:
@@ -63,6 +82,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set tag and version
id: meta-server
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
@@ -75,6 +104,5 @@ jobs:
context: .
file: ./Dockerfile.server
push: true
tags: |
siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
tags: ${{ steps.meta-server.outputs.tags }}
platforms: linux/amd64

22
.github/workflows/pr-quality.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
max-failures: 4
blocked-commit-authors: "claude,copilot"
require-description: true
min-account-age: 5

View File

@@ -18,7 +18,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
node-version: 24.4.0
cache: "pnpm"
- name: Install Nixpacks

View File

@@ -24,7 +24,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
node-version: 24.4.0
cache: "pnpm"
- name: Install dependencies

5
.gitignore vendored
View File

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

2
.nvmrc
View File

@@ -1 +1 @@
20.16.0
24.4.0

View File

@@ -53,7 +53,7 @@ feat: add new feature
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory.
We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory.
```bash
git clone https://github.com/dokploy/dokploy.git
@@ -165,10 +165,11 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
### Important Considerations for Pull Requests
- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code.
- **Testing is Mandatory:** All Pull Requests **must be tested** by the PR author before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested by their creator will be rejected.** This policy keeps the PR history clean and values contributors who submit verified, working code. Untested PRs are often recognizable by disproportionately large or scattered changes for simple tasks—please test first.
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing.
Thank you for your contribution!

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base
FROM node:24.4.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@9.12.0 --activate
RUN corepack prepare pnpm@10.22.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/dokploy run build
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base
FROM node:24.4.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@9.12.0 --activate
RUN corepack prepare pnpm@10.22.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -29,7 +29,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/dokploy run build
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base
FROM node:24.4.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@9.12.0 --activate
RUN corepack prepare pnpm@10.22.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/schedules run build
RUN pnpm --filter=./apps/schedules --prod deploy /prod/schedules
RUN pnpm --filter=./apps/schedules --prod deploy --legacy /prod/schedules
RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base
FROM node:24.4.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@9.12.0 --activate
RUN corepack prepare pnpm@10.22.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/api run build
RUN pnpm --filter=./apps/api --prod deploy /prod/api
RUN pnpm --filter=./apps/api --prod deploy --legacy /prod/api
RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist

View File

@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "PORT=4000 tsx watch src/index.ts",
"build": "tsc --project tsconfig.json",
"build": "rimraf dist && tsc --project tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
@@ -12,7 +12,7 @@
"inngest": "3.40.1",
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0",
"@hono/zod-validator": "0.7.6",
"dotenv": "^16.4.5",
"hono": "^4.11.7",
"pino": "9.4.0",
@@ -20,18 +20,19 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"redis": "4.7.0",
"zod": "^3.25.32"
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^20.16.0",
"@types/node": "^24.4.0",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"rimraf": "6.1.3",
"tsx": "^4.16.2",
"typescript": "^5.8.3"
},
"packageManager": "pnpm@9.12.0",
"packageManager": "pnpm@10.22.0",
"engines": {
"node": "^20.16.0",
"pnpm": ">=9.12.0"
"node": "^24.4.0",
"pnpm": ">=10.22.0"
}
}

View File

@@ -1 +0,0 @@
20.16.0

View File

@@ -14,13 +14,18 @@ vi.mock("@dokploy/server/db", () => {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}] as any),
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
then: (resolve: (v: any) => void) => {
resolve([]);
},
} as any;
return chain;
};
return {
db: {
select: vi.fn(),
select: vi.fn(() => createChainableMock()),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
@@ -28,6 +33,12 @@ vi.mock("@dokploy/server/db", () => {
applications: {
findFirst: vi.fn(),
},
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
member: {
findMany: vi.fn().mockResolvedValue([]),
},
},
},
};

View File

@@ -15,13 +15,18 @@ vi.mock("@dokploy/server/db", () => {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}]),
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
then: (resolve: (v: any) => void) => {
resolve([]);
},
};
return chain;
};
return {
db: {
select: vi.fn(),
select: vi.fn(() => createChainableMock()),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
@@ -29,6 +34,12 @@ vi.mock("@dokploy/server/db", () => {
applications: {
findFirst: vi.fn(),
},
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
member: {
findMany: vi.fn().mockResolvedValue([]),
},
},
},
};

View File

@@ -83,6 +83,14 @@ describe("GitHub Webhook Skip CI", () => {
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
// Soft Serve
expect(
extractCommitMessage(
{ "x-softserve-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
});
it("should handle missing commit message", () => {
@@ -99,6 +107,9 @@ describe("GitHub Webhook Skip CI", () => {
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
"NEW COMMIT",
);
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
"NEW COMMIT",
);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
extractBranchName,
extractCommitMessage,
extractHash,
getProviderByHeader,
} from "@/pages/api/deploy/[refreshToken]";
describe("Soft Serve Webhook", () => {
const mockSoftServeHeaders = {
"x-softserve-event": "push",
};
const createMockBody = (message: string, hash: string, branch: string) => ({
event: "push",
ref: `refs/heads/${branch}`,
after: hash,
commits: [{ message: message }],
});
const message: string = "feat: add new feature";
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
const branch: string = "feat/add-new";
const goodWebhook = createMockBody(message, hash, branch);
it("should properly extract the provider name", () => {
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
});
it("should properly extract the commit message", () => {
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
message,
);
});
it("should properly extract hash", () => {
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
});
it("should properly extract branch name", () => {
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
});
it("should gracefully handle invalid webhook", () => {
expect(getProviderByHeader({})).toBeNull();
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
});
});

View File

@@ -6,6 +6,7 @@ import { paths } from "@dokploy/server/constants";
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const OUTPUT_BASE = "./__test__/drop/zips/output";
const { APPLICATIONS_PATH } = paths();
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual = await importOriginal();
@@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
// @ts-ignore
...actual,
paths: () => ({
APPLICATIONS_PATH: "./__test__/drop/zips/output",
// @ts-ignore
...actual.paths(),
BASE_PATH: OUTPUT_BASE,
APPLICATIONS_PATH: OUTPUT_BASE,
}),
};
});
@@ -150,6 +154,176 @@ const baseApp: ApplicationNested = {
ulimitsSwarm: null,
};
/**
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
*/
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
baseApp.appName = "ghsa-rce";
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
const cronPayload = "* * * * * root id\n";
const placeholder = "x".repeat(traversalEntry.length);
const zip = new AdmZip();
zip.addFile(
"package.json",
Buffer.from('{"name": "app", "version": "1.0.0"}'),
);
zip.addFile("index.js", Buffer.from('console.log("Application");'));
zip.addFile(placeholder, Buffer.from(cronPayload));
let buf = Buffer.from(zip.toBuffer());
buf = Buffer.from(
buf.toString("binary").split(placeholder).join(traversalEntry),
"binary",
);
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
/Path traversal detected.*resolved path escapes output directory/,
);
});
});
describe("security: existing symlink escape", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT write outside base when directory is a symlink", async () => {
const appName = "symlink-existing";
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// outside target (attacker wants to write here)
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
await fs.mkdir(outside, { recursive: true });
// attacker-controlled symlink inside project
await fs.symlink(outside, path.join(output, "logs"));
// zip looks totally harmless
const zip = new AdmZip();
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
// if vulnerable -> file exists outside sandbox
const escaped = await fs
.readFile(path.join(outside, "pwned.txt"), "utf8")
.then(() => true)
.catch(() => false);
expect(escaped).toBe(false);
});
});
describe("security: zip symlink entry blocked", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects zip containing real symlink entry", async () => {
const appName = "zip-symlink";
const zipBuffer = await fs.readFile(
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
);
const file = new File([zipBuffer as any], "exploit.zip");
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
/Dangerous node entries are not allowed/,
);
});
});
describe("unzipDrop path under output (no traversal)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
baseApp.appName = "cron-under-output";
const zip = new AdmZip();
zip.addFile(
"etc/cron.d/malicious-cron",
Buffer.from("* * * * * root id\n"),
);
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
const file = new File(
[zip.toBuffer() as unknown as ArrayBuffer],
"app.zip",
);
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
await unzipDrop(file, baseApp);
const content = await fs.readFile(
path.join(outputPath, "etc/cron.d/malicious-cron"),
"utf8",
);
expect(content).toBe("* * * * * root id\n");
});
});
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
const appName = "sandbox-escape";
const base = APPLICATIONS_PATH.replace("/applications", "");
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// attacker writes into traefik config inside base
const zip = new AdmZip();
zip.addFile(
"../../../traefik/dynamic/evil.yml",
Buffer.from("pwned: true"),
);
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
const exists = await fs
.readFile(escapedPath)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
});
describe("unzipDrop using real zip files", () => {
// const { APPLICATIONS_PATH } = paths();
beforeAll(async () => {
@@ -166,14 +340,12 @@ describe("unzipDrop using real zip files", () => {
try {
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
console.log(`Output Path: ${outputPath}`);
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "test.txt")).toBe(true);
} catch (err) {
console.log(err);
} finally {
}
});

View File

@@ -0,0 +1 @@
/etc/passwd

View File

@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
overrides: Record<string, boolean> = {},
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
canCreateProjects: overrides.canCreateProjects ?? false,
canDeleteProjects: overrides.canDeleteProjects ?? false,
canCreateServices: overrides.canCreateServices ?? false,
canDeleteServices: overrides.canDeleteServices ?? false,
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
canAccessToDocker: overrides.canAccessToDocker ?? false,
canAccessToAPI: overrides.canAccessToAPI ?? false,
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { checkPermission } = await import("@dokploy/server/services/permission");
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("static roles bypass enterprise resources", () => {
it("owner bypasses deployment.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { deployment: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses backup.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { backup: ["create"] }),
).resolves.toBeUndefined();
});
it("member bypasses schedule.delete", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { schedule: ["delete"] }),
).resolves.toBeUndefined();
});
it("member bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, {
deployment: ["read"],
backup: ["create"],
domain: ["delete"],
}),
).resolves.toBeUndefined();
});
});
describe("static roles validate free-tier resources", () => {
it("owner passes project.create", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { project: ["create"] }),
).resolves.toBeUndefined();
});
it("member fails project.create (no legacy override)", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { project: ["create"] }),
).rejects.toThrow();
});
it("member passes service.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { service: ["read"] }),
).resolves.toBeUndefined();
});
it("member fails service.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { service: ["create"] }),
).rejects.toThrow();
});
});
describe("legacy boolean overrides for member", () => {
it("member passes project.create with canCreateProjects=true", async () => {
memberToReturn = mockMemberData("member", { canCreateProjects: true });
await expect(
checkPermission(ctx, { project: ["create"] }),
).resolves.toBeUndefined();
});
it("member passes docker.read with canAccessToDocker=true", async () => {
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
await expect(
checkPermission(ctx, { docker: ["read"] }),
).resolves.toBeUndefined();
});
it("member fails docker.read with canAccessToDocker=false", async () => {
memberToReturn = mockMemberData("member");
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
});
});

View File

@@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import {
enterpriseOnlyResources,
statements,
} from "@dokploy/server/lib/access-control";
const FREE_TIER_RESOURCES = [
"organization",
"member",
"invitation",
"team",
"ac",
"project",
"service",
"environment",
"docker",
"sshKeys",
"gitProviders",
"traefikFiles",
"api",
];
const ENTERPRISE_RESOURCES = [
"volume",
"deployment",
"envVars",
"projectEnvVars",
"environmentEnvVars",
"server",
"registry",
"certificate",
"backup",
"volumeBackup",
"schedule",
"domain",
"destination",
"notification",
"logs",
"monitoring",
"auditLog",
];
describe("enterpriseOnlyResources set", () => {
it("contains all enterprise resources", () => {
for (const resource of ENTERPRISE_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(true);
}
});
it("does NOT contain free-tier resources", () => {
for (const resource of FREE_TIER_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(false);
}
});
it("every resource in statements is either free or enterprise", () => {
const allResources = Object.keys(statements);
for (const resource of allResources) {
const isFree = FREE_TIER_RESOURCES.includes(resource);
const isEnterprise = enterpriseOnlyResources.has(resource);
expect(isFree || isEnterprise).toBe(true);
}
});
it("free and enterprise sets don't overlap", () => {
for (const resource of FREE_TIER_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(false);
}
});
it("all statement resources are accounted for", () => {
const allResources = Object.keys(statements);
const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES];
for (const resource of allResources) {
expect(categorized).toContain(resource);
}
});
});

View File

@@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
overrides: Record<string, boolean> = {},
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
canCreateProjects: overrides.canCreateProjects ?? false,
canDeleteProjects: overrides.canDeleteProjects ?? false,
canCreateServices: overrides.canCreateServices ?? false,
canDeleteServices: overrides.canDeleteServices ?? false,
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
canAccessToDocker: overrides.canAccessToDocker ?? false,
canAccessToAPI: overrides.canAccessToAPI ?? false,
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { resolvePermissions } = await import(
"@dokploy/server/services/permission"
);
const { enterpriseOnlyResources, statements } = await import(
"@dokploy/server/lib/access-control"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("enterprise resources for static roles", () => {
it("owner gets true for all enterprise resources", async () => {
memberToReturn = mockMemberData("owner");
const perms = await resolvePermissions(ctx);
for (const resource of enterpriseOnlyResources) {
const actions = statements[resource as keyof typeof statements];
for (const action of actions) {
expect((perms as any)[resource][action]).toBe(true);
}
}
});
it("admin gets true for all enterprise resources", async () => {
memberToReturn = mockMemberData("admin");
const perms = await resolvePermissions(ctx);
for (const resource of enterpriseOnlyResources) {
const actions = statements[resource as keyof typeof statements];
for (const action of actions) {
expect((perms as any)[resource][action]).toBe(true);
}
}
});
it("member gets true for service-level enterprise resources", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.deployment.read).toBe(true);
expect(perms.deployment.create).toBe(true);
expect(perms.domain.read).toBe(true);
expect(perms.backup.read).toBe(true);
expect(perms.logs.read).toBe(true);
expect(perms.monitoring.read).toBe(true);
});
it("member gets false for org-level enterprise resources", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(false);
expect(perms.registry.read).toBe(false);
expect(perms.certificate.read).toBe(false);
expect(perms.destination.read).toBe(false);
expect(perms.notification.read).toBe(false);
expect(perms.auditLog.read).toBe(false);
});
});
describe("free-tier resources for member", () => {
it("member gets service.read=true", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.service.read).toBe(true);
});
it("member gets project.create=false without legacy override", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(false);
});
it("member gets project.create=true with canCreateProjects", async () => {
memberToReturn = mockMemberData("member", { canCreateProjects: true });
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(true);
});
it("member gets docker.read=false without legacy override", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.docker.read).toBe(false);
});
it("member gets docker.read=true with canAccessToDocker", async () => {
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
const perms = await resolvePermissions(ctx);
expect(perms.docker.read).toBe(true);
});
});
describe("free-tier resources for owner", () => {
it("owner gets all free-tier permissions as true", async () => {
memberToReturn = mockMemberData("owner");
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(true);
expect(perms.project.delete).toBe(true);
expect(perms.service.create).toBe(true);
expect(perms.service.read).toBe(true);
expect(perms.service.delete).toBe(true);
expect(perms.docker.read).toBe(true);
expect(perms.traefikFiles.read).toBe(true);
expect(perms.traefikFiles.write).toBe(true);
});
});

View File

@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
accessedServices: string[] = [],
accessedProjects: string[] = [],
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects,
accessedServices,
accessedEnvironments: [] as string[],
canCreateProjects: false,
canDeleteProjects: false,
canCreateServices: false,
canDeleteServices: false,
canCreateEnvironments: false,
canDeleteEnvironments: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { checkServicePermissionAndAccess, checkServiceAccess } = await import(
"@dokploy/server/services/permission"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("checkServicePermissionAndAccess", () => {
it("owner bypasses accessedServices check", async () => {
memberToReturn = mockMemberData("owner", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).resolves.toBeUndefined();
});
it("admin bypasses accessedServices check", async () => {
memberToReturn = mockMemberData("admin", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
backup: ["create"],
}),
).resolves.toBeUndefined();
});
it("member with access to service passes", async () => {
memberToReturn = mockMemberData("member", ["service-123"]);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).resolves.toBeUndefined();
});
it("member WITHOUT access to service fails", async () => {
memberToReturn = mockMemberData("member", ["other-service"]);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).rejects.toThrow("You don't have access to this service");
});
it("member with empty accessedServices fails", async () => {
memberToReturn = mockMemberData("member", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
domain: ["delete"],
}),
).rejects.toThrow("You don't have access to this service");
});
});
describe("checkServiceAccess", () => {
it("member with service access passes read check", async () => {
memberToReturn = mockMemberData("member", ["app-1"]);
await expect(
checkServiceAccess(ctx, "app-1", "read"),
).resolves.toBeUndefined();
});
it("member without service access fails read check", async () => {
memberToReturn = mockMemberData("member", []);
await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow(
"You don't have access to this service",
);
});
it("owner bypasses all access checks", async () => {
memberToReturn = mockMemberData("owner", [], []);
await expect(
checkServiceAccess(ctx, "project-1", "create"),
).resolves.toBeUndefined();
});
});

View File

@@ -12,7 +12,11 @@ vi.mock("@dokploy/server/db", () => {
chain.where = () => chain;
chain.values = () => chain;
chain.returning = () => Promise.resolve([{}]);
chain.then = undefined;
chain.from = () => chain;
chain.innerJoin = () => chain;
chain.then = (resolve: (value: unknown) => void) => {
resolve([]);
};
const tableMock = {
findFirst: vi.fn(() => Promise.resolve(undefined)),
@@ -21,7 +25,6 @@ vi.mock("@dokploy/server/db", () => {
update: vi.fn(() => chain),
delete: vi.fn(() => chain),
};
const createQueryMock = () => tableMock;
return {
db: {

View File

@@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = {
urlCallback: "",
},
},
whitelabelingConfig: {
appName: null,
appDescription: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
loginLogoUrl: null,
supportUrl: null,
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
footerText: null,
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,

View File

@@ -0,0 +1,81 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
const BASE = "/base";
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@dokploy/server/constants")>();
return {
...actual,
paths: () => ({
...actual.paths(),
BASE_PATH: BASE,
LOGS_PATH: `${BASE}/logs`,
APPLICATIONS_PATH: `${BASE}/applications`,
}),
};
});
// Import after mock so paths() uses our BASE
const { readValidDirectory } = await import("@dokploy/server");
describe("readValidDirectory (path traversal)", () => {
it("returns true when directory is exactly BASE_PATH", () => {
expect(readValidDirectory(BASE)).toBe(true);
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
});
it("returns true when directory is under BASE_PATH", () => {
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
});
it("returns false for path traversal escaping base (absolute)", () => {
expect(readValidDirectory("/etc/passwd")).toBe(false);
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
expect(readValidDirectory("/tmp/outside")).toBe(false);
});
it("returns false when resolved path escapes base via ..", () => {
// Resolved: /etc/passwd (outside /base)
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
});
it("returns true when .. stays within base", () => {
// e.g. /base/logs/../applications -> /base/applications (still under /base)
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
});
it("accepts serverId for remote base path", () => {
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
expect(readValidDirectory(BASE, "server-1")).toBe(true);
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
});
it("returns false for null/undefined-like paths that resolve outside", () => {
// Paths that might resolve to cwd or root
expect(readValidDirectory(".")).toBe(false);
expect(readValidDirectory("..")).toBe(false);
});
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
expect(readValidDirectory(`${BASE}/`)).toBe(true);
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
});
it("returns false when path looks like base but is a sibling or prefix", () => {
expect(readValidDirectory("/base-evil")).toBe(false);
expect(readValidDirectory("/bas")).toBe(false);
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
});
it("returns false for empty string (resolves to cwd)", () => {
expect(readValidDirectory("")).toBe(false);
});
});

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import {
isValidContainerId,
isValidSearch,
isValidSince,
isValidTail,
} from "../../server/wss/utils";
describe("isValidTail (docker-container-logs)", () => {
it("accepts valid numeric tail values", () => {
expect(isValidTail("0")).toBe(true);
expect(isValidTail("1")).toBe(true);
expect(isValidTail("100")).toBe(true);
expect(isValidTail("10000")).toBe(true);
});
it("rejects tail above 10000", () => {
expect(isValidTail("10001")).toBe(false);
expect(isValidTail("99999")).toBe(false);
});
it("rejects non-numeric tail", () => {
expect(isValidTail("")).toBe(false);
expect(isValidTail("abc")).toBe(false);
expect(isValidTail("10a")).toBe(false);
expect(isValidTail("-1")).toBe(false);
});
it("rejects command injection payloads in tail", () => {
expect(isValidTail("10; whoami; #")).toBe(false);
expect(isValidTail("100 | cat /etc/passwd")).toBe(false);
expect(isValidTail("$(id)")).toBe(false);
expect(isValidTail("`id`")).toBe(false);
expect(isValidTail("100\nid")).toBe(false);
expect(isValidTail("100 && id")).toBe(false);
expect(isValidTail("100; env | grep DATABASE")).toBe(false);
});
});
describe("isValidSince (docker-container-logs)", () => {
it("accepts 'all'", () => {
expect(isValidSince("all")).toBe(true);
});
it("accepts valid duration format (number + s|m|h|d)", () => {
expect(isValidSince("5s")).toBe(true);
expect(isValidSince("10m")).toBe(true);
expect(isValidSince("1h")).toBe(true);
expect(isValidSince("2d")).toBe(true);
expect(isValidSince("0s")).toBe(true);
expect(isValidSince("999d")).toBe(true);
});
it("rejects invalid duration format", () => {
expect(isValidSince("")).toBe(false);
expect(isValidSince("5")).toBe(false);
expect(isValidSince("s")).toBe(false);
expect(isValidSince("5x")).toBe(false);
expect(isValidSince("5sec")).toBe(false);
expect(isValidSince("5 m")).toBe(false);
});
it("rejects command injection payloads in since", () => {
expect(isValidSince("5s; whoami")).toBe(false);
expect(isValidSince("all; id")).toBe(false);
expect(isValidSince("1m$(id)")).toBe(false);
expect(isValidSince("1m | cat /etc/passwd")).toBe(false);
});
});
describe("isValidSearch (docker-container-logs)", () => {
it("accepts empty string", () => {
expect(isValidSearch("")).toBe(true);
});
it("accepts only alphanumeric, space, dot, underscore, hyphen", () => {
expect(isValidSearch("error")).toBe(true);
expect(isValidSearch("foo bar")).toBe(true);
expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true);
expect(isValidSearch("")).toBe(true);
});
it("rejects strings longer than 500 chars", () => {
expect(isValidSearch("a".repeat(501))).toBe(false);
expect(isValidSearch("a".repeat(500))).toBe(true);
});
it("rejects control characters and non-printable", () => {
expect(isValidSearch("foo\nbar")).toBe(false);
expect(isValidSearch("foo\rbar")).toBe(false);
expect(isValidSearch("\x00")).toBe(false);
expect(isValidSearch("a\x19b")).toBe(false);
});
it("rejects command injection vectors in search (search is concatenated into shell)", () => {
// Double-quoted context (SSH line 99): $ and ` execute
expect(isValidSearch("$(whoami)")).toBe(false);
expect(isValidSearch("`id`")).toBe(false);
expect(isValidSearch("$(id)")).toBe(false);
// Single-quoted context (local line 153): ' breaks out
expect(isValidSearch("'$(whoami)'")).toBe(false);
expect(isValidSearch("error'")).toBe(false);
expect(isValidSearch("'; whoami; #")).toBe(false);
// Other shell-metacharacters
expect(isValidSearch("error; id")).toBe(false);
expect(isValidSearch("a|b")).toBe(false);
expect(isValidSearch('error"')).toBe(false);
expect(isValidSearch("a&b")).toBe(false);
});
});
describe("isValidContainerId (docker-container-logs)", () => {
it("accepts valid hex container IDs", () => {
expect(isValidContainerId("a".repeat(12))).toBe(true);
expect(isValidContainerId("abc123def456")).toBe(true);
expect(isValidContainerId("a".repeat(64))).toBe(true);
});
it("accepts valid container names", () => {
expect(isValidContainerId("my-container")).toBe(true);
expect(isValidContainerId("app_1")).toBe(true);
expect(isValidContainerId("service.name")).toBe(true);
});
it("rejects command injection in container ID", () => {
expect(isValidContainerId("dummy; whoami")).toBe(false);
expect(isValidContainerId("$(id)")).toBe(false);
expect(isValidContainerId("`id`")).toBe(false);
expect(isValidContainerId("container|cat /etc/passwd")).toBe(false);
expect(isValidContainerId("x; env | grep DATABASE")).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Server } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -73,7 +73,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
@@ -236,7 +236,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
)}
<div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit">
<Button isLoading={isPending} type="submit" className="w-fit">
Save
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";

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,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form";
@@ -50,7 +50,7 @@ export const AddCommand = ({ applicationId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.application.update.useMutation();
const { mutateAsync, isPending } = api.application.update.useMutation();
const form = useForm<AddCommand>({
defaultValues: {
@@ -177,7 +177,7 @@ export const AddCommand = ({ applicationId }: Props) => {
</div>
</div>
<div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit">
<Button isLoading={isPending} type="submit" className="w-fit">
Save
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Code2, Globe2, HardDrive } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -69,11 +69,11 @@ export const ShowImport = ({ composeId }: Props) => {
} | null>(null);
const utils = api.useUtils();
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } =
const { mutateAsync: processTemplate, isPending: isLoadingTemplate } =
api.compose.processTemplate.useMutation();
const {
mutateAsync: importTemplate,
isLoading: isImporting,
isPending: isImporting,
isSuccess: isImportSuccess,
} = api.compose.import.useMutation();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
@@ -35,13 +35,9 @@ import { api } from "@/utils/api";
const AddPortSchema = z.object({
publishedPort: z.number().int().min(1).max(65535),
publishMode: z.enum(["ingress", "host"], {
required_error: "Publish mode is required",
}),
publishMode: z.enum(["ingress", "host"]),
targetPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"], {
required_error: "Protocol is required",
}),
protocol: z.enum(["tcp", "udp"]),
});
type AddPort = z.infer<typeof AddPortSchema>;
@@ -68,7 +64,7 @@ export const HandlePorts = ({
enabled: !!portId,
},
);
const { mutateAsync, isLoading, error, isError } = portId
const { mutateAsync, isPending, error, isError } = portId
? api.port.update.useMutation()
: api.port.create.useMutation();
@@ -270,7 +266,7 @@ export const HandlePorts = ({
<DialogFooter>
<Button
isLoading={isLoading}
isLoading={isPending}
form="hook-form-add-port"
type="submit"
>

View File

@@ -25,7 +25,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
{ enabled: !!applicationId },
);
const { mutateAsync: deletePort, isLoading: isRemoving } =
const { mutateAsync: deletePort, isPending: isRemoving } =
api.port.delete.useMutation();
return (

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -100,11 +100,11 @@ export const HandleRedirect = ({
const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } = redirectId
const { mutateAsync, isPending, error, isError } = redirectId
? api.redirects.update.useMutation()
: api.redirects.create.useMutation();
const form = useForm<AddRedirect>({
const form = useForm({
defaultValues: {
permanent: false,
regex: "",
@@ -268,7 +268,7 @@ export const HandleRedirect = ({
<DialogFooter>
<Button
isLoading={isLoading}
isLoading={isPending}
form="hook-form-add-redirect"
type="submit"
>

View File

@@ -24,7 +24,7 @@ export const ShowRedirects = ({ applicationId }: Props) => {
{ enabled: !!applicationId },
);
const { mutateAsync: deleteRedirect, isLoading: isRemoving } =
const { mutateAsync: deleteRedirect, isPending: isRemoving } =
api.redirects.delete.useMutation();
const utils = api.useUtils();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -46,7 +46,7 @@ export const HandleSecurity = ({
}: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data } = api.security.one.useQuery(
const { data, refetch } = api.security.one.useQuery(
{
securityId: securityId ?? "",
},
@@ -55,7 +55,7 @@ export const HandleSecurity = ({
},
);
const { mutateAsync, isLoading, error, isError } = securityId
const { mutateAsync, isPending, error, isError } = securityId
? api.security.update.useMutation()
: api.security.create.useMutation();
@@ -88,6 +88,7 @@ export const HandleSecurity = ({
await utils.application.readTraefikConfig.invalidate({
applicationId,
});
await refetch();
setIsOpen(false);
})
.catch(() => {
@@ -163,7 +164,7 @@ export const HandleSecurity = ({
<DialogFooter>
<Button
isLoading={isLoading}
isLoading={isPending}
form="hook-form-add-security"
type="submit"
>

View File

@@ -27,7 +27,7 @@ export const ShowSecurity = ({ applicationId }: Props) => {
{ enabled: !!applicationId },
);
const { mutateAsync: deleteSecurity, isLoading: isRemoving } =
const { mutateAsync: deleteSecurity, isPending: isRemoving } =
api.security.delete.useMutation();
const utils = api.useUtils();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Server } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -74,7 +74,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
const { data: buildServers } = api.server.buildServers.useQuery();
const { data: registries } = api.registry.all.useQuery();
const { mutateAsync, isLoading } = api.application.update.useMutation();
const { mutateAsync, isPending } = api.application.update.useMutation();
const form = useForm<Schema>({
defaultValues: {
@@ -274,7 +274,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
/>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
<Button isLoading={isPending} type="submit">
Save
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { InfoIcon, Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form";
@@ -128,11 +128,11 @@ export const ShowResources = ({ id, type }: Props) => {
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<AddResources>({
const form = useForm({
defaultValues: {
cpuLimit: "",
cpuReservation: "",
@@ -452,6 +452,11 @@ export const ShowResources = ({ id, type }: Props) => {
min={-1}
placeholder="65535"
{...field}
value={
typeof field.value === "number"
? field.value
: ""
}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
@@ -475,6 +480,11 @@ export const ShowResources = ({ id, type }: Props) => {
min={-1}
placeholder="65535"
{...field}
value={
typeof field.value === "number"
? field.value
: ""
}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
@@ -507,7 +517,7 @@ export const ShowResources = ({ id, type }: Props) => {
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
<Button isLoading={isPending} type="submit">
Save
</Button>
</div>

View File

@@ -15,13 +15,17 @@ interface Props {
}
export const ShowTraefikConfig = ({ applicationId }: Props) => {
const { data, isLoading } = api.application.readTraefikConfig.useQuery(
const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.traefikFiles.read ?? false;
const { data, isPending } = api.application.readTraefikConfig.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
{ enabled: !!applicationId && canRead },
);
if (!canRead) return null;
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between">
@@ -35,7 +39,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isLoading ? (
{isPending ? (
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
Loading...
<Loader2 className="animate-spin" />

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -7,6 +7,7 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -24,7 +25,6 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
@@ -60,6 +60,8 @@ export const validateAndFormatYAML = (yamlText: string) => {
};
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.traefikFiles.write ?? false;
const [open, setOpen] = useState(false);
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
const { data, refetch } = api.application.readTraefikConfig.useQuery(
@@ -69,7 +71,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
{ enabled: !!applicationId },
);
const { mutateAsync, isLoading, error, isError } =
const { mutateAsync, isPending, error, isError } =
api.application.updateTraefikConfig.useMutation();
const form = useForm<UpdateTraefikConfig>({
@@ -125,9 +127,11 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
}
}}
>
<DialogTrigger asChild>
<Button isLoading={isLoading}>Modify</Button>
</DialogTrigger>
{canWrite && (
<DialogTrigger asChild>
<Button isLoading={isPending}>Modify</Button>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Update traefik config</DialogTitle>
@@ -198,7 +202,7 @@ routers:
</p>
</div>
<Button
isLoading={isLoading}
isLoading={isPending}
form="hook-form-update-traefik-config"
type="submit"
>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PlusIcon } from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react";

View File

@@ -21,6 +21,13 @@ interface Props {
}
export const ShowVolumes = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.volume.read ?? false;
const canCreate = permissions?.volume.create ?? false;
const canDelete = permissions?.volume.delete ?? false;
if (!canRead) return null;
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
@@ -37,7 +44,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const { mutateAsync: deleteVolume, isLoading: isRemoving } =
const { mutateAsync: deleteVolume, isPending: isRemoving } =
api.mounts.remove.useMutation();
return (
<Card className="bg-background">
@@ -50,7 +57,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
</CardDescription>
</div>
{data && data?.mounts.length > 0 && (
{canCreate && data && data?.mounts.length > 0 && (
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume
</AddVolumes>
@@ -63,9 +70,11 @@ export const ShowVolumes = ({ id, type }: Props) => {
<span className="text-base text-muted-foreground">
No volumes/mounts configured
</span>
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume
</AddVolumes>
{canCreate && (
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume
</AddVolumes>
)}
</div>
) : (
<div className="flex flex-col pt-2 gap-4">
@@ -130,38 +139,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
</div>
</div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType={type}
/>
<DialogAction
title="Delete Volume"
description="Are you sure you want to delete this volume?"
type="destructive"
onClick={async () => {
await deleteVolume({
mountId: mount.mountId,
})
.then(() => {
refetch();
toast.success("Volume deleted successfully");
{canCreate && (
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType={type}
/>
)}
{canDelete && (
<DialogAction
title="Delete Volume"
description="Are you sure you want to delete this volume?"
type="destructive"
onClick={async () => {
await deleteVolume({
mountId: mount.mountId,
})
.catch(() => {
toast.error("Error deleting volume");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
.then(() => {
refetch();
toast.success("Volume deleted successfully");
})
.catch(() => {
toast.error("Error deleting volume");
});
}}
>
<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>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -93,7 +93,7 @@ export const UpdateVolume = ({
},
);
const { mutateAsync, isLoading, error, isError } =
const { mutateAsync, isPending, error, isError } =
api.mounts.update.useMutation();
const form = useForm<UpdateMount>({
@@ -187,7 +187,7 @@ export const UpdateVolume = ({
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
isLoading={isLoading}
isLoading={isPending}
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
@@ -310,7 +310,7 @@ PORT=3000
</div>
<DialogFooter>
<Button
isLoading={isLoading}
isLoading={isPending}
// form="hook-form-update-volume"
type="submit"
>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Cog } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -74,12 +74,7 @@ const buildTypeDisplayMap: Record<BuildType, string> = {
const mySchema = z.discriminatedUnion("buildType", [
z.object({
buildType: z.literal(BuildType.dockerfile),
dockerfile: z
.string({
required_error: "Dockerfile path is required",
invalid_type_error: "Dockerfile path is required",
})
.min(1, "Dockerfile required"),
dockerfile: z.string().nullable().default(""),
dockerContextPath: z.string().nullable().default(""),
dockerBuildStage: z.string().nullable().default(""),
}),
@@ -168,14 +163,14 @@ const resetData = (data: ApplicationData): AddTemplate => {
};
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } =
const { mutateAsync, isPending } =
api.application.saveBuildType.useMutation();
const { data, refetch } = api.application.one.useQuery(
{ applicationId },
{ enabled: !!applicationId },
);
const form = useForm<AddTemplate>({
const form = useForm({
defaultValues: {
buildType: BuildType.nixpacks,
},
@@ -347,7 +342,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
<FormLabel>Docker File</FormLabel>
<FormControl>
<Input
placeholder="Path of your docker file"
placeholder="Path of your docker file (default: Dockerfile)"
{...field}
value={field.value ?? ""}
/>
@@ -533,7 +528,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
</>
)}
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
<Button isLoading={isPending} type="submit">
Save
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import { Paintbrush } from "lucide-react";
import { Ban } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
@@ -20,7 +20,7 @@ interface Props {
}
export const CancelQueues = ({ id, type }: Props) => {
const { mutateAsync, isLoading } =
const { mutateAsync, isPending } =
type === "application"
? api.application.cleanQueues.useMutation()
: api.compose.cleanQueues.useMutation();
@@ -33,9 +33,9 @@ export const CancelQueues = ({ id, type }: Props) => {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
<Button variant="destructive" className="w-fit" isLoading={isPending}>
Cancel Queues
<Paintbrush className="size-4" />
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>

View File

@@ -0,0 +1,73 @@
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
interface Props {
id: string;
type: "application" | "compose";
}
export const ClearDeployments = ({ id, type }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isPending } =
type === "application"
? api.application.clearDeployments.useMutation()
: api.compose.clearDeployments.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isPending}>
Clear deployments
<Paintbrush className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to clear old deployments?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all old deployment records and logs, keeping only
the active deployment (the most recent successful one).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
})
.then(async () => {
toast.success("Old deployments cleared successfully");
await utils.deployment.allByType.invalidate({
id,
type: type as "application" | "compose",
});
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -20,7 +20,7 @@ interface Props {
}
export const KillBuild = ({ id, type }: Props) => {
const { mutateAsync, isLoading } =
const { mutateAsync, isPending } =
type === "application"
? api.application.killBuild.useMutation()
: api.compose.killBuild.useMutation();
@@ -28,7 +28,7 @@ export const KillBuild = ({ id, type }: Props) => {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}>
<Button variant="outline" className="w-fit" isLoading={isPending}>
Kill Build
<Scissors className="size-4" />
</Button>

View File

@@ -194,13 +194,21 @@ export const ShowDeployment = ({
{" "}
{filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine key={index} log={log} noTimestamp />
<TerminalLine
key={`${log.rawTimestamp ?? ""}-${index}`}
log={log}
noTimestamp
/>
))
) : (
<>
{optionalErrors.length > 0 ? (
optionalErrors.map((log: LogLine, index: number) => (
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
<TerminalLine
key={`extra-${log.rawTimestamp ?? ""}-${index}`}
log={log}
noTimestamp
/>
))
) : (
<div className="flex justify-center items-center h-full text-muted-foreground">

View File

@@ -2,13 +2,16 @@ import {
ChevronDown,
ChevronUp,
Clock,
Copy,
Loader2,
RefreshCcw,
RocketIcon,
Settings,
Trash2,
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import copy from "copy-to-clipboard";
import { AlertBlock } from "@/components/shared/alert-block";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -25,6 +28,7 @@ import {
import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues";
import { ClearDeployments } from "./clear-deployments";
import { KillBuild } from "./kill-build";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
@@ -59,7 +63,7 @@ export const ShowDeployments = ({
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const { data: deployments, isLoading: isLoadingDeployments } =
const { data: deployments, isPending: isLoadingDeployments } =
api.deployment.allByType.useQuery(
{
id,
@@ -73,19 +77,21 @@ export const ShowDeployments = ({
const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync: rollback, isLoading: isRollingBack } =
const { mutateAsync: rollback, isPending: isRollingBack } =
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
const { mutateAsync: killProcess, isPending: isKillingProcess } =
api.deployment.killProcess.useMutation();
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
api.deployment.removeDeployment.useMutation();
// Cancel deployment mutations
const {
mutateAsync: cancelApplicationDeployment,
isLoading: isCancellingApp,
isPending: isCancellingApp,
} = api.application.cancelDeployment.useMutation();
const {
mutateAsync: cancelComposeDeployment,
isLoading: isCancellingCompose,
isPending: isCancellingCompose,
} = api.compose.cancelDeployment.useMutation();
const [url, setUrl] = React.useState("");
@@ -93,6 +99,12 @@ export const ShowDeployments = ({
new Set(),
);
const webhookUrl = useMemo(
() =>
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
[url, refreshToken, type],
);
const MAX_DESCRIPTION_LENGTH = 200;
const truncateDescription = (description: string): string => {
@@ -144,6 +156,9 @@ export const ShowDeployments = ({
</CardDescription>
</div>
<div className="flex flex-row items-center flex-wrap gap-2">
{(type === "application" || type === "compose") && (
<ClearDeployments id={id} type={type} />
)}
{(type === "application" || type === "compose") && (
<KillBuild id={id} type={type} />
)}
@@ -217,11 +232,27 @@ export const ShowDeployments = ({
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy${
type === "compose" ? "/compose" : ""
}/${refreshToken}`}
</span>
<Badge
role="button"
tabIndex={0}
aria-label="Copy webhook URL to clipboard"
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
variant="outline"
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
copy(webhookUrl);
toast.success("Copied to clipboard.");
}
}}
onClick={() => {
copy(webhookUrl);
toast.success("Copied to clipboard.");
}}
>
{webhookUrl}
<Copy className="h-4 w-4 ml-2" />
</Badge>
{(type === "application" || type === "compose") && (
<RefreshToken id={id} type={type} />
)}
@@ -252,6 +283,8 @@ export const ShowDeployments = ({
const isExpanded = expandedDescriptions.has(
deployment.deploymentId,
);
const canDelete =
deployment.status === "done" || deployment.status === "error";
return (
<div
@@ -370,6 +403,33 @@ export const ShowDeployments = ({
View
</Button>
{canDelete && (
<DialogAction
title="Delete Deployment"
description="Are you sure you want to delete this deployment? This action cannot be undone."
type="default"
onClick={async () => {
try {
await removeDeployment({
deploymentId: deployment.deploymentId,
});
toast.success("Deployment deleted successfully");
} catch (error) {
toast.error("Error deleting deployment");
}
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isRemovingDeployment}
>
Delete
<Trash2 className="size-4" />
</Button>
</DialogAction>
)}
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
@@ -159,11 +159,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
},
);
const { mutateAsync, isError, error, isLoading } = domainId
const { mutateAsync, isError, error, isPending } = domainId
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const { data: canGenerateTraefikMeDomains } =
@@ -240,7 +240,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
domainType: type,
});
}
}, [form, data, isLoading, domainId]);
}, [form, data, isPending, domainId]);
// Separate effect for handling custom cert resolver validation
useEffect(() => {
@@ -730,7 +730,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</form>
<DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit">
<Button isLoading={isPending} form="hook-form" type="submit">
{dictionary.submit}
</Button>
</DialogFooter>

View File

@@ -50,6 +50,9 @@ interface Props {
}
export const ShowDomains = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canCreateDomain = permissions?.domain.create ?? false;
const canDeleteDomain = permissions?.domain.delete ?? false;
const { data: application } =
type === "application"
? api.application.one.useQuery(
@@ -97,7 +100,7 @@ export const ShowDomains = ({ id, type }: Props) => {
const { mutateAsync: validateDomain } =
api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation();
const handleValidateDomain = async (host: string) => {
@@ -149,7 +152,7 @@ export const ShowDomains = ({ id, type }: Props) => {
</div>
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
{canCreateDomain && data && data?.length > 0 && (
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
@@ -173,13 +176,15 @@ export const ShowDomains = ({ id, type }: Props) => {
To access the application it is required to set at least 1
domain
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
</div>
{canCreateDomain && (
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
</div>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
@@ -214,47 +219,51 @@ export const ShowDomains = ({ id, type }: Props) => {
}
/>
)}
<AddDomain
id={id}
type={type}
domainId={item.domainId}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
{canCreateDomain && (
<AddDomain
id={id}
type={type}
domainId={item.domainId}
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<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 className="w-full break-all">

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type CSSProperties, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -36,6 +36,8 @@ interface Props {
}
export const ShowEnvironment = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.envVars.write ?? false;
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
@@ -60,7 +62,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
mongo: () => api.mongo.update.useMutation(),
compose: () => api.compose.update.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
@@ -111,7 +113,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -121,7 +123,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading]);
}, [form, onSubmit, isPending]);
return (
<div className="flex w-full flex-col gap-5 ">
@@ -185,25 +187,27 @@ PORT=3000
)}
/>
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
{canWrite && (
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button
type="button"
variant="outline"
onClick={handleCancel}
>
Cancel
</Button>
)}
<Button
type="button"
variant="outline"
onClick={handleCancel}
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Cancel
Save
</Button>
)}
<Button
isLoading={isLoading}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>
</div>
)}
</form>
</Form>
</CardContent>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -31,7 +31,9 @@ interface Props {
}
export const ShowEnvironment = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } =
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.envVars.write ?? false;
const { mutateAsync, isPending } =
api.application.saveEnvironment.useMutation();
const { data, refetch } = api.application.one.useQuery(
@@ -104,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -114,7 +116,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading]);
}, [form, onSubmit, isPending]);
return (
<Card className="bg-background px-6 pb-6">
@@ -201,27 +203,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canWrite}
/>
</FormControl>
</FormItem>
)}
/>
)}
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
{canWrite && (
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
)}
<Button
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
)}
<Button
isLoading={isLoading}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>
</div>
)}
</form>
</Form>
</Card>

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -74,10 +74,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
api.bitbucket.bitbucketProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingBitbucketProvider } =
const { mutateAsync, isPending: isSavingBitbucketProvider } =
api.application.saveBitbucketProvider.useMutation();
const form = useForm<BitbucketProvider>({
const form = useForm({
defaultValues: {
buildPath: "/",
repository: {
@@ -333,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -350,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
{status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>
@@ -416,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { TrashIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -24,10 +24,10 @@ interface Props {
export const SaveDragNDrop = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading } =
const { mutateAsync, isPending } =
api.application.dropDeployment.useMutation();
const form = useForm<UploadFile>({
const form = useForm({
defaultValues: {},
resolver: zodResolver(uploadFileSchema),
});
@@ -129,8 +129,8 @@ export const SaveDragNDrop = ({ applicationId }: Props) => {
<Button
type="submit"
className="w-fit"
isLoading={isLoading}
disabled={!zip || isLoading}
isLoading={isPending}
disabled={!zip || isPending}
>
Deploy{" "}
</Button>

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
@@ -58,10 +58,10 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isLoading } =
const { mutateAsync, isPending } =
api.application.saveGitProvider.useMutation();
const form = useForm<GitProvider>({
const form = useForm({
defaultValues: {
branch: "",
buildPath: "/",
@@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
@@ -317,7 +315,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</div>
<div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}>
<Button type="submit" className="w-fit" isLoading={isPending}>
Save
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -88,10 +88,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGiteaProvider } =
const { mutateAsync, isPending: isSavingGiteaProvider } =
api.application.saveGiteaProvider.useMutation();
const form = useForm<GiteaProvider>({
const form = useForm({
defaultValues: {
buildPath: "/",
repository: {
@@ -353,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -371,7 +371,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
{status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>
@@ -463,7 +463,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newPaths = [...field.value];
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
@@ -481,7 +481,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
field.onChange([...field.value, path]);
field.onChange([...(field.value || []), path]);
input.value = "";
}
}
@@ -498,7 +498,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
) as HTMLInputElement;
const path = input.value.trim();
if (path) {
field.onChange([...field.value, path]);
field.onChange([...(field.value || []), path]);
input.value = "";
}
}}

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -72,10 +72,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGithubProvider } =
const { mutateAsync, isPending: isSavingGithubProvider } =
api.application.saveGithubProvider.useMutation();
const form = useForm<GithubProvider>({
const form = useForm({
defaultValues: {
buildPath: "/",
repository: {
@@ -94,7 +94,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } =
const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
{
githubId,
@@ -320,7 +320,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -337,7 +337,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
{status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>
@@ -459,7 +459,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
key={`${path}-${index}`}
variant="secondary"
className="flex items-center gap-1"
>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo } from "react";
@@ -74,10 +74,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGitlabProvider } =
const { mutateAsync, isPending: isSavingGitlabProvider } =
api.application.saveGitlabProvider.useMutation();
const form = useForm<GitlabProvider>({
const form = useForm({
defaultValues: {
buildPath: "/",
repository: {
@@ -351,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -368,7 +368,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
{status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>
@@ -448,7 +448,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
key={`${path}-${index}`}
variant="secondary"
className="flex items-center gap-1"
>

View File

@@ -36,13 +36,13 @@ interface Props {
}
export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: githubProviders, isLoading: isLoadingGithub } =
const { data: githubProviders, isPending: isLoadingGithub } =
api.github.githubProviders.useQuery();
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
const { data: gitlabProviders, isPending: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders, isLoading: isLoadingGitea } =
const { data: giteaProviders, isPending: isLoadingGitea } =
api.gitea.giteaProviders.useQuery();
const { data: application, refetch } = api.application.one.useQuery({

View File

@@ -30,6 +30,9 @@ interface Props {
export const ShowGeneralApplication = ({ applicationId }: Props) => {
const router = useRouter();
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const canUpdateService = permissions?.service.create ?? false;
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
@@ -37,14 +40,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
{ enabled: !!applicationId },
);
const { mutateAsync: update } = api.application.update.useMutation();
const { mutateAsync: start, isLoading: isStarting } =
const { mutateAsync: start, isPending: isStarting } =
api.application.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } =
const { mutateAsync: stop, isPending: isStopping } =
api.application.stop.useMutation();
const { mutateAsync: deploy } = api.application.deploy.useMutation();
const { mutateAsync: reload, isLoading: isReloading } =
const { mutateAsync: reload, isPending: isReloading } =
api.application.reload.useMutation();
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
@@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
<DialogAction
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await deploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
);
{canDeploy && (
<DialogAction
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await deploy({
applicationId: applicationId,
})
.catch(() => {
toast.error("Error deploying application");
});
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying application");
});
}}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Downloads the source code and performs a complete build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Application reloaded successfully");
refetch();
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Downloads the source code and performs a complete
build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
})
.catch(() => {
toast.error("Error reloading application");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
.then(() => {
toast.success("Application reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading application");
});
}}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the application without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the application without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Hammer className="size-4 mr-1" />
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Only rebuilds the application without downloading new
code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Hammer className="size-4 mr-1" />
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Only rebuilds the application without downloading new
code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{data?.applicationStatus === "idle" ? (
{canDeploy && data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Application"
description="Are you sure you want to start this application?"
@@ -219,7 +229,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</Tooltip>
</Button>
</DialogAction>
) : (
) : canDeploy ? (
<DialogAction
title="Stop Application"
description="Are you sure you want to stop this application?"
@@ -256,7 +266,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</Tooltip>
</Button>
</DialogAction>
)}
) : null}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
@@ -270,49 +280,53 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
Open Terminal
</Button>
</DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
{canUpdateService && (
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
autoDeploy: enabled,
})
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Clean Cache</span>
<Switch
aria-label="Toggle clean cache"
checked={data?.cleanCache || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
cleanCache: enabled,
})
.then(async () => {
toast.success("Clean Cache Updated");
await refetch();
{canUpdateService && (
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Clean Cache</span>
<Switch
aria-label="Toggle clean cache"
checked={data?.cleanCache || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
cleanCache: enabled,
})
.catch(() => {
toast.error("Error updating Clean Cache");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
.then(async () => {
toast.success("Clean Cache Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating Clean Cache");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
</CardContent>
</Card>
<ShowProviderForm applicationId={applicationId} />

View File

@@ -56,7 +56,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native");
const { data: services, isLoading: servicesLoading } =
const { data: services, isPending: servicesLoading } =
api.docker.getServiceContainersByAppName.useQuery(
{
appName,
@@ -67,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
},
);
const { data: containers, isLoading: containersLoading } =
const { data: containers, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,

View File

@@ -0,0 +1,107 @@
import { FilePlus } from "lucide-react";
import { useState } from "react";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Props {
folderPath: string;
onCreate: (filename: string, content: string) => void;
onOpenChange: (open: boolean) => void;
alwaysVisible?: boolean;
}
export const CreateFileDialog = ({
folderPath,
onCreate,
onOpenChange,
alwaysVisible = false,
}: Props) => {
const [filename, setFilename] = useState("");
const [content, setContent] = useState("");
const handleCreate = () => {
if (!filename.trim()) return;
onCreate(filename.trim(), content);
setFilename("");
setContent("");
onOpenChange(false);
};
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
type="button"
className={`h-6 w-6 ${alwaysVisible ? "" : "opacity-0 group-hover:opacity-100"}`}
title="Create file"
>
<FilePlus className="h-3 w-3" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<form
onSubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<DialogHeader>
<DialogTitle>Create file</DialogTitle>
<DialogDescription>
{folderPath ? `New file in ${folderPath}/` : "New file in root"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="filename">Filename</Label>
<Input
id="filename"
placeholder="e.g. .env.example"
value={filename}
onChange={(e) => setFilename(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Content</Label>
<div className="h-[200px] rounded-md border">
<CodeEditor
value={content}
onChange={(v) => setContent(v ?? "")}
className="h-full"
wrapperClassName="h-[200px]"
lineWrapping
/>
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" type="button">
Cancel
</Button>
</DialogClose>
<DialogClose asChild>
<Button type="submit" disabled={!filename.trim()}>
Create
</Button>
</DialogClose>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,102 @@
import { Loader2, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
interface Props {
patchId: string;
entityId: string;
type: "application" | "compose";
onSuccess?: () => void;
}
export const EditPatchDialog = ({
patchId,
entityId,
type,
onSuccess,
}: Props) => {
const { data: patch, isPending: isPatchLoading } = api.patch.one.useQuery(
{ patchId },
{ enabled: !!patchId },
);
const [content, setContent] = useState("");
useEffect(() => {
if (patch) {
setContent(patch.content);
}
}, [patch]);
const utils = api.useUtils();
const updatePatch = api.patch.update.useMutation();
const handleSave = () => {
updatePatch
.mutateAsync({ patchId, content })
.then(() => {
toast.success("Patch saved");
utils.patch.byEntityId.invalidate({ id: entityId, type });
onSuccess?.();
})
.catch((err) => {
toast.error(err.message);
});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title="Edit patch">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>Edit Patch</DialogTitle>
<DialogDescription>
{patch ? `Editing: ${patch.filePath}` : "Loading patch..."}
</DialogDescription>
</DialogHeader>
{isPatchLoading ? (
<div className="flex flex-1 items-center justify-center px-6 py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex-1 min-h-0 px-6 overflow-hidden flex flex-col">
<CodeEditor
value={content}
onChange={(value) => setContent(value ?? "")}
className="h-[400px] w-full"
wrapperClassName="h-[400px]"
lineWrapping
/>
</div>
)}
<DialogFooter className="px-6 ">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={handleSave} isLoading={updatePatch.isPending}>
{updatePatch.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./show-patches";
export * from "./patch-editor";

View File

@@ -0,0 +1,368 @@
import {
ArrowLeft,
ChevronRight,
File,
Folder,
Loader2,
Save,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { api } from "@/utils/api";
import { CreateFileDialog } from "./create-file-dialog";
interface Props {
id: string;
type: "application" | "compose";
repoPath: string;
onClose: () => void;
}
type DirectoryEntry = {
name: string;
path: string;
type: "file" | "directory";
children?: DirectoryEntry[];
};
export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [createFolderPath, setCreateFolderPath] = useState<string | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(),
);
const utils = api.useUtils();
const { data: directories, isPending: isDirLoading } =
api.patch.readRepoDirectories.useQuery(
{ id: id, type, repoPath },
{ enabled: !!repoPath },
);
const { data: patches } = api.patch.byEntityId.useQuery(
{ id, type },
{ enabled: !!id },
);
const { mutateAsync: saveAsPatch, isPending: isSavingPatch } =
api.patch.saveFileAsPatch.useMutation();
const { mutateAsync: markForDeletion, isPending: isMarkingDeletion } =
api.patch.markFileForDeletion.useMutation();
const updatePatch = api.patch.update.useMutation();
const { data: fileData, isFetching: isFileLoading } =
api.patch.readRepoFile.useQuery(
{
id,
type,
filePath: selectedFile || "",
},
{
enabled: !!selectedFile,
},
);
useEffect(() => {
if (fileData !== undefined) {
setFileContent(fileData);
}
}, [fileData]);
const handleFileSelect = (filePath: string) => {
setSelectedFile(filePath);
};
const toggleFolder = (path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
};
const handleSave = () => {
if (!selectedFile) return;
saveAsPatch({
id,
type,
filePath: selectedFile,
content: fileContent,
patchType: "update",
})
.then(() => {
toast.success("Patch saved");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to save patch");
});
};
const handleMarkForDeletion = () => {
if (!selectedFile) return;
markForDeletion({ id, type, filePath: selectedFile })
.then(() => {
toast.success("File marked for deletion");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to mark file for deletion");
});
};
const handleCreateFile = useCallback(
(folderPath: string, filename: string, content: string) => {
const filePath = folderPath ? `${folderPath}/${filename}` : filename;
saveAsPatch({
id,
type,
filePath,
content,
patchType: "create",
})
.then(() => {
toast.success("File created");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to create file");
});
},
[id, type, saveAsPatch, utils],
);
const selectedFilePatch = patches?.find(
(p) => p.filePath === selectedFile && p.type === "delete",
);
const handleUnmarkDeletion = () => {
if (!selectedFilePatch) return;
updatePatch
.mutateAsync({
patchId: selectedFilePatch.patchId,
type: "update",
content: fileData || "",
})
.then(() => {
toast.success("Deletion unmarked");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to unmark deletion");
});
};
const hasChanges = fileData !== undefined && fileContent !== fileData;
const renderTree = useCallback(
(entries: DirectoryEntry[], depth = 0) => {
return entries
.sort((a, b) => {
// Directories first, then alphabetically
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
.map((entry) => {
const isExpanded = expandedFolders.has(entry.path);
const isSelected = selectedFile === entry.path;
if (entry.type === "directory") {
return (
<div key={entry.path}>
<div className="group flex items-center">
<button
type="button"
onClick={() => toggleFolder(entry.path)}
className={
"flex-1 flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors text-left min-w-0"
}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<ChevronRight
className={`h-4 w-4 shrink-0 transition-transform ${
isExpanded ? "rotate-90" : ""
}`}
/>
<Folder className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate">{entry.name}</span>
</button>
<CreateFileDialog
folderPath={entry.path}
onCreate={(filename, content) =>
handleCreateFile(entry.path, filename, content)
}
onOpenChange={(open) =>
setCreateFolderPath(open ? entry.path : null)
}
/>
</div>
{isExpanded && entry.children && (
<div>{renderTree(entry.children, depth + 1)}</div>
)}
</div>
);
}
const isMarkedForDeletion = patches?.some(
(p) => p.filePath === entry.path && p.type === "delete",
);
return (
<button
type="button"
key={entry.path}
onClick={() => handleFileSelect(entry.path)}
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${
isSelected ? "bg-muted" : ""
} ${isMarkedForDeletion ? "text-destructive" : ""}`}
style={{ paddingLeft: `${depth * 12 + 28}px` }}
>
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{entry.name}</span>
{isMarkedForDeletion && (
<Trash2 className="h-3 w-3 shrink-0 text-destructive ml-auto" />
)}
</button>
);
});
},
[expandedFolders, selectedFile, patches, handleCreateFile],
);
return (
<Card className="bg-background overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<CardTitle>Edit File</CardTitle>
<CardDescription>
{selectedFile
? `Editing: ${selectedFile}`
: "Select a file from the tree to edit"}
</CardDescription>
</div>
</div>
{selectedFile && (
<div className="flex items-center gap-2">
{selectedFilePatch ? (
<Button
variant="outline"
size="sm"
onClick={handleUnmarkDeletion}
disabled={updatePatch.isPending}
>
{updatePatch.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Unmark deletion
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={handleMarkForDeletion}
disabled={isMarkingDeletion}
>
{isMarkingDeletion && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Trash2 className="mr-2 h-4 w-4" />
Mark for deletion
</Button>
<Button
onClick={handleSave}
disabled={isSavingPatch || !hasChanges}
>
{isSavingPatch && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
Save Patch
</Button>
</>
)}
</div>
)}
</CardHeader>
<CardContent className="p-0">
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
<div className="border-r h-full overflow-hidden">
<ScrollArea className="h-full">
<div className="p-2 space-y-1">
<div className="group flex items-center gap-2 px-2 py-1.5 mb-1">
<CreateFileDialog
folderPath=""
alwaysVisible
onCreate={(filename, content) =>
handleCreateFile("", filename, content)
}
onOpenChange={(open) =>
setCreateFolderPath(open ? "" : null)
}
/>
<span className="text-xs text-muted-foreground">
New file in root
</span>
</div>
{isDirLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : directories ? (
renderTree(directories)
) : (
<div className="text-sm text-muted-foreground p-4">
No files found
</div>
)}
</div>
</ScrollArea>
</div>
<div className="h-full overflow-hidden relative">
{isFileLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : selectedFile ? (
<CodeEditor
value={fileData || ""}
onChange={(value) => setFileContent(value || "")}
className="h-full w-full"
wrapperClassName="h-full"
lineWrapping
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
Select a file to edit
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,225 @@
import { File, FilePlus2, Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { EditPatchDialog } from "./edit-patch-dialog";
import { PatchEditor } from "./patch-editor";
interface Props {
id: string;
type: "application" | "compose";
}
export const ShowPatches = ({ id, type }: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [repoPath, setRepoPath] = useState<string | null>(null);
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
const utils = api.useUtils();
const { data: patches, isPending: isPatchesLoading } =
api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id });
const mutationMap = {
application: () => api.patch.delete.useMutation(),
compose: () => api.patch.delete.useMutation(),
};
const ensureRepo = api.patch.ensureRepo.useMutation();
const togglePatch = api.patch.toggleEnabled.useMutation();
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.patch.delete.useMutation();
const handleCloseEditor = () => {
setSelectedFile(null);
setRepoPath(null);
};
if (repoPath) {
return (
<PatchEditor
id={id}
type={type}
repoPath={repoPath || ""}
onClose={handleCloseEditor}
/>
);
}
const handleOpenEditor = async () => {
setIsLoadingRepo(true);
await ensureRepo
.mutateAsync({ id, type })
.then((result) => {
setRepoPath(result);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setIsLoadingRepo(false);
});
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Patches</CardTitle>
<CardDescription>
Apply code patches to your repository during build. Patches are
applied after cloning the repository and before building.
</CardDescription>
</div>
{patches && patches?.length > 0 && (
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<FilePlus2 className="mr-2 h-4 w-4" />
Create Patch
</Button>
)}
</CardHeader>
<CardContent>
{isPatchesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : patches?.length === 0 ? (
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<FilePlus2 className="h-10 w-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No patches yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Add file patches to modify your repo before each buildconfigs,
env, or code. Create your first patch to get started.
</p>
</div>
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
{isLoadingRepo && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<FilePlus2 className="mr-2 h-4 w-4" />
Create Patch
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>File Path</TableHead>
<TableHead className="w-[80px]">Type</TableHead>
<TableHead className="w-[100px]">Enabled</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{patches?.map((patch) => (
<TableRow key={patch.patchId}>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<File className="h-4 w-4 text-muted-foreground shrink-0" />
{patch.filePath}
</div>
</TableCell>
<TableCell>
<Badge
variant={
patch.type === "delete"
? "destructive"
: patch.type === "create"
? "default"
: "secondary"
}
className="font-normal"
>
{patch.type}
</Badge>
</TableCell>
<TableCell>
<Switch
checked={patch.enabled}
onCheckedChange={(checked) => {
togglePatch
.mutateAsync({
patchId: patch.patchId,
enabled: checked,
})
.then(() => {
toast.success("Patch updated");
utils.patch.byEntityId.invalidate({
id,
type,
});
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setIsLoadingRepo(false);
});
}}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{(patch.type === "update" || patch.type === "create") && (
<EditPatchDialog
patchId={patch.patchId}
entityId={id}
type={type}
/>
)}
<Button
variant="ghost"
size="icon"
onClick={() => {
mutateAsync({ patchId: patch.patchId })
.then(() => {
toast.success("Patch deleted");
utils.patch.byEntityId.invalidate({
id,
type,
});
})
.catch((err) => {
toast.error(err.message);
});
}}
title="Delete patch"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Dices } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -75,11 +75,11 @@ export const AddPreviewDomain = ({
},
);
const { mutateAsync, isError, error, isLoading } = domainId
const { mutateAsync, isError, error, isPending } = domainId
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const form = useForm<Domain>({
@@ -103,7 +103,7 @@ export const AddPreviewDomain = ({
if (!domainId) {
form.reset({});
}
}, [form, form.reset, data, isLoading]);
}, [form, form.reset, data, isPending]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
@@ -301,7 +301,7 @@ export const AddPreviewDomain = ({
</form>
<DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit">
<Button isLoading={isPending} form="hook-form" type="submit">
{dictionary.submit}
</Button>
</DialogFooter>

View File

@@ -43,7 +43,7 @@ interface Props {
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { data } = api.application.one.useQuery({ applicationId });
const { mutateAsync: deletePreviewDeployment, isLoading } =
const { mutateAsync: deletePreviewDeployment, isPending } =
api.previewDeployment.delete.useMutation();
const { mutateAsync: redeployPreviewDeployment } =
@@ -57,8 +57,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
{ applicationId },
{
enabled: !!applicationId,
refetchInterval: (data) =>
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
refetchInterval: 2000,
},
);
@@ -282,7 +281,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
<Button
variant="ghost"
size="sm"
isLoading={isLoading}
isLoading={isPending}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="size-4" />

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, Plus, Settings2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -80,7 +80,7 @@ interface Props {
export const ShowPreviewSettings = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [isEnabled, setIsEnabled] = useState(false);
const { mutateAsync: updateApplication, isLoading } =
const { mutateAsync: updateApplication, isPending } =
api.application.update.useMutation();
const { data, refetch } = api.application.one.useQuery({ applicationId });
@@ -535,7 +535,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
Cancel
</Button>
<Button
isLoading={isLoading}
isLoading={isPending}
form="hook-form-delete-application"
type="submit"
>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -71,7 +71,7 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
},
);
const { mutateAsync: updateApplication, isLoading } =
const { mutateAsync: updateApplication, isPending } =
api.application.update.useMutation();
const { data: registries } = api.registry.all.useQuery();
@@ -212,7 +212,7 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
/>
)}
<Button type="submit" className="w-full" isLoading={isLoading}>
<Button type="submit" className="w-full" isLoading={isPending}>
Save Settings
</Button>
</form>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import {
CheckIcon,
ChevronsUpDown,
@@ -220,8 +220,8 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
const form = useForm({
resolver: standardSchemaResolver(formSchema),
defaultValues: {
name: "",
cronExpression: "",
@@ -275,11 +275,11 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
}
}, [form, schedule, scheduleId]);
const { mutateAsync, isLoading } = scheduleId
const { mutateAsync, isPending } = scheduleId
? api.schedule.update.useMutation()
: api.schedule.create.useMutation();
const onSubmit = async (values: z.infer<typeof formSchema>) => {
const onSubmit = async (values: z.output<typeof formSchema>) => {
if (!id && !scheduleId) return;
await mutateAsync({
@@ -662,7 +662,7 @@ echo "Hello, world!"
)}
/>
<Button type="submit" isLoading={isLoading} className="w-full">
<Button type="submit" isLoading={isPending} className="w-full">
{scheduleId ? "Update" : "Create"} Schedule
</Button>
</form>

View File

@@ -51,7 +51,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
},
);
const utils = api.useUtils();
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
const { mutateAsync: deleteSchedule, isPending: isDeleting } =
api.schedule.delete.useMutation();
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -43,7 +43,7 @@ interface Props {
export const UpdateApplication = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
const { mutateAsync, error, isError, isPending } =
api.application.update.useMutation();
const { data } = api.application.one.useQuery(
{
@@ -148,7 +148,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
/>
<DialogFooter>
<Button
isLoading={isLoading}
isLoading={isPending}
form="hook-form-update-application"
type="submit"
>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -116,7 +116,7 @@ export const HandleVolumeBackups = ({
const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
@@ -195,7 +195,7 @@ export const HandleVolumeBackups = ({
}
}, [form, volumeBackup, volumeBackupId]);
const { mutateAsync, isLoading } = volumeBackupId
const { mutateAsync, isPending } = volumeBackupId
? api.volumeBackups.update.useMutation()
: api.volumeBackups.create.useMutation();
@@ -207,7 +207,7 @@ export const HandleVolumeBackups = ({
await mutateAsync({
...values,
keepLatestCount: preparedKeepLatestCount,
keepLatestCount: preparedKeepLatestCount ?? undefined,
destinationId: values.destinationId,
volumeBackupId: volumeBackupId || "",
serviceType: volumeBackupType,
@@ -630,7 +630,7 @@ export const HandleVolumeBackups = ({
)}
/>
<Button type="submit" isLoading={isLoading} className="w-full">
<Button type="submit" isLoading={isPending} className="w-full">
{volumeBackupId ? "Update" : "Create"} Volume Backup
</Button>
</form>

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { debounce } from "lodash";
import debounce from "lodash/debounce";
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -53,27 +53,15 @@ interface Props {
}
const RestoreBackupSchema = z.object({
destinationId: z
.string({
required_error: "Please select a destination",
})
.min(1, {
message: "Destination is required",
}),
backupFile: z
.string({
required_error: "Please select a backup file",
})
.min(1, {
message: "Backup file is required",
}),
volumeName: z
.string({
required_error: "Please enter a volume name",
})
.min(1, {
message: "Volume name is required",
}),
destinationId: z.string().min(1, {
message: "Destination is required",
}),
backupFile: z.string().min(1, {
message: "Backup file is required",
}),
volumeName: z.string().min(1, {
message: "Volume name is required",
}),
});
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
@@ -83,7 +71,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
const { data: destinations = [] } = api.destination.all.useQuery();
const form = useForm<z.infer<typeof RestoreBackupSchema>>({
const form = useForm({
defaultValues: {
destinationId: "",
backupFile: "",
@@ -105,7 +93,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
debouncedSetSearch(value);
};
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{
destinationId: destinationId,
search: debouncedSearchTerm,
@@ -294,7 +282,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
onValueChange={handleSearchChange}
className="h-9"
/>
{isLoading ? (
{isPending ? (
<div className="py-6 text-center text-sm">
Loading backup files...
</div>

View File

@@ -54,7 +54,7 @@ export const ShowVolumeBackups = ({
},
);
const utils = api.useUtils();
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
const { mutateAsync: deleteVolumeBackup, isPending: isDeleting } =
api.volumeBackups.delete.useMutation();
const { mutateAsync: runManually } =
api.volumeBackups.runManually.useMutation();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -52,7 +52,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { mutateAsync, isPending } = api.compose.update.useMutation();
const form = useForm<AddCommand>({
defaultValues: {
@@ -128,7 +128,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
/>
</div>
<div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit">
<Button isLoading={isPending} type="submit" className="w-fit">
Save
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { AlertTriangle, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";

View File

@@ -1,5 +1,5 @@
import type { ServiceType } from "@dokploy/server/db/schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { Copy, Trash2 } from "lucide-react";
import { useRouter } from "next/router";
@@ -46,6 +46,8 @@ interface Props {
}
export const DeleteService = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDelete = permissions?.service.delete ?? false;
const [isOpen, setIsOpen] = useState(false);
const queryMap = {
@@ -74,7 +76,7 @@ export const DeleteService = ({ id, type }: Props) => {
mongo: () => api.mongo.remove.useMutation(),
compose: () => api.compose.delete.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.remove.useMutation();
const { push } = useRouter();
@@ -123,6 +125,8 @@ export const DeleteService = ({ id, type }: Props) => {
data?.applicationStatus === "running") ||
(data && "composeStatus" in data && data?.composeStatus === "running");
if (!canDelete) return null;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
@@ -130,7 +134,7 @@ export const DeleteService = ({ id, type }: Props) => {
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isLoading}
isLoading={isPending}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
@@ -228,7 +232,7 @@ export const DeleteService = ({ id, type }: Props) => {
</Button>
<Button
isLoading={isLoading}
isLoading={isPending}
disabled={isDisabled}
form="hook-form-delete-compose"
type="submit"

View File

@@ -19,6 +19,9 @@ interface Props {
}
export const ComposeActions = ({ composeId }: Props) => {
const router = useRouter();
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const canUpdateService = permissions?.service.create ?? false;
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
@@ -28,169 +31,176 @@ export const ComposeActions = ({ composeId }: Props) => {
const { mutateAsync: update } = api.compose.update.useMutation();
const { mutateAsync: deploy } = api.compose.deploy.useMutation();
const { mutateAsync: redeploy } = api.compose.redeploy.useMutation();
const { mutateAsync: start, isLoading: isStarting } =
const { mutateAsync: start, isPending: isStarting } =
api.compose.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } =
const { mutateAsync: stop, isPending: isStopping } =
api.compose.stop.useMutation();
return (
<div className="flex flex-row gap-4 w-full flex-wrap ">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
<DialogAction
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
type="default"
onClick={async () => {
await deploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="default"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads the source code and performs a complete build</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Compose"
description="Are you sure you want to reload this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading compose");
});
}}
>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the compose without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
{canDeploy && (
<DialogAction
title="Start Compose"
description="Are you sure you want to start this compose?"
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
type="default"
onClick={async () => {
await start({
await deploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose started successfully");
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error starting compose");
toast.error("Error deploying compose");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
variant="default"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the compose (requires a previous successful build)
Downloads the source code and performs a complete build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
)}
{canDeploy && (
<DialogAction
title="Stop Compose"
description="Are you sure you want to stop this compose?"
title="Reload Compose"
description="Are you sure you want to reload this compose?"
type="default"
onClick={async () => {
await stop({
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose stopped successfully");
toast.success("Compose reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping compose");
toast.error("Error reloading compose");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
variant="secondary"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running compose</p>
<p>Reload the compose without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy &&
(data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<DialogAction
title="Start Compose"
description="Are you sure you want to start this compose?"
type="default"
onClick={async () => {
await start({
composeId: composeId,
})
.then(() => {
toast.success("Compose started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting compose");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the compose (requires a previous successful build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Compose"
description="Are you sure you want to stop this compose?"
onClick={async () => {
await stop({
composeId: composeId,
})
.then(() => {
toast.success("Compose stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping compose");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running compose</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
))}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
@@ -205,27 +215,29 @@ export const ComposeActions = ({ composeId }: Props) => {
Open Terminal
</Button>
</DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
{canUpdateService && (
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -26,6 +26,8 @@ const AddComposeFile = z.object({
type AddComposeFile = z.infer<typeof AddComposeFile>;
export const ComposeFileEditor = ({ composeId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canUpdate = permissions?.service.create ?? false;
const utils = api.useUtils();
const { data, refetch } = api.compose.one.useQuery(
{
@@ -34,7 +36,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
{ enabled: !!composeId },
);
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { mutateAsync, isPending } = api.compose.update.useMutation();
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const form = useForm<AddComposeFile>({
@@ -93,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -103,7 +105,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading]);
}, [form, onSubmit, isPending]);
return (
<>
@@ -164,14 +166,16 @@ services:
</Form>
<div className="flex justify-between flex-col lg:flex-row gap-2">
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
<Button
type="submit"
form="hook-form-save-compose-file"
isLoading={isLoading}
className="lg:w-fit w-full"
>
Save
</Button>
{canUpdate && (
<Button
type="submit"
form="hook-form-save-compose-file"
isLoading={isPending}
className="lg:w-fit w-full"
>
Save
</Button>
)}
</div>
</div>
</>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -74,10 +74,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
api.bitbucket.bitbucketProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingBitbucketProvider } =
const { mutateAsync, isPending: isSavingBitbucketProvider } =
api.compose.update.useMutation();
const form = useForm<BitbucketProvider>({
const form = useForm({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
@@ -335,7 +335,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -352,7 +352,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
{status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
@@ -58,9 +58,9 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { mutateAsync, isPending } = api.compose.update.useMutation();
const form = useForm<GitProvider>({
const form = useForm({
defaultValues: {
branch: "",
repositoryURL: "",
@@ -230,10 +230,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
@@ -318,7 +316,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
</div>
<div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}>
<Button type="submit" className="w-fit" isLoading={isPending}>
Save{" "}
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -72,10 +72,10 @@ interface Props {
export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGiteaProvider } =
const { mutateAsync, isPending: isSavingGiteaProvider } =
api.compose.update.useMutation();
const form = useForm<GiteaProvider>({
const form = useForm({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
@@ -331,7 +331,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -72,10 +72,10 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGithubProvider } =
const { mutateAsync, isPending: isSavingGithubProvider } =
api.compose.update.useMutation();
const form = useForm<GithubProvider>({
const form = useForm({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
@@ -94,7 +94,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } =
const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
{
githubId,
@@ -321,7 +321,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -338,7 +338,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
{status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo } from "react";
@@ -74,10 +74,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGitlabProvider } =
const { mutateAsync, isPending: isSavingGitlabProvider } =
api.compose.update.useMutation();
const form = useForm<GitlabProvider>({
const form = useForm({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
@@ -353,7 +353,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -370,7 +370,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
{status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>

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