mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-15 19:05:28 +02:00
Compare commits
1 Commits
canary
...
dosu/doc-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d287e2a56d |
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
name: frontend-design
|
|
||||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
|
||||||
license: Complete terms in LICENSE.txt
|
|
||||||
---
|
|
||||||
|
|
||||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
|
||||||
|
|
||||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
|
||||||
|
|
||||||
## Design Thinking
|
|
||||||
|
|
||||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
|
||||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
|
||||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
|
||||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
|
||||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
|
||||||
|
|
||||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
|
||||||
|
|
||||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
|
||||||
- Production-grade and functional
|
|
||||||
- Visually striking and memorable
|
|
||||||
- Cohesive with a clear aesthetic point-of-view
|
|
||||||
- Meticulously refined in every detail
|
|
||||||
|
|
||||||
## Frontend Aesthetics Guidelines
|
|
||||||
|
|
||||||
Focus on:
|
|
||||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
|
||||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
|
||||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
|
||||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
|
||||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
|
||||||
|
|
||||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
|
||||||
|
|
||||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
|
||||||
|
|
||||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
|
||||||
|
|
||||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
|
||||||
88
.github/workflows/dokploy.yml
vendored
88
.github/workflows/dokploy.yml
vendored
@@ -138,8 +138,6 @@ jobs:
|
|||||||
needs: [combine-manifests]
|
needs: [combine-manifests]
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -152,14 +150,6 @@ jobs:
|
|||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Fetch install.sh
|
|
||||||
run: |
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
|
||||||
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
|
||||||
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
|
||||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
|
||||||
mv install-pinned.sh install.sh
|
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
@@ -168,83 +158,5 @@ jobs:
|
|||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
files: install.sh
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
sync-version:
|
|
||||||
needs: [generate-release]
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Sync version to MCP repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
|
||||||
cd /tmp/mcp-repo
|
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
|
||||||
mv package.json.tmp package.json
|
|
||||||
|
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
|
||||||
pnpm run fetch-openapi
|
|
||||||
pnpm run generate
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
--allow-empty
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
|
|
||||||
|
|
||||||
- name: Sync version to CLI repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
|
||||||
cd /tmp/cli-repo
|
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
|
||||||
mv package.json.tmp package.json
|
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
|
||||||
pnpm run generate
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
--allow-empty
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
|
|
||||||
|
|
||||||
- name: Sync version to SDK repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
|
||||||
cd /tmp/sdk-repo
|
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
|
||||||
mv package.json.tmp package.json
|
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
|
||||||
pnpm run generate
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
--allow-empty
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
|
|
||||||
|
|||||||
22
.github/workflows/pr-quality.yml
vendored
Normal file
22
.github/workflows/pr-quality.yml
vendored
Normal 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
|
||||||
63
.github/workflows/sync-openapi-docs.yml
vendored
63
.github/workflows/sync-openapi-docs.yml
vendored
@@ -68,66 +68,3 @@ jobs:
|
|||||||
|
|
||||||
echo "✅ OpenAPI synced to website successfully"
|
echo "✅ OpenAPI synced to website successfully"
|
||||||
|
|
||||||
- name: Sync to MCP repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
|
|
||||||
|
|
||||||
cd mcp-repo
|
|
||||||
|
|
||||||
cp -f ../openapi.json openapi.json
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
|
|
||||||
git add openapi.json
|
|
||||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
|
||||||
--allow-empty
|
|
||||||
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ OpenAPI synced to MCP repository successfully"
|
|
||||||
|
|
||||||
- name: Sync to CLI repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
|
|
||||||
|
|
||||||
cd cli-repo
|
|
||||||
|
|
||||||
cp -f ../openapi.json openapi.json
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
|
|
||||||
git add openapi.json
|
|
||||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
|
||||||
--allow-empty
|
|
||||||
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ OpenAPI synced to CLI repository successfully"
|
|
||||||
|
|
||||||
- name: Sync to SDK repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
|
|
||||||
|
|
||||||
cd sdk-repo
|
|
||||||
|
|
||||||
cp -f ../openapi.json openapi.json
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
|
|
||||||
git add openapi.json
|
|
||||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
|
||||||
--allow-empty
|
|
||||||
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ OpenAPI synced to SDK repository successfully"
|
|
||||||
|
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,8 +4,5 @@
|
|||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.biome": "explicit",
|
"source.fixAll.biome": "explicit",
|
||||||
"source.organizeImports.biome": "explicit"
|
"source.organizeImports.biome": "explicit"
|
||||||
},
|
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,14 +99,7 @@ pnpm run dokploy:build
|
|||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
To build the docker image first run commands to copy .env files
|
To build the docker image
|
||||||
|
|
||||||
```bash
|
|
||||||
cp apps/dokploy/.env.production.example .env.production
|
|
||||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
then run build command
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run docker:build
|
pnpm run docker:build
|
||||||
|
|||||||
@@ -66,8 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||||
|
|
||||||
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
|
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||||
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies th
|
|||||||
Dokploy includes multiple features to make your life easier.
|
Dokploy includes multiple features to make your life easier.
|
||||||
|
|
||||||
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
|
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
|
||||||
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.
|
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis.
|
||||||
- **Backups**: Automate backups for databases to an external storage destination.
|
- **Backups**: Automate backups for databases to an external storage destination.
|
||||||
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
|
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
|
||||||
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
|
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
|
||||||
@@ -39,7 +39,7 @@ To get started, run the following command on a VPS:
|
|||||||
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
|
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://dokploy.com/install.sh | bash
|
curl -sSL https://dokploy.com/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||||
|
|||||||
@@ -6,3 +6,377 @@ npm run dev
|
|||||||
```
|
```
|
||||||
open http://localhost:3000
|
open http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
The API server requires the following environment variables for configuration:
|
||||||
|
|
||||||
|
### Inngest Configuration
|
||||||
|
|
||||||
|
Required for the GET /jobs endpoint to list deployment jobs:
|
||||||
|
|
||||||
|
- **INNGEST_BASE_URL** - The base URL for the Inngest instance
|
||||||
|
- Self-hosted: `http://localhost:8288`
|
||||||
|
- Production: `https://dev-inngest.dokploy.com`
|
||||||
|
|
||||||
|
- **INNGEST_SIGNING_KEY** - The signing key for authenticating with Inngest
|
||||||
|
|
||||||
|
Optional configuration for filtering and pagination:
|
||||||
|
|
||||||
|
- **INNGEST_EVENTS_RECEIVED_AFTER** (optional) - An RFC3339 timestamp to filter events received after a specific date (e.g., `2024-01-01T00:00:00Z`). If unset, no date filter is applied.
|
||||||
|
|
||||||
|
- **INNGEST_JOBS_MAX_EVENTS** (optional) - Maximum number of events to fetch when listing jobs. Default is 100, maximum is 10000. Used for pagination with cursor.
|
||||||
|
|
||||||
|
### Lemon Squeezy Integration
|
||||||
|
|
||||||
|
- **LEMON_SQUEEZY_API_KEY** - API key for Lemon Squeezy integration
|
||||||
|
- **LEMON_SQUEEZY_STORE_ID** - Store ID for Lemon Squeezy integration
|
||||||
|
|
||||||
|
### Docker Configuration
|
||||||
|
|
||||||
|
Optional configuration for customizing Docker daemon connections:
|
||||||
|
|
||||||
|
- **DOCKER_API_VERSION** (optional) - Specifies which Docker API version to use when connecting to the Docker daemon. If not set, the Docker client uses the default API version.
|
||||||
|
|
||||||
|
- **DOKPLOY_DOCKER_HOST** (optional) - Specifies the Docker daemon host to connect to. If not set, uses the default Docker socket connection.
|
||||||
|
|
||||||
|
- **DOKPLOY_DOCKER_PORT** (optional) - Specifies the port for connecting to the Docker daemon. If not set, uses the default port.
|
||||||
|
|
||||||
|
These variables allow advanced users to customize how the Dokploy API server connects to Docker, which can be useful for connecting to remote Docker daemons or using specific API versions.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### GET /jobs
|
||||||
|
|
||||||
|
Lists deployment jobs (Inngest runs) for a specified server.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `serverId` (required) - The ID of the server to list deployment jobs for
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Returns an array of deployment job objects with the same shape as BullMQ queue jobs:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"name": "string",
|
||||||
|
"data": {},
|
||||||
|
"timestamp": 0,
|
||||||
|
"processedOn": 0,
|
||||||
|
"finishedOn": 0,
|
||||||
|
"failedReason": "string",
|
||||||
|
"state": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `400` - serverId is not provided
|
||||||
|
- `503` - INNGEST_BASE_URL is not configured
|
||||||
|
- `200` - Empty array on other errors
|
||||||
|
|
||||||
|
This endpoint is used by the UI to display deployment queue information in the dashboard.
|
||||||
|
|
||||||
|
## Search Endpoints
|
||||||
|
|
||||||
|
The following search endpoints provide flexible querying capabilities with pagination support. All search endpoints respect member permissions, returning only resources the user has access to.
|
||||||
|
|
||||||
|
### application.search
|
||||||
|
|
||||||
|
Search applications across name, appName, description, repository, owner, and dockerImage fields.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `q` (optional string) - General search term that searches across name, appName, description, repository, owner, and dockerImage
|
||||||
|
- `name` (optional string) - Filter by application name
|
||||||
|
- `appName` (optional string) - Filter by app name
|
||||||
|
- `description` (optional string) - Filter by description
|
||||||
|
- `repository` (optional string) - Filter by repository
|
||||||
|
- `owner` (optional string) - Filter by owner
|
||||||
|
- `dockerImage` (optional string) - Filter by Docker image
|
||||||
|
- `projectId` (optional string) - Filter by project ID
|
||||||
|
- `environmentId` (optional string) - Filter by environment ID
|
||||||
|
- `limit` (number, default 20, min 1, max 100) - Maximum number of results
|
||||||
|
- `offset` (number, default 0, min 0) - Pagination offset
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"applicationId": "string",
|
||||||
|
"name": "string",
|
||||||
|
"appName": "string",
|
||||||
|
"description": "string",
|
||||||
|
"environmentId": "string",
|
||||||
|
"applicationStatus": "string",
|
||||||
|
"sourceType": "string",
|
||||||
|
"createdAt": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### compose.search
|
||||||
|
|
||||||
|
Search compose services with filtering by name, appName, and description.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `q` (optional string) - General search term across name, appName, description
|
||||||
|
- `name` (optional string) - Filter by name
|
||||||
|
- `appName` (optional string) - Filter by app name
|
||||||
|
- `description` (optional string) - Filter by description
|
||||||
|
- `projectId` (optional string) - Filter by project ID
|
||||||
|
- `environmentId` (optional string) - Filter by environment ID
|
||||||
|
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||||
|
- `offset` (number, default 0, min 0) - Pagination offset
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"composeId": "string",
|
||||||
|
"name": "string",
|
||||||
|
"appName": "string",
|
||||||
|
"description": "string",
|
||||||
|
"environmentId": "string",
|
||||||
|
"composeStatus": "string",
|
||||||
|
"sourceType": "string",
|
||||||
|
"createdAt": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### environment.search
|
||||||
|
|
||||||
|
Search environments by name and description.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `q` (optional string) - General search term across name and description
|
||||||
|
- `name` (optional string) - Filter by name
|
||||||
|
- `description` (optional string) - Filter by description
|
||||||
|
- `projectId` (optional string) - Filter by project ID
|
||||||
|
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||||
|
- `offset` (number, default 0, min 0) - Pagination offset
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"environmentId": "string",
|
||||||
|
"name": "string",
|
||||||
|
"description": "string",
|
||||||
|
"createdAt": "string",
|
||||||
|
"env": "string",
|
||||||
|
"projectId": "string",
|
||||||
|
"isDefault": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### project.search
|
||||||
|
|
||||||
|
Search projects by name and description.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `q` (optional string) - General search term across name and description
|
||||||
|
- `name` (optional string) - Filter by name
|
||||||
|
- `description` (optional string) - Filter by description
|
||||||
|
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||||
|
- `offset` (number, default 0, min 0) - Pagination offset
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"projectId": "string",
|
||||||
|
"name": "string",
|
||||||
|
"description": "string",
|
||||||
|
"createdAt": "string",
|
||||||
|
"organizationId": "string",
|
||||||
|
"env": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Service Search Endpoints
|
||||||
|
|
||||||
|
The following database services all share the same search interface:
|
||||||
|
- **postgres.search**
|
||||||
|
- **mysql.search**
|
||||||
|
- **mariadb.search**
|
||||||
|
- **mongo.search**
|
||||||
|
- **redis.search**
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `q` (optional string) - General search term across name, appName, description
|
||||||
|
- `name` (optional string) - Filter by name
|
||||||
|
- `appName` (optional string) - Filter by app name
|
||||||
|
- `description` (optional string) - Filter by description
|
||||||
|
- `projectId` (optional string) - Filter by project ID
|
||||||
|
- `environmentId` (optional string) - Filter by environment ID
|
||||||
|
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||||
|
- `offset` (number, default 0, min 0) - Pagination offset
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"postgresId": "string",
|
||||||
|
"name": "string",
|
||||||
|
"appName": "string",
|
||||||
|
"description": "string",
|
||||||
|
"environmentId": "string",
|
||||||
|
"applicationStatus": "string",
|
||||||
|
"createdAt": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
*Note: The response shape is similar across all database services, with the ID field varying (e.g., `mysqlId`, `mariadbId`, `mongoId`, `redisId`).*
|
||||||
|
|
||||||
|
**Search Behavior:**
|
||||||
|
- All searches use case-insensitive pattern matching with wildcards
|
||||||
|
- Results are ordered by creation date (descending)
|
||||||
|
- Members only see services they have access to
|
||||||
|
- Returns total count for pagination UI
|
||||||
|
|
||||||
|
## Database Service Update Endpoints
|
||||||
|
|
||||||
|
The following database services support update operations with similar parameters:
|
||||||
|
- **postgres.update** (apiUpdatePostgres)
|
||||||
|
- **mysql.update** (apiUpdateMySql)
|
||||||
|
- **mariadb.update** (apiUpdateMariaDB)
|
||||||
|
- **mongo.update** (apiUpdateMongo)
|
||||||
|
- **redis.update** (apiUpdateRedis)
|
||||||
|
|
||||||
|
**Input Parameters:**
|
||||||
|
|
||||||
|
Each update endpoint accepts the following parameters (all optional except the service ID):
|
||||||
|
|
||||||
|
- `postgresId` / `mysqlId` / `mariadbId` / `mongoId` / `redisId` (required) - The ID of the database service to update
|
||||||
|
- `dockerImage` (optional string) - Custom Docker image to use for the database service
|
||||||
|
- Other service-specific parameters as defined in the schema
|
||||||
|
|
||||||
|
**Example Input:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"postgresId": "string",
|
||||||
|
"dockerImage": "postgres:16-alpine"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Returns the updated database service object.
|
||||||
|
|
||||||
|
**Purpose:**
|
||||||
|
The `dockerImage` parameter allows users to specify custom Docker images for database services, providing flexibility for version selection, custom builds, or alternative distributions.
|
||||||
|
|
||||||
|
## Whitelabeling Endpoints
|
||||||
|
|
||||||
|
The whitelabeling endpoints allow enterprise/self-hosted Dokploy instances to customize branding, logos, colors, and UI appearance. These endpoints are only available in self-hosted mode (not cloud).
|
||||||
|
|
||||||
|
### whitelabeling.get
|
||||||
|
|
||||||
|
Get the current whitelabeling configuration.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Enterprise license required
|
||||||
|
- Only available for self-hosted (not cloud)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Returns the whitelabeling configuration object or null if not configured.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"appName": "string | null",
|
||||||
|
"appDescription": "string | null",
|
||||||
|
"logoUrl": "string | null",
|
||||||
|
"faviconUrl": "string | null",
|
||||||
|
"primaryColor": "string | null",
|
||||||
|
"customCss": "string | null",
|
||||||
|
"loginLogoUrl": "string | null",
|
||||||
|
"supportUrl": "string | null",
|
||||||
|
"docsUrl": "string | null",
|
||||||
|
"errorPageTitle": "string | null",
|
||||||
|
"errorPageDescription": "string | null",
|
||||||
|
"metaTitle": "string | null",
|
||||||
|
"footerText": "string | null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### whitelabeling.update
|
||||||
|
|
||||||
|
Update the whitelabeling configuration.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Enterprise license required
|
||||||
|
- Owner role required
|
||||||
|
- Only available for self-hosted (not cloud)
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"whitelabelingConfig": {
|
||||||
|
"appName": "string | null",
|
||||||
|
"appDescription": "string | null",
|
||||||
|
"logoUrl": "string | null",
|
||||||
|
"faviconUrl": "string | null",
|
||||||
|
"primaryColor": "string | null",
|
||||||
|
"customCss": "string | null",
|
||||||
|
"loginLogoUrl": "string | null",
|
||||||
|
"supportUrl": "string | null",
|
||||||
|
"docsUrl": "string | null",
|
||||||
|
"errorPageTitle": "string | null",
|
||||||
|
"errorPageDescription": "string | null",
|
||||||
|
"metaTitle": "string | null",
|
||||||
|
"footerText": "string | null"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### whitelabeling.reset
|
||||||
|
|
||||||
|
Reset whitelabeling configuration to default values (all fields set to null).
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Enterprise license required
|
||||||
|
- Owner role required
|
||||||
|
- Only available for self-hosted (not cloud)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### whitelabeling.getPublic
|
||||||
|
|
||||||
|
Public endpoint to fetch whitelabeling configuration. This endpoint can be accessed without authentication, allowing the whitelabeling settings to be applied globally (including on the login page before auth).
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- No authentication required
|
||||||
|
- Only available for self-hosted (not cloud)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Returns the whitelabeling configuration object or null if not configured. Response shape is identical to `whitelabeling.get`.
|
||||||
@@ -17,17 +17,17 @@
|
|||||||
"hono": "^4.11.7",
|
"hono": "^4.11.7",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
"react": "19.2.7",
|
"react": "18.2.0",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "18.2.0",
|
||||||
"redis": "4.7.0",
|
"redis": "4.7.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.4.0",
|
"@types/node": "^24.4.0",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^18.2.15",
|
||||||
"rimraf": "6.1.3",
|
"rimraf": "6.1.3",
|
||||||
"tsx": "^4.22.4",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.22.0",
|
"packageManager": "pnpm@10.22.0",
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
|
|
||||||
|
|
||||||
describe("apiKeyNameSchema", () => {
|
|
||||||
it("rejects an empty name", () => {
|
|
||||||
const result = apiKeyNameSchema.safeParse("");
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts a name at the maximum length", () => {
|
|
||||||
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH);
|
|
||||||
const result = apiKeyNameSchema.safeParse(name);
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a name over the maximum length instead of passing it to better-auth", () => {
|
|
||||||
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1);
|
|
||||||
const result = apiKeyNameSchema.safeParse(name);
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error.issues[0]?.message).toBe(
|
|
||||||
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("redactRcloneCredentials (#4621)", () => {
|
|
||||||
it("should redact access key in rclone command", () => {
|
|
||||||
const cmd =
|
|
||||||
'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz';
|
|
||||||
const redacted = redactRcloneCredentials(cmd);
|
|
||||||
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
|
||||||
expect(redacted).toContain('--s3-access-key-id="[REDACTED]"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should redact secret access key in rclone command", () => {
|
|
||||||
const cmd =
|
|
||||||
'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz';
|
|
||||||
const redacted = redactRcloneCredentials(cmd);
|
|
||||||
expect(redacted).not.toContain("supersecret");
|
|
||||||
expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should redact both credentials simultaneously", () => {
|
|
||||||
const cmd =
|
|
||||||
'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/';
|
|
||||||
const redacted = redactRcloneCredentials(cmd);
|
|
||||||
expect(redacted).not.toContain("AKIA123");
|
|
||||||
expect(redacted).not.toContain("secret456");
|
|
||||||
expect(redacted).toContain('--s3-region="us-east-1"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not modify non-credential flags", () => {
|
|
||||||
const cmd =
|
|
||||||
'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz';
|
|
||||||
const redacted = redactRcloneCredentials(cmd);
|
|
||||||
expect(redacted).toBe(cmd);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle commands with no credentials", () => {
|
|
||||||
const cmd = "rclone lsf :s3:bucket/";
|
|
||||||
expect(redactRcloneCredentials(cmd)).toBe(cmd);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle error strings containing credentials", () => {
|
|
||||||
const errorStr =
|
|
||||||
'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/';
|
|
||||||
const redacted = redactRcloneCredentials(errorStr);
|
|
||||||
expect(redacted).not.toContain("MYKEY");
|
|
||||||
expect(redacted).not.toContain("MYSECRET");
|
|
||||||
expect(redacted).toContain("[REDACTED]");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
// Isolate the command builder from the compose-file I/O performed by
|
|
||||||
// writeDomainsToCompose; we only care about the docker invocation it emits.
|
|
||||||
vi.mock("@dokploy/server/utils/docker/domain", () => ({
|
|
||||||
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const baseCompose = {
|
|
||||||
appName: "my-app",
|
|
||||||
sourceType: "raw",
|
|
||||||
command: "",
|
|
||||||
composePath: "docker-compose.yml",
|
|
||||||
composeType: "stack",
|
|
||||||
isolatedDeployment: false,
|
|
||||||
randomize: false,
|
|
||||||
suffix: "",
|
|
||||||
serverId: null,
|
|
||||||
env: "",
|
|
||||||
mounts: [],
|
|
||||||
domains: [],
|
|
||||||
environment: { project: { env: "" }, env: "" },
|
|
||||||
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
|
|
||||||
|
|
||||||
// Regression coverage for #4401: the deploy command runs under `env -i`, which
|
|
||||||
// clears the environment except for the vars listed explicitly. HOME must be
|
|
||||||
// preserved so docker can resolve ~/.docker/config.json — otherwise
|
|
||||||
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
|
|
||||||
// and private-registry images fail to pull.
|
|
||||||
describe("getBuildComposeCommand registry auth (#4401)", () => {
|
|
||||||
it("preserves HOME for swarm stack deploys", async () => {
|
|
||||||
const command = await getBuildComposeCommand({
|
|
||||||
...baseCompose,
|
|
||||||
composeType: "stack",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(command).toContain("stack deploy");
|
|
||||||
expect(command).toContain("--with-registry-auth");
|
|
||||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves HOME for docker compose deploys", async () => {
|
|
||||||
const command = await getBuildComposeCommand({
|
|
||||||
...baseCompose,
|
|
||||||
composeType: "docker-compose",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(command).toContain("compose -p my-app");
|
|
||||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -32,9 +32,6 @@ describe("Host rule format regression tests", () => {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
customEntrypoint: null,
|
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Host rule format validation", () => {
|
describe("Host rule format validation", () => {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ describe("createDomainLabels", () => {
|
|||||||
const baseDomain: Domain = {
|
const baseDomain: Domain = {
|
||||||
host: "example.com",
|
host: "example.com",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
customEntrypoint: null,
|
|
||||||
https: false,
|
https: false,
|
||||||
uniqueConfigKey: 1,
|
uniqueConfigKey: 1,
|
||||||
customCertResolver: null,
|
customCertResolver: null,
|
||||||
@@ -22,8 +21,6 @@ describe("createDomainLabels", () => {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
it("should create basic labels for web entrypoint", async () => {
|
||||||
@@ -104,51 +101,6 @@ describe("createDomainLabels", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add tls=true for certificateType none on websecure entrypoint", async () => {
|
|
||||||
const noneDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
certificateType: "none" as const,
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, noneDomain, "websecure");
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure.tls=true",
|
|
||||||
);
|
|
||||||
// no cert resolver should be set when relying on a default/custom cert
|
|
||||||
expect(labels).not.toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not add tls=true for certificateType none on web entrypoint", async () => {
|
|
||||||
const noneDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
certificateType: "none" as const,
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, noneDomain, "web");
|
|
||||||
expect(labels).not.toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.tls=true",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add tls=true for certificateType none on a custom https entrypoint", async () => {
|
|
||||||
const noneDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "websecure-custom",
|
|
||||||
certificateType: "none" as const,
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
noneDomain,
|
|
||||||
"websecure-custom",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure-custom.tls=true",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle different ports correctly", async () => {
|
it("should handle different ports correctly", async () => {
|
||||||
const customPortDomain = { ...baseDomain, port: 3000 };
|
const customPortDomain = { ...baseDomain, port: 3000 };
|
||||||
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
||||||
@@ -219,12 +171,12 @@ describe("createDomainLabels", () => {
|
|||||||
"websecure",
|
"websecure",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Web entrypoint with HTTPS should only have redirect
|
// Web entrypoint should have both middlewares with redirect first
|
||||||
expect(webLabels).toContain(
|
expect(webLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Websecure should have the addprefix middleware
|
// Websecure should only have the addprefix middleware
|
||||||
expect(websecureLabels).toContain(
|
expect(websecureLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
|
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
@@ -256,9 +208,9 @@ describe("createDomainLabels", () => {
|
|||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Web router with HTTPS should only have redirect
|
// Should have middlewares in correct order: redirect, stripprefix, addprefix
|
||||||
expect(webLabels).toContain(
|
expect(webLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,259 +240,4 @@ describe("createDomainLabels", () => {
|
|||||||
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add single custom middleware to router", async () => {
|
|
||||||
const customMiddlewareDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
customMiddlewareDomain,
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add multiple custom middlewares to router", async () => {
|
|
||||||
const customMiddlewareDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
middlewares: ["auth@file", "rate-limit@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
customMiddlewareDomain,
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
|
|
||||||
const combinedDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
|
||||||
|
|
||||||
// Web router with HTTPS should only redirect, custom middlewares go on websecure
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
|
||||||
);
|
|
||||||
expect(labels).not.toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
|
|
||||||
const combinedDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
|
||||||
|
|
||||||
// stripprefix should come before custom middleware
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
|
|
||||||
const fullDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
internalPath: "/hello",
|
|
||||||
middlewares: ["auth@file", "rate-limit@file"],
|
|
||||||
};
|
|
||||||
const webLabels = await createDomainLabels(appName, fullDomain, "web");
|
|
||||||
|
|
||||||
// Web router with HTTPS should only redirect
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
|
||||||
);
|
|
||||||
// Middleware definitions should still be present (Traefik needs them registered)
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
|
||||||
);
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
|
||||||
);
|
|
||||||
// But they should NOT be attached to the router
|
|
||||||
expect(webLabels).not.toContain("stripprefix-test-app-1,");
|
|
||||||
expect(webLabels).not.toContain("auth@file");
|
|
||||||
expect(webLabels).not.toContain("rate-limit@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include custom middlewares on websecure entrypoint", async () => {
|
|
||||||
const customMiddlewareDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const websecureLabels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
customMiddlewareDomain,
|
|
||||||
"websecure",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Websecure should have custom middleware but not redirect-to-https
|
|
||||||
expect(websecureLabels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
|
|
||||||
);
|
|
||||||
expect(websecureLabels).not.toContain("redirect-to-https");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
|
|
||||||
const domain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
middlewares: ["rate-limit@file", "auth@file"],
|
|
||||||
};
|
|
||||||
const webLabels = await createDomainLabels(appName, domain, "web");
|
|
||||||
|
|
||||||
// Web router with HTTPS should ONLY have redirect, not custom middlewares
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
|
||||||
);
|
|
||||||
expect(webLabels).not.toContain("rate-limit@file");
|
|
||||||
expect(webLabels).not.toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create basic labels for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{ ...baseDomain, customEntrypoint: "custom" },
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
expect(labels).toEqual([
|
|
||||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
|
||||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
|
||||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
|
||||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create https labels for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
expect(labels).toEqual([
|
|
||||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
|
||||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
|
||||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
|
||||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
|
||||||
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add stripPath middleware for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add internalPath middleware for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
internalPath: "/hello",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add path prefix in rule for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should combine all middlewares for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
internalPath: "/hello",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
https: true,
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
|
|
||||||
// Should not contain redirect-to-https since there's only one router
|
|
||||||
expect(middlewareLabel).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ networks:
|
|||||||
dokploy-network:
|
dokploy-network:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shouldn't add suffix to dokploy-network", () => {
|
test("It shoudn't add suffix to dokploy-network", () => {
|
||||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ services:
|
|||||||
- dokploy-network
|
- dokploy-network
|
||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shouldn't add suffix to dokploy-network in services", () => {
|
test("It shoudn't add suffix to dokploy-network in services", () => {
|
||||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
@@ -241,10 +241,10 @@ services:
|
|||||||
dokploy-network:
|
dokploy-network:
|
||||||
aliases:
|
aliases:
|
||||||
- apid
|
- apid
|
||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
|
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
|
||||||
const composeData = parse(composeFile8) as ComposeSpecification;
|
const composeData = parse(composeFile8) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|||||||
@@ -42,39 +42,6 @@ test("Add suffix to volumes declared directly in services", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const composeFileAccessMode = `
|
|
||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
|
||||||
web:
|
|
||||||
image: nginx:alpine
|
|
||||||
volumes:
|
|
||||||
- web_config:/etc/nginx/conf.d:ro
|
|
||||||
- certs/sub:/etc/certs:Z
|
|
||||||
`;
|
|
||||||
|
|
||||||
test("Add suffix to volumes preserves access mode (:ro, :z, :Z)", () => {
|
|
||||||
const composeData = parse(composeFileAccessMode) as ComposeSpecification;
|
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
|
||||||
|
|
||||||
if (!composeData.services) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToVolumesInServices(
|
|
||||||
composeData.services,
|
|
||||||
suffix,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(updatedComposeData.web?.volumes).toContain(
|
|
||||||
`web_config-${suffix}:/etc/nginx/conf.d:ro`,
|
|
||||||
);
|
|
||||||
expect(updatedComposeData.web?.volumes).toContain(
|
|
||||||
`certs-${suffix}/sub:/etc/certs:Z`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const composeFileTypeVolume = `
|
const composeFileTypeVolume = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
eq: vi.fn((field: string, value: unknown) => ({ field, value })),
|
|
||||||
and: vi.fn((...conditions: Array<{ field: string; value: unknown }>) => ({
|
|
||||||
conditions,
|
|
||||||
})),
|
|
||||||
githubFindFirst: vi.fn(),
|
|
||||||
applicationsFindMany: vi.fn(),
|
|
||||||
composeFindMany: vi.fn(),
|
|
||||||
queueAdd: vi.fn(),
|
|
||||||
verify: vi.fn(),
|
|
||||||
shouldDeploy: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("drizzle-orm", () => ({
|
|
||||||
eq: mocks.eq,
|
|
||||||
and: mocks.and,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/server/db/schema", () => ({
|
|
||||||
applications: {
|
|
||||||
sourceType: "application.sourceType",
|
|
||||||
autoDeploy: "application.autoDeploy",
|
|
||||||
triggerType: "application.triggerType",
|
|
||||||
branch: "application.branch",
|
|
||||||
repository: "application.repository",
|
|
||||||
owner: "application.owner",
|
|
||||||
githubId: "application.githubId",
|
|
||||||
isPreviewDeploymentsActive: "application.isPreviewDeploymentsActive",
|
|
||||||
},
|
|
||||||
compose: {
|
|
||||||
sourceType: "compose.sourceType",
|
|
||||||
autoDeploy: "compose.autoDeploy",
|
|
||||||
triggerType: "compose.triggerType",
|
|
||||||
branch: "compose.branch",
|
|
||||||
repository: "compose.repository",
|
|
||||||
owner: "compose.owner",
|
|
||||||
githubId: "compose.githubId",
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
githubInstallationId: "github.githubInstallationId",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
github: {
|
|
||||||
findFirst: mocks.githubFindFirst,
|
|
||||||
},
|
|
||||||
applications: {
|
|
||||||
findMany: mocks.applicationsFindMany,
|
|
||||||
},
|
|
||||||
compose: {
|
|
||||||
findMany: mocks.composeFindMany,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server", () => ({
|
|
||||||
IS_CLOUD: false,
|
|
||||||
shouldDeploy: mocks.shouldDeploy,
|
|
||||||
checkUserRepositoryPermissions: vi.fn(),
|
|
||||||
createPreviewDeployment: vi.fn(),
|
|
||||||
createSecurityBlockedComment: vi.fn(),
|
|
||||||
findGithubById: vi.fn(),
|
|
||||||
findPreviewDeploymentByApplicationId: vi.fn(),
|
|
||||||
findPreviewDeploymentsByPullRequestId: vi.fn(),
|
|
||||||
getBitbucketHeaders: vi.fn(() => ({})),
|
|
||||||
removePreviewDeployment: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@octokit/webhooks", () => ({
|
|
||||||
Webhooks: vi.fn().mockImplementation(function Webhooks() {
|
|
||||||
return {
|
|
||||||
verify: mocks.verify,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/server/queues/queueSetup", () => ({
|
|
||||||
myQueue: {
|
|
||||||
add: mocks.queueAdd,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/server/utils/deploy", () => ({
|
|
||||||
deploy: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import handler from "@/pages/api/deploy/github";
|
|
||||||
|
|
||||||
const getConditionValue = (
|
|
||||||
where: { conditions?: Array<{ field: string; value: unknown }> } | undefined,
|
|
||||||
field: string,
|
|
||||||
) => where?.conditions?.find((condition) => condition.field === field)?.value;
|
|
||||||
|
|
||||||
const createResponse = () => {
|
|
||||||
const res = {
|
|
||||||
status: vi.fn(),
|
|
||||||
json: vi.fn(),
|
|
||||||
} as unknown as NextApiResponse & {
|
|
||||||
status: ReturnType<typeof vi.fn>;
|
|
||||||
json: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
|
|
||||||
res.status.mockImplementation(() => res);
|
|
||||||
res.json.mockImplementation(() => res);
|
|
||||||
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createPushRequest = (
|
|
||||||
branch: string,
|
|
||||||
owner: { login?: string; name?: string } = { login: "agentHits" },
|
|
||||||
) =>
|
|
||||||
({
|
|
||||||
headers: {
|
|
||||||
"x-hub-signature-256": "sha256=test-signature",
|
|
||||||
"x-github-event": "push",
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
installation: {
|
|
||||||
id: 12345,
|
|
||||||
},
|
|
||||||
ref: `refs/heads/${branch}`,
|
|
||||||
after: "abc123",
|
|
||||||
head_commit: {
|
|
||||||
message: "fix: trigger deployment",
|
|
||||||
},
|
|
||||||
commits: [
|
|
||||||
{
|
|
||||||
modified: ["src/index.ts"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
repository: {
|
|
||||||
name: "dokploy",
|
|
||||||
full_name: "agentHits/dokploy",
|
|
||||||
clone_url: "https://github.com/agentHits/dokploy.git",
|
|
||||||
html_url: "https://github.com/agentHits/dokploy",
|
|
||||||
owner,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) as unknown as NextApiRequest;
|
|
||||||
|
|
||||||
const createTagRequest = (tagName: string) => {
|
|
||||||
const req = createPushRequest("main") as unknown as {
|
|
||||||
body: { ref: string; head_commit: { message: string } };
|
|
||||||
};
|
|
||||||
|
|
||||||
req.body.ref = `refs/tags/${tagName}`;
|
|
||||||
req.body.head_commit.message = `release: ${tagName}`;
|
|
||||||
|
|
||||||
return req as unknown as NextApiRequest;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("GitHub app webhook auto-deploy", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mocks.githubFindFirst.mockResolvedValue({
|
|
||||||
githubId: "github-provider-id",
|
|
||||||
githubInstallationId: 12345,
|
|
||||||
githubWebhookSecret: "webhook-secret",
|
|
||||||
});
|
|
||||||
mocks.verify.mockResolvedValue(true);
|
|
||||||
mocks.shouldDeploy.mockReturnValue(true);
|
|
||||||
mocks.composeFindMany.mockResolvedValue([]);
|
|
||||||
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
|
|
||||||
|
|
||||||
mocks.applicationsFindMany.mockImplementation(({ where }) => {
|
|
||||||
const matches =
|
|
||||||
getConditionValue(where, "application.sourceType") === "github" &&
|
|
||||||
getConditionValue(where, "application.autoDeploy") === true &&
|
|
||||||
getConditionValue(where, "application.triggerType") === "push" &&
|
|
||||||
getConditionValue(where, "application.branch") === "main" &&
|
|
||||||
getConditionValue(where, "application.repository") === "dokploy" &&
|
|
||||||
getConditionValue(where, "application.owner") === "agentHits" &&
|
|
||||||
getConditionValue(where, "application.githubId") ===
|
|
||||||
"github-provider-id";
|
|
||||||
|
|
||||||
return Promise.resolve(
|
|
||||||
matches
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
applicationId: "application-id",
|
|
||||||
serverId: null,
|
|
||||||
watchPaths: null,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches push events using repository owner name when available", async () => {
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await handler(
|
|
||||||
createPushRequest("main", {
|
|
||||||
login: "agentHits-login",
|
|
||||||
name: "agentHits",
|
|
||||||
}),
|
|
||||||
res,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
|
||||||
"deployments",
|
|
||||||
expect.objectContaining({
|
|
||||||
applicationId: "application-id",
|
|
||||||
applicationType: "application",
|
|
||||||
type: "deploy",
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toHaveBeenCalledWith(200);
|
|
||||||
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches compose push events using repository owner login fallback", async () => {
|
|
||||||
mocks.applicationsFindMany.mockResolvedValue([]);
|
|
||||||
mocks.composeFindMany.mockImplementation(({ where }) => {
|
|
||||||
const matches =
|
|
||||||
getConditionValue(where, "compose.sourceType") === "github" &&
|
|
||||||
getConditionValue(where, "compose.autoDeploy") === true &&
|
|
||||||
getConditionValue(where, "compose.triggerType") === "push" &&
|
|
||||||
getConditionValue(where, "compose.branch") === "main" &&
|
|
||||||
getConditionValue(where, "compose.repository") === "dokploy" &&
|
|
||||||
getConditionValue(where, "compose.owner") === "agentHits" &&
|
|
||||||
getConditionValue(where, "compose.githubId") === "github-provider-id";
|
|
||||||
|
|
||||||
return Promise.resolve(
|
|
||||||
matches
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
composeId: "compose-id",
|
|
||||||
serverId: null,
|
|
||||||
watchPaths: null,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await handler(createPushRequest("main"), res);
|
|
||||||
|
|
||||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
|
||||||
"deployments",
|
|
||||||
expect.objectContaining({
|
|
||||||
applicationType: "compose",
|
|
||||||
composeId: "compose-id",
|
|
||||||
type: "deploy",
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toHaveBeenCalledWith(200);
|
|
||||||
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches tag events using repository owner login fallback", async () => {
|
|
||||||
mocks.applicationsFindMany.mockImplementation(({ where }) => {
|
|
||||||
const matches =
|
|
||||||
getConditionValue(where, "application.sourceType") === "github" &&
|
|
||||||
getConditionValue(where, "application.autoDeploy") === true &&
|
|
||||||
getConditionValue(where, "application.triggerType") === "tag" &&
|
|
||||||
getConditionValue(where, "application.repository") === "dokploy" &&
|
|
||||||
getConditionValue(where, "application.owner") === "agentHits" &&
|
|
||||||
getConditionValue(where, "application.githubId") ===
|
|
||||||
"github-provider-id";
|
|
||||||
|
|
||||||
return Promise.resolve(
|
|
||||||
matches
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
applicationId: "application-id",
|
|
||||||
serverId: null,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await handler(createTagRequest("v1.0.0"), res);
|
|
||||||
|
|
||||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
|
||||||
"deployments",
|
|
||||||
expect.objectContaining({
|
|
||||||
applicationId: "application-id",
|
|
||||||
applicationType: "application",
|
|
||||||
titleLog: "Tag created: v1.0.0",
|
|
||||||
type: "deploy",
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toHaveBeenCalledWith(200);
|
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
|
||||||
message: "Deployed 1 apps based on tag v1.0.0",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not deploy when the pushed branch does not match", async () => {
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await handler(createPushRequest("feature"), res);
|
|
||||||
|
|
||||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
|
||||||
expect(res.status).toHaveBeenCalledWith(200);
|
|
||||||
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -415,24 +415,5 @@ describe("Docker Image Name and Tag Extraction", () => {
|
|||||||
expect(extractImageTag("my-image:123")).toBe("123");
|
expect(extractImageTag("my-image:123")).toBe("123");
|
||||||
expect(extractImageTag("my-image:1")).toBe("1");
|
expect(extractImageTag("my-image:1")).toBe("1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 'latest' for registry with port but no tag", () => {
|
|
||||||
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
|
|
||||||
"latest",
|
|
||||||
);
|
|
||||||
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
|
|
||||||
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
|
|
||||||
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract tag from registry with port and tag", () => {
|
|
||||||
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
|
|
||||||
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
|
|
||||||
"v2.0",
|
|
||||||
);
|
|
||||||
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
|
|
||||||
"sha-abc123",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { shouldDeploy } from "@dokploy/server";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("shouldDeploy", () => {
|
|
||||||
it("should deploy when no watch paths are configured", () => {
|
|
||||||
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
|
|
||||||
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should deploy when watch paths match modified files", () => {
|
|
||||||
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
|
|
||||||
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not deploy when watch paths do not match", () => {
|
|
||||||
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when modified files contain non-string values", () => {
|
|
||||||
expect(() =>
|
|
||||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
|
||||||
).not.toThrow();
|
|
||||||
expect(
|
|
||||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when modified files are undefined or null", () => {
|
|
||||||
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
|
|
||||||
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
|
|
||||||
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
|
|
||||||
expect(shouldDeploy(["src/**"], null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when every modified file is non-string", () => {
|
|
||||||
expect(() =>
|
|
||||||
shouldDeploy(["src/**"], [undefined, undefined] as any),
|
|
||||||
).not.toThrow();
|
|
||||||
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -120,7 +120,6 @@ const baseApp: ApplicationNested = {
|
|||||||
environmentId: "",
|
environmentId: "",
|
||||||
enabled: null,
|
enabled: null,
|
||||||
env: null,
|
env: null,
|
||||||
icon: null,
|
|
||||||
healthCheckSwarm: null,
|
healthCheckSwarm: null,
|
||||||
labelsSwarm: null,
|
labelsSwarm: null,
|
||||||
memoryLimit: null,
|
memoryLimit: null,
|
||||||
|
|||||||
93
apps/dokploy/__test__/env/encryption.test.ts
vendored
93
apps/dokploy/__test__/env/encryption.test.ts
vendored
@@ -1,93 +0,0 @@
|
|||||||
import {
|
|
||||||
decryptValue,
|
|
||||||
encryptValue,
|
|
||||||
exportEncryptionKeys,
|
|
||||||
isEncrypted,
|
|
||||||
} from "@dokploy/server/lib/encryption";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
describe("encryptValue / decryptValue", () => {
|
|
||||||
it("round-trips a value", () => {
|
|
||||||
const value =
|
|
||||||
"DATABASE_URL=postgres://user:secret@host:5432/db\nAPI_KEY=123";
|
|
||||||
const encrypted = encryptValue(value);
|
|
||||||
|
|
||||||
expect(encrypted).not.toBe(value);
|
|
||||||
expect(isEncrypted(encrypted)).toBe(true);
|
|
||||||
expect(encrypted).not.toContain("secret");
|
|
||||||
expect(decryptValue(encrypted)).toBe(value);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a random IV so equal inputs produce different ciphertexts", () => {
|
|
||||||
const value = "KEY=value";
|
|
||||||
expect(encryptValue(value)).not.toBe(encryptValue(value));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes legacy plaintext through on decrypt", () => {
|
|
||||||
const plaintext = "KEY=legacy-plaintext-value";
|
|
||||||
expect(decryptValue(plaintext)).toBe(plaintext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes empty values through unchanged", () => {
|
|
||||||
expect(encryptValue("")).toBe("");
|
|
||||||
expect(decryptValue("")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not double-encrypt an already encrypted value", () => {
|
|
||||||
const encrypted = encryptValue("KEY=value");
|
|
||||||
expect(encryptValue(encrypted)).toBe(encrypted);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws a descriptive error on tampered ciphertext", () => {
|
|
||||||
const encrypted = encryptValue("KEY=value");
|
|
||||||
const tampered = `${encrypted.slice(0, -4)}AAAA`;
|
|
||||||
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exports the derived keys as 32-byte hex lines for backups", () => {
|
|
||||||
expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("dedicated ENCRYPTION_KEY", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadWithEncryptionKey = async (key: string) => {
|
|
||||||
vi.stubEnv("ENCRYPTION_KEY", key);
|
|
||||||
vi.resetModules();
|
|
||||||
return await import("@dokploy/server/lib/encryption");
|
|
||||||
};
|
|
||||||
|
|
||||||
it("encrypts with the dedicated key when set", async () => {
|
|
||||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
|
||||||
const encrypted = withKey.encryptValue("KEY=value");
|
|
||||||
|
|
||||||
expect(withKey.decryptValue(encrypted)).toBe("KEY=value");
|
|
||||||
// The default (auth-secret derived) module cannot read it
|
|
||||||
expect(() => decryptValue(encrypted)).toThrow(/ENCRYPTION_KEY/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still decrypts legacy values via the auth-secret fallback", async () => {
|
|
||||||
// Encrypted before the install adopted a dedicated key
|
|
||||||
const legacyEncrypted = encryptValue("KEY=legacy-value");
|
|
||||||
|
|
||||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
|
||||||
expect(withKey.decryptValue(legacyEncrypted)).toBe("KEY=legacy-value");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-encrypts with the dedicated key on write", async () => {
|
|
||||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
|
||||||
const reEncrypted = withKey.encryptValue(
|
|
||||||
withKey.decryptValue(encryptValue("KEY=migrated")),
|
|
||||||
);
|
|
||||||
|
|
||||||
const other = await loadWithEncryptionKey("another-key");
|
|
||||||
// Readable only by the dedicated key (or its own fallback), proving
|
|
||||||
// the write used the primary key, not the legacy one
|
|
||||||
expect(withKey.decryptValue(reEncrypted)).toBe("KEY=migrated");
|
|
||||||
expect(() => other.decryptValue(reEncrypted)).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getEnvironmentVariablesObject } from "@dokploy/server/index";
|
import { getEnviromentVariablesObject } from "@dokploy/server/index";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const projectEnv = `
|
const projectEnv = `
|
||||||
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
|
|||||||
SECRET_KEY=env-secret-123
|
SECRET_KEY=env-secret-123
|
||||||
`;
|
`;
|
||||||
|
|
||||||
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => {
|
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
|
||||||
it("resolves environment variables correctly for Stack compose", () => {
|
it("resolves environment variables correctly for Stack compose", () => {
|
||||||
const serviceEnv = `
|
const serviceEnv = `
|
||||||
FOO=\${{environment.NODE_ENV}}
|
FOO=\${{environment.NODE_ENV}}
|
||||||
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
|
|||||||
BAZ=test
|
BAZ=test
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceEnv,
|
serviceEnv,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
|
|||||||
SERVICE_PORT=4000
|
SERVICE_PORT=4000
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceEnv,
|
serviceEnv,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -72,7 +72,7 @@ PASSWORD=secret123
|
|||||||
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv);
|
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
||||||
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||||
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ NODE_ENV=production
|
|||||||
API_URL=\${{environment.API_URL}}
|
API_URL=\${{environment.API_URL}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceOverrideEnv,
|
serviceOverrideEnv,
|
||||||
"",
|
"",
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
|
|||||||
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
complexServiceEnv,
|
complexServiceEnv,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
|
|||||||
DB_NAME=\${{environment.DATABASE_NAME}}
|
DB_NAME=\${{environment.DATABASE_NAME}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceWithConflicts,
|
serviceWithConflicts,
|
||||||
conflictingProjectEnv,
|
conflictingProjectEnv,
|
||||||
conflictingEnvironmentEnv,
|
conflictingEnvironmentEnv,
|
||||||
@@ -170,7 +170,7 @@ SERVICE_VAR=test
|
|||||||
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceWithEmpty,
|
serviceWithEmpty,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -1,369 +0,0 @@
|
|||||||
import {
|
|
||||||
canEditDeployGitSource,
|
|
||||||
getAccessibleGitProviderIds,
|
|
||||||
} from "@dokploy/server/services/git-provider";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mockDb = vi.hoisted(() => ({
|
|
||||||
query: {
|
|
||||||
gitProvider: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
},
|
|
||||||
member: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
|
|
||||||
|
|
||||||
const mockHasValidLicense = vi.hoisted(() => vi.fn());
|
|
||||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
|
||||||
hasValidLicense: mockHasValidLicense,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ORG_ID = "org-1";
|
|
||||||
const USER_OWNER = "user-owner";
|
|
||||||
const USER_ADMIN = "user-admin";
|
|
||||||
const USER_MEMBER = "user-member";
|
|
||||||
const USER_MEMBER_2 = "user-member-2";
|
|
||||||
|
|
||||||
const providerOwned = {
|
|
||||||
gitProviderId: "gp-owned",
|
|
||||||
userId: USER_MEMBER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
};
|
|
||||||
const providerShared = {
|
|
||||||
gitProviderId: "gp-shared",
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: true,
|
|
||||||
};
|
|
||||||
const providerPrivate = {
|
|
||||||
gitProviderId: "gp-private",
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
};
|
|
||||||
const providerOtherMember = {
|
|
||||||
gitProviderId: "gp-other",
|
|
||||||
userId: USER_MEMBER_2,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const allProviders = [
|
|
||||||
providerOwned,
|
|
||||||
providerShared,
|
|
||||||
providerPrivate,
|
|
||||||
providerOtherMember,
|
|
||||||
];
|
|
||||||
|
|
||||||
function session(userId: string) {
|
|
||||||
return { userId, activeOrganizationId: ORG_ID };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
|
|
||||||
mockHasValidLicense.mockResolvedValue(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getAccessibleGitProviderIds", () => {
|
|
||||||
describe("owner", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "owner",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all org providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
|
||||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes providers owned by other members", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("admin", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "admin",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all org providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
|
||||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes providers owned by other members — fixes issue #4469", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member without enterprise license", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
|
||||||
});
|
|
||||||
mockHasValidLicense.mockResolvedValue(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access their own provider", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access shared providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access private providers of other users even if assigned (no license)", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access providers of other members", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member with enterprise license", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access provider explicitly assigned to them", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access provider not assigned and not shared", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access shared provider even without explicit assignment", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access own provider regardless of assignments", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access provider of other member even with license but no assignment", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member with no member record", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue(null);
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only returns own providers and shared ones", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("enterprise license — member assigned to a provider they do not own", () => {
|
|
||||||
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
|
|
||||||
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("empty org", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "admin",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty set when org has no providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
|
||||||
expect(ids.size).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("canEditDeployGitSource", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("owner", () => {
|
|
||||||
it("can edit deploy using any provider", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerPrivate.gitProviderId,
|
|
||||||
session(USER_OWNER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("admin", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot edit deploy using owner's private provider (not shared)", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerPrivate.gitProviderId,
|
|
||||||
session(USER_ADMIN),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using a provider shared with the org", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: true,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerShared.gitProviderId,
|
|
||||||
session(USER_ADMIN),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using their own provider", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_ADMIN,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
"gp-admin-owned",
|
|
||||||
session(USER_ADMIN),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using their own provider", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_MEMBER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerOwned.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using a provider shared with the org", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: true,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerShared.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
|
|
||||||
// This is the key case: enterprise, provider del owner, no compartido,
|
|
||||||
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerPrivate.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot edit deploy using another member's private provider", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_MEMBER_2,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerOtherMember.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false if provider does not exist", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
"nonexistent-id",
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -58,7 +58,7 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("owner and admin bypass enterprise resources", () => {
|
describe("static roles bypass enterprise resources", () => {
|
||||||
it("owner bypasses deployment.read", async () => {
|
it("owner bypasses deployment.read", async () => {
|
||||||
memberToReturn = mockMemberData("owner");
|
memberToReturn = mockMemberData("owner");
|
||||||
await expect(
|
await expect(
|
||||||
@@ -73,8 +73,15 @@ describe("owner and admin bypass enterprise resources", () => {
|
|||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("owner bypasses multiple enterprise permissions at once", async () => {
|
it("member bypasses schedule.delete", async () => {
|
||||||
memberToReturn = mockMemberData("owner");
|
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(
|
await expect(
|
||||||
checkPermission(ctx, {
|
checkPermission(ctx, {
|
||||||
deployment: ["read"],
|
deployment: ["read"],
|
||||||
@@ -85,55 +92,6 @@ describe("owner and admin bypass enterprise resources", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("member is denied org-level enterprise resources (CVE: bypass via staticRoles)", () => {
|
|
||||||
it("member is denied registry.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { registry: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied certificate.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { certificate: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied destination.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { destination: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied notification.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { notification: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied auditLog.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { auditLog: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied server.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied registry.create", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { registry: ["create"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("static roles validate free-tier resources", () => {
|
describe("static roles validate free-tier resources", () => {
|
||||||
it("owner passes project.create", async () => {
|
it("owner passes project.create", async () => {
|
||||||
memberToReturn = mockMemberData("owner");
|
memberToReturn = mockMemberData("owner");
|
||||||
@@ -183,29 +141,4 @@ describe("legacy boolean overrides for member", () => {
|
|||||||
memberToReturn = mockMemberData("member");
|
memberToReturn = mockMemberData("member");
|
||||||
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
|
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("member passes gitProviders.create with canAccessToGitProviders=true", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", {
|
|
||||||
canAccessToGitProviders: true,
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { gitProviders: ["create"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member passes gitProviders.delete with canAccessToGitProviders=true", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", {
|
|
||||||
canAccessToGitProviders: true,
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { gitProviders: ["delete"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member fails gitProviders.create with canAccessToGitProviders=false", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { gitProviders: ["create"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
enterpriseOnlyResources,
|
enterpriseOnlyResources,
|
||||||
statements,
|
statements,
|
||||||
} from "@dokploy/server/lib/access-control";
|
} from "@dokploy/server/lib/access-control";
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const FREE_TIER_RESOURCES = [
|
const FREE_TIER_RESOURCES = [
|
||||||
"organization",
|
"organization",
|
||||||
@@ -35,7 +35,6 @@ const ENTERPRISE_RESOURCES = [
|
|||||||
"domain",
|
"domain",
|
||||||
"destination",
|
"destination",
|
||||||
"notification",
|
"notification",
|
||||||
"tag",
|
|
||||||
"logs",
|
"logs",
|
||||||
"monitoring",
|
"monitoring",
|
||||||
"auditLog",
|
"auditLog",
|
||||||
|
|||||||
@@ -143,24 +143,6 @@ describe("free-tier resources for member", () => {
|
|||||||
const perms = await resolvePermissions(ctx);
|
const perms = await resolvePermissions(ctx);
|
||||||
expect(perms.docker.read).toBe(true);
|
expect(perms.docker.read).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("member gets gitProviders create/delete=false without legacy override", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.gitProviders.read).toBe(false);
|
|
||||||
expect(perms.gitProviders.create).toBe(false);
|
|
||||||
expect(perms.gitProviders.delete).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets gitProviders read/create/delete=true with canAccessToGitProviders", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", {
|
|
||||||
canAccessToGitProviders: true,
|
|
||||||
});
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.gitProviders.read).toBe(true);
|
|
||||||
expect(perms.gitProviders.create).toBe(true);
|
|
||||||
expect(perms.gitProviders.delete).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("free-tier resources for owner", () => {
|
describe("free-tier resources for owner", () => {
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const getWebServerSettings = vi.fn();
|
|
||||||
const findFirstServer = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
server: {
|
|
||||||
findFirst: (...args: unknown[]) => findFirstServer(...args),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db/schema", () => ({
|
|
||||||
server: {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
|
||||||
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
|
|
||||||
|
|
||||||
import { resolveBuildsConcurrency } from "../../server/queues/concurrency";
|
|
||||||
import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
|
|
||||||
|
|
||||||
describe("resolveBuildsConcurrency", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("local web server partition", () => {
|
|
||||||
it("returns the configured concurrency", async () => {
|
|
||||||
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
|
|
||||||
|
|
||||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not cap high values", async () => {
|
|
||||||
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
|
|
||||||
|
|
||||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(
|
|
||||||
999,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("floors values below 1 to 1", async () => {
|
|
||||||
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 0 });
|
|
||||||
|
|
||||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to 1 when settings are missing", async () => {
|
|
||||||
getWebServerSettings.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("remote server partition", () => {
|
|
||||||
it("returns the server concurrency", async () => {
|
|
||||||
findFirstServer.mockResolvedValue({ buildsConcurrency: 4 });
|
|
||||||
|
|
||||||
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to 1 for an unknown server", async () => {
|
|
||||||
findFirstServer.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await expect(resolveBuildsConcurrency("ghost")).resolves.toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to 1 if resolution throws", async () => {
|
|
||||||
getWebServerSettings.mockRejectedValue(new Error("db down"));
|
|
||||||
|
|
||||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
getGroup,
|
|
||||||
getPartition,
|
|
||||||
InMemoryQueue,
|
|
||||||
LOCAL_PARTITION,
|
|
||||||
} from "../../server/queues/in-memory-queue";
|
|
||||||
import type { DeploymentJob } from "../../server/queues/queue-types";
|
|
||||||
|
|
||||||
const appJob = (applicationId: string, serverId?: string): DeploymentJob => ({
|
|
||||||
applicationId,
|
|
||||||
titleLog: "deploy",
|
|
||||||
descriptionLog: "",
|
|
||||||
type: "deploy",
|
|
||||||
applicationType: "application",
|
|
||||||
serverId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const composeJob = (composeId: string, serverId?: string): DeploymentJob => ({
|
|
||||||
composeId,
|
|
||||||
titleLog: "deploy",
|
|
||||||
descriptionLog: "",
|
|
||||||
type: "deploy",
|
|
||||||
applicationType: "compose",
|
|
||||||
serverId,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** A controllable async task: resolves only when `release()` is called. */
|
|
||||||
const deferred = () => {
|
|
||||||
let resolve!: () => void;
|
|
||||||
const promise = new Promise<void>((r) => {
|
|
||||||
resolve = r;
|
|
||||||
});
|
|
||||||
return { promise, release: resolve };
|
|
||||||
};
|
|
||||||
|
|
||||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
||||||
|
|
||||||
describe("getPartition / getGroup", () => {
|
|
||||||
it("partitions by serverId, falling back to the local partition", () => {
|
|
||||||
expect(getPartition(appJob("a"))).toBe(LOCAL_PARTITION);
|
|
||||||
expect(getPartition(appJob("a", "server-1"))).toBe("server-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("groups applications and compose by their id", () => {
|
|
||||||
expect(getGroup(appJob("a"))).toBe("application:a");
|
|
||||||
expect(getGroup(composeJob("c"))).toBe("compose:c");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("InMemoryQueue concurrency", () => {
|
|
||||||
let nowValue = 0;
|
|
||||||
const now = () => ++nowValue;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
nowValue = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs different applications concurrently up to the limit", async () => {
|
|
||||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
|
||||||
const started: string[] = [];
|
|
||||||
|
|
||||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 2, now });
|
|
||||||
queue.process(async (job) => {
|
|
||||||
const id = (job.data as any).applicationId;
|
|
||||||
started.push(id);
|
|
||||||
const d = deferred();
|
|
||||||
tasks.set(id, d);
|
|
||||||
await d.promise;
|
|
||||||
});
|
|
||||||
await queue.run();
|
|
||||||
|
|
||||||
await queue.add(appJob("a"));
|
|
||||||
await queue.add(appJob("b"));
|
|
||||||
await queue.add(appJob("c"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
// Concurrency 2 -> only a and b start, c waits.
|
|
||||||
expect(started).toEqual(["a", "b"]);
|
|
||||||
|
|
||||||
tasks.get("a")!.release();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
// A slot freed -> c starts.
|
|
||||||
expect(started).toEqual(["a", "b", "c"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes jobs of the same application (per-group FIFO)", async () => {
|
|
||||||
const tasks: Array<ReturnType<typeof deferred>> = [];
|
|
||||||
const started: number[] = [];
|
|
||||||
let counter = 0;
|
|
||||||
|
|
||||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 5, now });
|
|
||||||
queue.process(async () => {
|
|
||||||
started.push(++counter);
|
|
||||||
const d = deferred();
|
|
||||||
tasks.push(d);
|
|
||||||
await d.promise;
|
|
||||||
});
|
|
||||||
await queue.run();
|
|
||||||
|
|
||||||
// Two deploys of the SAME app, even with concurrency 5.
|
|
||||||
await queue.add(appJob("same"));
|
|
||||||
await queue.add(appJob("same"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
// Only the first one runs; the second waits for the group to free.
|
|
||||||
expect(started).toEqual([1]);
|
|
||||||
|
|
||||||
tasks[0]!.release();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(started).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isolates concurrency per server partition", async () => {
|
|
||||||
const started: string[] = [];
|
|
||||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
|
||||||
|
|
||||||
// server-1 allows 1, server-2 allows 1, but they are independent.
|
|
||||||
const queue = new InMemoryQueue({
|
|
||||||
resolveConcurrency: () => 1,
|
|
||||||
now,
|
|
||||||
});
|
|
||||||
queue.process(async (job) => {
|
|
||||||
const id = `${job.data.serverId}:${(job.data as any).applicationId}`;
|
|
||||||
started.push(id);
|
|
||||||
const d = deferred();
|
|
||||||
tasks.set(id, d);
|
|
||||||
await d.promise;
|
|
||||||
});
|
|
||||||
await queue.run();
|
|
||||||
|
|
||||||
await queue.add(appJob("a", "server-1"));
|
|
||||||
await queue.add(appJob("b", "server-2"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
// One per partition runs in parallel despite concurrency 1 each.
|
|
||||||
expect(started.sort()).toEqual(["server-1:a", "server-2:b"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("honors a different concurrency per server", async () => {
|
|
||||||
const started: string[] = [];
|
|
||||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
|
||||||
|
|
||||||
// server-fast allows 2, server-slow allows 1.
|
|
||||||
const queue = new InMemoryQueue({
|
|
||||||
resolveConcurrency: (partition) => (partition === "server-fast" ? 2 : 1),
|
|
||||||
now,
|
|
||||||
});
|
|
||||||
queue.process(async (job) => {
|
|
||||||
const id = `${job.data.serverId}:${(job.data as any).applicationId}`;
|
|
||||||
started.push(id);
|
|
||||||
const d = deferred();
|
|
||||||
tasks.set(id, d);
|
|
||||||
await d.promise;
|
|
||||||
});
|
|
||||||
await queue.run();
|
|
||||||
|
|
||||||
await queue.add(appJob("a", "server-fast"));
|
|
||||||
await queue.add(appJob("b", "server-fast"));
|
|
||||||
await queue.add(appJob("c", "server-slow"));
|
|
||||||
await queue.add(appJob("d", "server-slow"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
// server-fast runs 2 in parallel; server-slow only 1.
|
|
||||||
expect(started.sort()).toEqual([
|
|
||||||
"server-fast:a",
|
|
||||||
"server-fast:b",
|
|
||||||
"server-slow:c",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Free a server-slow slot -> its queued app starts.
|
|
||||||
tasks.get("server-slow:c")!.release();
|
|
||||||
await flush();
|
|
||||||
expect(started).toContain("server-slow:d");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes the same app on a server even with spare concurrency", async () => {
|
|
||||||
const started: number[] = [];
|
|
||||||
const tasks: Array<ReturnType<typeof deferred>> = [];
|
|
||||||
let counter = 0;
|
|
||||||
|
|
||||||
// Plenty of room (concurrency 2) but two deploys of the SAME app.
|
|
||||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 2, now });
|
|
||||||
queue.process(async () => {
|
|
||||||
started.push(++counter);
|
|
||||||
const d = deferred();
|
|
||||||
tasks.push(d);
|
|
||||||
await d.promise;
|
|
||||||
});
|
|
||||||
await queue.run();
|
|
||||||
|
|
||||||
await queue.add(appJob("app-x", "server-1"));
|
|
||||||
await queue.add(appJob("app-x", "server-1"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
// Only one build of app-x runs despite 2 free slots.
|
|
||||||
expect(started).toEqual([1]);
|
|
||||||
|
|
||||||
tasks[0]!.release();
|
|
||||||
await flush();
|
|
||||||
expect(started).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps concurrency below 1 up to 1 (license-disabled behaviour)", async () => {
|
|
||||||
const started: string[] = [];
|
|
||||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
|
||||||
|
|
||||||
// Simulate a non-licensed resolver returning 0 — must still run 1.
|
|
||||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 0, now });
|
|
||||||
queue.process(async (job) => {
|
|
||||||
const id = (job.data as any).applicationId;
|
|
||||||
started.push(id);
|
|
||||||
const d = deferred();
|
|
||||||
tasks.set(id, d);
|
|
||||||
await d.promise;
|
|
||||||
});
|
|
||||||
await queue.run();
|
|
||||||
|
|
||||||
await queue.add(appJob("a"));
|
|
||||||
await queue.add(appJob("b"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(started).toEqual(["a"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("picks up concurrency changes between scheduling ticks", async () => {
|
|
||||||
const started: string[] = [];
|
|
||||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
|
||||||
let limit = 1;
|
|
||||||
|
|
||||||
const queue = new InMemoryQueue({
|
|
||||||
resolveConcurrency: () => limit,
|
|
||||||
now,
|
|
||||||
});
|
|
||||||
queue.process(async (job) => {
|
|
||||||
const id = (job.data as any).applicationId;
|
|
||||||
started.push(id);
|
|
||||||
const d = deferred();
|
|
||||||
tasks.set(id, d);
|
|
||||||
await d.promise;
|
|
||||||
});
|
|
||||||
await queue.run();
|
|
||||||
|
|
||||||
await queue.add(appJob("a"));
|
|
||||||
await queue.add(appJob("b"));
|
|
||||||
await flush();
|
|
||||||
expect(started).toEqual(["a"]);
|
|
||||||
|
|
||||||
// Raise the limit (e.g. license activated) and release the running job
|
|
||||||
// so a new tick observes the new concurrency.
|
|
||||||
limit = 2;
|
|
||||||
tasks.get("a")!.release();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(started).toContain("b");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("InMemoryQueue job management", () => {
|
|
||||||
it("lists waiting jobs and removes them by predicate", async () => {
|
|
||||||
const block = deferred();
|
|
||||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
|
|
||||||
queue.process(async () => {
|
|
||||||
await block.promise;
|
|
||||||
});
|
|
||||||
await queue.run();
|
|
||||||
|
|
||||||
await queue.add(appJob("running"));
|
|
||||||
await queue.add(appJob("waiting-1"));
|
|
||||||
await queue.add(composeJob("waiting-2"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const waiting = await queue.getJobs(["waiting"]);
|
|
||||||
expect(waiting.map((j) => j.data)).toHaveLength(2);
|
|
||||||
|
|
||||||
const removed = queue.removeWaiting(
|
|
||||||
(data) => (data as any).applicationId === "waiting-1",
|
|
||||||
);
|
|
||||||
expect(removed).toBe(1);
|
|
||||||
|
|
||||||
const after = await queue.getJobs(["waiting"]);
|
|
||||||
expect(after).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears all waiting jobs", async () => {
|
|
||||||
const block = deferred();
|
|
||||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
|
|
||||||
queue.process(async () => {
|
|
||||||
await block.promise;
|
|
||||||
});
|
|
||||||
await queue.run();
|
|
||||||
|
|
||||||
await queue.add(appJob("running"));
|
|
||||||
await queue.add(appJob("waiting-1"));
|
|
||||||
await queue.add(appJob("waiting-2"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(queue.clearWaiting()).toBe(2);
|
|
||||||
expect(await queue.getJobs(["waiting"])).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts processing as soon as a processor is registered", async () => {
|
|
||||||
const started: string[] = [];
|
|
||||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 5 });
|
|
||||||
|
|
||||||
// No processor yet -> jobs queue but do not run.
|
|
||||||
await queue.add(appJob("a"));
|
|
||||||
await flush();
|
|
||||||
expect(started).toEqual([]);
|
|
||||||
|
|
||||||
// Registering the processor auto-starts the queue (no separate run()).
|
|
||||||
queue.process(async (job) => {
|
|
||||||
started.push((job.data as any).applicationId);
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
expect(started).toEqual(["a"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("continues scheduling after a job throws", async () => {
|
|
||||||
const started: string[] = [];
|
|
||||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
|
|
||||||
queue.process(async (job) => {
|
|
||||||
const id = (job.data as any).applicationId;
|
|
||||||
started.push(id);
|
|
||||||
if (id === "a") throw new Error("boom");
|
|
||||||
});
|
|
||||||
await queue.run();
|
|
||||||
|
|
||||||
await queue.add(appJob("a"));
|
|
||||||
await queue.add(appJob("b"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(started).toEqual(["a", "b"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { apiCreateRegistry, apiTestRegistry } from "@dokploy/server/db/schema";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("Registry Schema - Username case preservation (#4632)", () => {
|
|
||||||
const validBase = {
|
|
||||||
registryName: "AWS ECR",
|
|
||||||
password: "dXNlcm5hbWU6cGFzc3dvcmQ=", // dummy base64 token
|
|
||||||
registryUrl: "123456789.dkr.ecr.us-east-1.amazonaws.com",
|
|
||||||
registryType: "cloud" as const,
|
|
||||||
imagePrefix: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should preserve uppercase username (AWS ECR requires 'AWS')", () => {
|
|
||||||
const result = apiCreateRegistry.safeParse({
|
|
||||||
...validBase,
|
|
||||||
username: "AWS",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.data.username).toBe("AWS");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not lowercase mixed-case usernames", () => {
|
|
||||||
const result = apiCreateRegistry.safeParse({
|
|
||||||
...validBase,
|
|
||||||
username: "MyUser",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.data.username).toBe("MyUser");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should still trim whitespace from username", () => {
|
|
||||||
const result = apiCreateRegistry.safeParse({
|
|
||||||
...validBase,
|
|
||||||
username: " AWS ",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.data.username).toBe("AWS");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject empty username", () => {
|
|
||||||
const result = apiCreateRegistry.safeParse({
|
|
||||||
...validBase,
|
|
||||||
username: "",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should also preserve case in apiTestRegistry", () => {
|
|
||||||
const result = apiTestRegistry.safeParse({
|
|
||||||
...validBase,
|
|
||||||
username: "AWS",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.data.username).toBe("AWS");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept lowercase usernames too (backward compat)", () => {
|
|
||||||
const result = apiCreateRegistry.safeParse({
|
|
||||||
...validBase,
|
|
||||||
username: "myuser",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.data.username).toBe("myuser");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -57,7 +57,7 @@ const createApplication = (
|
|||||||
env: null,
|
env: null,
|
||||||
},
|
},
|
||||||
replicas: 1,
|
replicas: 1,
|
||||||
stopGracePeriodSwarm: 0,
|
stopGracePeriodSwarm: 0n,
|
||||||
ulimitsSwarm: null,
|
ulimitsSwarm: null,
|
||||||
serverId: "server-id",
|
serverId: "server-id",
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
|
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
|
||||||
const application = createApplication({ stopGracePeriodSwarm: 0 });
|
const application = createApplication({ stopGracePeriodSwarm: 0n });
|
||||||
|
|
||||||
await mechanizeDockerContainer(application);
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { execFileSync, execSync } from "node:child_process";
|
|
||||||
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { defaultCommand, reportDockerVersion } from "@dokploy/server";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const resolveBin = (name: string) =>
|
|
||||||
execSync(`command -v ${name}`, { encoding: "utf8" }).trim();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a sandbox PATH so `command -v docker` only sees our fake docker
|
|
||||||
* binary (or nothing), regardless of what the host has installed.
|
|
||||||
*/
|
|
||||||
const makeSandbox = (dockerShim?: string) => {
|
|
||||||
const dir = mkdtempSync(path.join(tmpdir(), "dokploy-server-setup-"));
|
|
||||||
for (const tool of ["awk", "tr"]) {
|
|
||||||
const shim = path.join(dir, tool);
|
|
||||||
writeFileSync(shim, `#!/bin/sh\nexec ${resolveBin(tool)} "$@"\n`);
|
|
||||||
chmodSync(shim, 0o755);
|
|
||||||
}
|
|
||||||
if (dockerShim) {
|
|
||||||
const shim = path.join(dir, "docker");
|
|
||||||
writeFileSync(shim, dockerShim);
|
|
||||||
chmodSync(shim, 0o755);
|
|
||||||
}
|
|
||||||
return dir;
|
|
||||||
};
|
|
||||||
|
|
||||||
const runReport = (sandboxPath: string) => {
|
|
||||||
const script = [
|
|
||||||
"DOCKER_VERSION=28.5.0",
|
|
||||||
reportDockerVersion(),
|
|
||||||
'echo "$DOCKER_VERSION_REPORT"',
|
|
||||||
].join("\n");
|
|
||||||
return execFileSync(resolveBin("bash"), ["-c", script], {
|
|
||||||
encoding: "utf8",
|
|
||||||
env: { ...process.env, PATH: sandboxPath },
|
|
||||||
})
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.pop();
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("reportDockerVersion", () => {
|
|
||||||
it("reports the engine version when docker and its daemon are available", () => {
|
|
||||||
const sandbox = makeSandbox(
|
|
||||||
[
|
|
||||||
"#!/bin/sh",
|
|
||||||
'if [ "$1" = "--version" ]; then',
|
|
||||||
' echo "Docker version 25.0.0, build aaaaaaa"',
|
|
||||||
" exit 0",
|
|
||||||
"fi",
|
|
||||||
'if [ "$1" = "version" ]; then',
|
|
||||||
' echo "29.4.3"',
|
|
||||||
" exit 0",
|
|
||||||
"fi",
|
|
||||||
"exit 1",
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
expect(runReport(sandbox)).toBe("29.4.3 (already installed)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the client version when the daemon is unreachable", () => {
|
|
||||||
const sandbox = makeSandbox(
|
|
||||||
[
|
|
||||||
"#!/bin/sh",
|
|
||||||
'if [ "$1" = "--version" ]; then',
|
|
||||||
' echo "Docker version 29.4.3, build 055a478"',
|
|
||||||
" exit 0",
|
|
||||||
"fi",
|
|
||||||
'echo "Cannot connect to the Docker daemon" >&2',
|
|
||||||
"exit 1",
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
expect(runReport(sandbox)).toBe("29.4.3 (already installed)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports the pinned version to be installed when docker is missing", () => {
|
|
||||||
expect(runReport(makeSandbox())).toBe("28.5.0 (will be installed)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("defaultCommand", () => {
|
|
||||||
it.each([false, true])(
|
|
||||||
"prints the detected Docker version in the setup banner (isBuildServer=%s)",
|
|
||||||
(isBuildServer) => {
|
|
||||||
const script = defaultCommand(isBuildServer);
|
|
||||||
expect(script).toContain(reportDockerVersion());
|
|
||||||
expect(script).toContain(
|
|
||||||
'echo "| Docker | $DOCKER_VERSION_REPORT"',
|
|
||||||
);
|
|
||||||
expect(script).not.toContain(
|
|
||||||
'echo "| Docker | $DOCKER_VERSION"',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -494,49 +494,4 @@ describe("processTemplate", () => {
|
|||||||
expect(result.mounts).toHaveLength(1);
|
expect(result.mounts).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isolated deployment config", () => {
|
|
||||||
it("should default to isolated=true when not specified", () => {
|
|
||||||
const template: CompleteTemplate = {
|
|
||||||
metadata: {} as any,
|
|
||||||
variables: {},
|
|
||||||
config: {
|
|
||||||
domains: [],
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(template.config.isolated).toBeUndefined();
|
|
||||||
// undefined !== false => isolatedDeployment = true
|
|
||||||
expect(template.config.isolated !== false).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be isolated when isolated=true is explicitly set", () => {
|
|
||||||
const template: CompleteTemplate = {
|
|
||||||
metadata: {} as any,
|
|
||||||
variables: {},
|
|
||||||
config: {
|
|
||||||
isolated: true,
|
|
||||||
domains: [],
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(template.config.isolated !== false).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disable isolated deployment when isolated=false", () => {
|
|
||||||
const template: CompleteTemplate = {
|
|
||||||
metadata: {} as any,
|
|
||||||
variables: {},
|
|
||||||
config: {
|
|
||||||
isolated: false,
|
|
||||||
domains: [],
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(template.config.isolated !== false).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ describe("helpers functions", () => {
|
|||||||
const domain = processValue("${domain}", {}, mockSchema);
|
const domain = processValue("${domain}", {}, mockSchema);
|
||||||
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
|
domain.endsWith(
|
||||||
|
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
|
||||||
|
),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
import type { ApplicationNested, Domain } from "@dokploy/server";
|
|
||||||
import {
|
|
||||||
buildForwardAuthEnv,
|
|
||||||
createRouterConfig,
|
|
||||||
deriveBaseDomain,
|
|
||||||
deriveCookieSecret,
|
|
||||||
forwardAuthCallbackUrl,
|
|
||||||
forwardAuthMiddlewareName,
|
|
||||||
} from "@dokploy/server";
|
|
||||||
import { beforeAll, describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
const app = {
|
|
||||||
appName: "my-app",
|
|
||||||
redirects: [],
|
|
||||||
security: [],
|
|
||||||
} as unknown as ApplicationNested;
|
|
||||||
|
|
||||||
const baseDomain: Domain = {
|
|
||||||
applicationId: "app-1",
|
|
||||||
certificateType: "none",
|
|
||||||
createdAt: "",
|
|
||||||
domainId: "domain-1",
|
|
||||||
host: "app.example.com",
|
|
||||||
https: false,
|
|
||||||
path: null,
|
|
||||||
port: 3000,
|
|
||||||
customEntrypoint: null,
|
|
||||||
serviceName: "",
|
|
||||||
composeId: "",
|
|
||||||
customCertResolver: null,
|
|
||||||
domainType: "application",
|
|
||||||
uniqueConfigKey: 7,
|
|
||||||
previewDeploymentId: "",
|
|
||||||
internalPath: "/",
|
|
||||||
stripPath: false,
|
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("forwardAuthMiddlewareName", () => {
|
|
||||||
test("is stable and unique per app + uniqueConfigKey", () => {
|
|
||||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
|
||||||
"forward-auth-my-app-7",
|
|
||||||
);
|
|
||||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
expect(forwardAuthMiddlewareName("my-app", 7)).not.toBe(
|
|
||||||
forwardAuthMiddlewareName("my-app", 8),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createRouterConfig forward-auth wiring", () => {
|
|
||||||
test("does NOT add forward-auth middleware when no provider is linked", async () => {
|
|
||||||
const config = await createRouterConfig(app, baseDomain, "websecure");
|
|
||||||
expect(config.middlewares).not.toContain(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("adds forward-auth middleware when a provider is linked", async () => {
|
|
||||||
const domain: Domain = {
|
|
||||||
...baseDomain,
|
|
||||||
forwardAuthEnabled: true,
|
|
||||||
};
|
|
||||||
const config = await createRouterConfig(app, domain, "websecure");
|
|
||||||
expect(config.middlewares).toContain(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("forward-auth runs before custom domain middlewares", async () => {
|
|
||||||
const domain: Domain = {
|
|
||||||
...baseDomain,
|
|
||||||
forwardAuthEnabled: true,
|
|
||||||
middlewares: ["rate-limit@file"],
|
|
||||||
};
|
|
||||||
const config = await createRouterConfig(app, domain, "websecure");
|
|
||||||
const forwardAuthIdx = config.middlewares?.indexOf(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
const customIdx = config.middlewares?.indexOf("rate-limit@file");
|
|
||||||
expect(forwardAuthIdx).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(customIdx).toBeGreaterThan(forwardAuthIdx as number);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirect-only web router does not get the forward-auth middleware", async () => {
|
|
||||||
const domain: Domain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
forwardAuthEnabled: true,
|
|
||||||
};
|
|
||||||
const config = await createRouterConfig(app, domain, "web");
|
|
||||||
expect(config.middlewares).toContain("redirect-to-https");
|
|
||||||
expect(config.middlewares).not.toContain(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildForwardAuthEnv", () => {
|
|
||||||
const baseOptions = {
|
|
||||||
oidc: {
|
|
||||||
clientId: "client-123",
|
|
||||||
clientSecret: "secret-xyz",
|
|
||||||
issuer: "https://idp.example.com",
|
|
||||||
},
|
|
||||||
cookieSecret: "cookie-secret-value",
|
|
||||||
authDomain: "auth.acme.com",
|
|
||||||
baseDomain: ".acme.com",
|
|
||||||
authDomainHttps: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
test("emits the required oauth2-proxy OIDC env vars", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_PROVIDER=oidc");
|
|
||||||
expect(env).toContain(
|
|
||||||
"OAUTH2_PROXY_OIDC_ISSUER_URL=https://idp.example.com",
|
|
||||||
);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_ID=client-123");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_SECRET=secret-xyz");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_SECRET=cookie-secret-value");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_REVERSE_PROXY=true");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses the central auth domain for the single fixed callback", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain(
|
|
||||||
"OAUTH2_PROXY_REDIRECT_URL=https://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shares cookie + whitelist on the base domain (no per-app redeploy)", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_DOMAINS=.acme.com");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_WHITELIST_DOMAINS=.acme.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matches cookie Secure flag and callback scheme to https setting", () => {
|
|
||||||
const https = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(https).toContain("OAUTH2_PROXY_COOKIE_SECURE=true");
|
|
||||||
|
|
||||||
const http = buildForwardAuthEnv({
|
|
||||||
...baseOptions,
|
|
||||||
authDomainHttps: false,
|
|
||||||
});
|
|
||||||
expect(http).toContain("OAUTH2_PROXY_COOKIE_SECURE=false");
|
|
||||||
expect(http).toContain(
|
|
||||||
"OAUTH2_PROXY_REDIRECT_URL=http://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("allows unverified emails so OIDC providers don't 500 the callback", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain(
|
|
||||||
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("defaults to any authenticated user and standard scopes", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=*");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid email profile");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("honors custom scopes and email domains", () => {
|
|
||||||
const env = buildForwardAuthEnv({
|
|
||||||
...baseOptions,
|
|
||||||
oidc: { ...baseOptions.oidc, scopes: ["openid", "groups"] },
|
|
||||||
emailDomains: ["acme.com", "corp.com"],
|
|
||||||
});
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid groups");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=acme.com,corp.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sets skip-discovery flag only when requested", () => {
|
|
||||||
const withoutSkip = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(withoutSkip).not.toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
|
||||||
|
|
||||||
const withSkip = buildForwardAuthEnv({
|
|
||||||
...baseOptions,
|
|
||||||
oidc: { ...baseOptions.oidc, skipDiscovery: true },
|
|
||||||
});
|
|
||||||
expect(withSkip).toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deriveBaseDomain", () => {
|
|
||||||
test("strips the auth subdomain to the shared base", () => {
|
|
||||||
expect(deriveBaseDomain("auth.acme.com")).toBe(".acme.com");
|
|
||||||
expect(deriveBaseDomain("sso.apps.acme.com")).toBe(".apps.acme.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps a two-label apex as the base", () => {
|
|
||||||
expect(deriveBaseDomain("acme.com")).toBe(".acme.com");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("forwardAuthCallbackUrl", () => {
|
|
||||||
test("builds the single IdP callback per scheme", () => {
|
|
||||||
expect(forwardAuthCallbackUrl("auth.acme.com", true)).toBe(
|
|
||||||
"https://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
expect(forwardAuthCallbackUrl("auth.acme.com", false)).toBe(
|
|
||||||
"http://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deriveCookieSecret", () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.BETTER_AUTH_SECRET = "test-root-secret";
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is deterministic for the same salt (survives service updates)", () => {
|
|
||||||
expect(deriveCookieSecret(".acme.com")).toBe(
|
|
||||||
deriveCookieSecret(".acme.com"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("differs per salt", () => {
|
|
||||||
expect(deriveCookieSecret(".acme.com")).not.toBe(
|
|
||||||
deriveCookieSecret(".other.com"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
|
|
||||||
const secret = deriveCookieSecret(".acme.com");
|
|
||||||
expect(Buffer.from(secret, "hex")).toHaveLength(16);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -25,7 +25,6 @@ const baseSettings: WebServerSettings = {
|
|||||||
letsEncryptEmail: null,
|
letsEncryptEmail: null,
|
||||||
sshPrivateKey: null,
|
sshPrivateKey: null,
|
||||||
enableDockerCleanup: false,
|
enableDockerCleanup: false,
|
||||||
buildsConcurrency: 1,
|
|
||||||
logCleanupCron: null,
|
logCleanupCron: null,
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
containers: {
|
containers: {
|
||||||
@@ -66,8 +65,6 @@ const baseSettings: WebServerSettings = {
|
|||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: false,
|
cleanupCacheOnCompose: false,
|
||||||
cleanupCacheOnPreviews: false,
|
cleanupCacheOnPreviews: false,
|
||||||
remoteServersOnly: false,
|
|
||||||
enforceSSO: false,
|
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ const baseApp: ApplicationNested = {
|
|||||||
dropBuildPath: null,
|
dropBuildPath: null,
|
||||||
enabled: null,
|
enabled: null,
|
||||||
env: null,
|
env: null,
|
||||||
icon: null,
|
|
||||||
healthCheckSwarm: null,
|
healthCheckSwarm: null,
|
||||||
labelsSwarm: null,
|
labelsSwarm: null,
|
||||||
memoryLimit: null,
|
memoryLimit: null,
|
||||||
@@ -138,7 +137,6 @@ const baseDomain: Domain = {
|
|||||||
https: false,
|
https: false,
|
||||||
path: null,
|
path: null,
|
||||||
port: null,
|
port: null,
|
||||||
customEntrypoint: null,
|
|
||||||
serviceName: "",
|
serviceName: "",
|
||||||
composeId: "",
|
composeId: "",
|
||||||
customCertResolver: null,
|
customCertResolver: null,
|
||||||
@@ -147,8 +145,6 @@ const baseDomain: Domain = {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
@@ -268,80 +264,6 @@ test("Websecure entrypoint on https domain with redirect", async () => {
|
|||||||
expect(router.middlewares).toContain("redirect-test-1");
|
expect(router.middlewares).toContain("redirect-test-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Custom Middlewares */
|
|
||||||
|
|
||||||
test("Web entrypoint with single custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, middlewares: ["auth@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint with multiple custom middlewares", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
expect(router.middlewares).toContain("rate-limit@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint on https domain with custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should only have HTTPS redirect - custom middleware applies on websecure
|
|
||||||
expect(router.middlewares).toContain("redirect-to-https");
|
|
||||||
expect(router.middlewares).not.toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Websecure entrypoint with custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
|
||||||
"websecure",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should have custom middleware but not HTTPS redirect
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint with redirect and custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
{
|
|
||||||
...baseApp,
|
|
||||||
appName: "test",
|
|
||||||
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
|
|
||||||
},
|
|
||||||
{ ...baseDomain, middlewares: ["auth@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should have both redirect middleware and custom middleware
|
|
||||||
expect(router.middlewares).toContain("redirect-test-1");
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint with empty middlewares array", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: false, middlewares: [] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should behave same as no middlewares - no redirect for http
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Certificates */
|
/** Certificates */
|
||||||
|
|
||||||
test("CertificateType on websecure entrypoint", async () => {
|
test("CertificateType on websecure entrypoint", async () => {
|
||||||
@@ -354,130 +276,6 @@ test("CertificateType on websecure entrypoint", async () => {
|
|||||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Custom entrypoint on http domain", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: false, customEntrypoint: "custom" },
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
expect(router.tls).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint on https domain", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.rule).toContain("PathPrefix(`/api`)");
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("stripprefix--1");
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
internalPath: "/hello",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("addprefix--1");
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
path: "/public",
|
|
||||||
stripPath: true,
|
|
||||||
internalPath: "/app/v2",
|
|
||||||
},
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
|
|
||||||
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
|
|
||||||
|
|
||||||
expect(stripIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(addIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(stripIndex).toBeLessThan(addIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with https and custom cert resolver", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "custom",
|
|
||||||
customCertResolver: "myresolver",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.tls?.certResolver).toBe("myresolver");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint without https should not have tls", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: false,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.tls).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** IDN/Punycode */
|
/** IDN/Punycode */
|
||||||
|
|
||||||
test("Internationalized domain name is converted to punycode", async () => {
|
test("Internationalized domain name is converted to punycode", async () => {
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { VALID_HOSTNAME_REGEX } from "@dokploy/server";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("VALID_HOSTNAME_REGEX", () => {
|
|
||||||
it.each([
|
|
||||||
"example.com",
|
|
||||||
"sub.example.com",
|
|
||||||
"bbn-client.example.com",
|
|
||||||
"a.b.c.example.co",
|
|
||||||
"xn--80ak6aa92e.com",
|
|
||||||
"123.example.com",
|
|
||||||
])("accepts valid hostname %s", (host) => {
|
|
||||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
"bbn_client.example.com",
|
|
||||||
"-example.com",
|
|
||||||
"example-.com",
|
|
||||||
"example",
|
|
||||||
"exa mple.com",
|
|
||||||
"example..com",
|
|
||||||
"",
|
|
||||||
`a${"a".repeat(63)}.com`,
|
|
||||||
])("rejects invalid hostname %s", (host) => {
|
|
||||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// IDNs (Cyrillic, German umlauts, etc.) must be submitted in their
|
|
||||||
// ACME/punycode form ("xn--...") — that's what Let's Encrypt issues
|
|
||||||
// certificates for, so raw Unicode labels are rejected here.
|
|
||||||
it.each(["пример.рф", "bücher.de", "日本語.jp"])(
|
|
||||||
"rejects raw unicode IDN %s",
|
|
||||||
(host) => {
|
|
||||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
"xn--e1afmkfd.xn--p1ai", // punycode for пример.рф
|
|
||||||
"xn--bcher-kva.de", // punycode for bücher.de
|
|
||||||
"xn--wgv71a119e.jp", // punycode for 日本語.jp
|
|
||||||
])("accepts punycode-encoded IDN %s", (host) => {
|
|
||||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -78,20 +78,4 @@ describe("readValidDirectory (path traversal)", () => {
|
|||||||
it("returns false for empty string (resolves to cwd)", () => {
|
it("returns false for empty string (resolves to cwd)", () => {
|
||||||
expect(readValidDirectory("")).toBe(false);
|
expect(readValidDirectory("")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true for Next.js dynamic route paths with square brackets", () => {
|
|
||||||
expect(
|
|
||||||
readValidDirectory(
|
|
||||||
`${BASE}/applications/myapp/code/app/api/[id]/route.ts`,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
readValidDirectory(
|
|
||||||
`${BASE}/applications/myapp/code/app/[...catch]/page.tsx`,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "radix-nova",
|
"style": "default",
|
||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "tailwind.config.ts",
|
||||||
"css": "styles/globals.css",
|
"css": "styles/globals.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "zinc",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils"
|
"utils": "@/lib/utils"
|
||||||
},
|
}
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"rtl": false,
|
|
||||||
"menuColor": "default",
|
|
||||||
"menuAccent": "subtle",
|
|
||||||
"registries": {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,21 +112,14 @@ const menuItems: MenuItem[] = [
|
|||||||
|
|
||||||
const hasStopGracePeriodSwarm = (
|
const hasStopGracePeriodSwarm = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is { stopGracePeriodSwarm: number | string | null } =>
|
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||||
typeof value === "object" &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
"stopGracePeriodSwarm" in value;
|
"stopGracePeriodSwarm" in value;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "application"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
|
||||||
| "redis";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddSwarmSettings = ({ id, type }: Props) => {
|
export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||||
@@ -156,7 +149,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
<div className="flex gap-4 h-[60vh] py-4">
|
<div className="flex gap-4 h-[60vh] py-4">
|
||||||
{/* Left Column - Menu */}
|
{/* Left Column - Menu */}
|
||||||
<div className="w-64 shrink-0 border-r pr-4 overflow-y-auto">
|
<div className="w-64 flex-shrink-0 border-r pr-4 overflow-y-auto">
|
||||||
<nav className="space-y-1">
|
<nav className="space-y-1">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
|
|||||||
@@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddRedirectSchema = z.object({
|
const AddRedirectchema = z.object({
|
||||||
replicas: z.number().min(1, "Replicas must be at least 1"),
|
replicas: z.number().min(1, "Replicas must be at least 1"),
|
||||||
registryId: z.string().optional(),
|
registryId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
type AddCommand = z.infer<typeof AddRedirectchema>;
|
||||||
|
|
||||||
export const ShowClusterSettings = ({ id, type }: Props) => {
|
export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
application: () =>
|
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -65,13 +65,12 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
const { data: registries } = api.registry.all.useQuery();
|
const { data: registries } = api.registry.all.useQuery();
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
application: () => api.application.update.useMutation(),
|
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
mariadb: () => api.mariadb.update.useMutation(),
|
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
|
||||||
mysql: () => api.mysql.update.useMutation(),
|
|
||||||
postgres: () => api.postgres.update.useMutation(),
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
redis: () => api.redis.update.useMutation(),
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync, isPending } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
@@ -87,7 +86,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
: {}),
|
: {}),
|
||||||
replicas: data?.replicas || 1,
|
replicas: data?.replicas || 1,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRedirectSchema),
|
resolver: zodResolver(AddRedirectchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,11 +105,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
const onSubmit = async (data: AddCommand) => {
|
const onSubmit = async (data: AddCommand) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId: id || "",
|
applicationId: id || "",
|
||||||
mariadbId: id || "",
|
|
||||||
mongoId: id || "",
|
|
||||||
mysqlId: id || "",
|
|
||||||
postgresId: id || "",
|
postgresId: id || "",
|
||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
...(type === "application"
|
...(type === "application"
|
||||||
? {
|
? {
|
||||||
registryId:
|
registryId:
|
||||||
|
|||||||
@@ -28,14 +28,7 @@ export const endpointSpecFormSchema = z.object({
|
|||||||
|
|
||||||
interface EndpointSpecFormProps {
|
interface EndpointSpecFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||||
@@ -51,7 +44,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -64,7 +56,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -103,7 +94,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
endpointSpecSwarm: hasAnyValue ? formData : null,
|
endpointSpecSwarm: hasAnyValue ? formData : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,29 +16,17 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const optionalNumber = z
|
|
||||||
.union([z.string(), z.number()])
|
|
||||||
.transform((val) => (val === "" ? undefined : Number(val)))
|
|
||||||
.optional();
|
|
||||||
|
|
||||||
export const healthCheckFormSchema = z.object({
|
export const healthCheckFormSchema = z.object({
|
||||||
Test: z.array(z.string()).optional(),
|
Test: z.array(z.string()).optional(),
|
||||||
Interval: optionalNumber,
|
Interval: z.coerce.number().optional(),
|
||||||
Timeout: optionalNumber,
|
Timeout: z.coerce.number().optional(),
|
||||||
StartPeriod: optionalNumber,
|
StartPeriod: z.coerce.number().optional(),
|
||||||
Retries: optionalNumber,
|
Retries: z.coerce.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface HealthCheckFormProps {
|
interface HealthCheckFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||||
@@ -54,7 +42,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -67,7 +54,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -118,7 +104,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
healthCheckSwarm: hasAnyValue ? formData : null,
|
healthCheckSwarm: hasAnyValue ? formData : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,12 +185,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Time between health checks (e.g., 10000000000 for 10 seconds)
|
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="10000000000"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -222,12 +202,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Maximum time to wait for health check response
|
Maximum time to wait for health check response
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="10000000000"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -244,12 +219,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Initial grace period before health checks begin
|
Initial grace period before health checks begin
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="10000000000"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -267,12 +237,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
unhealthy
|
unhealthy
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="3" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="3"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -29,14 +29,7 @@ export const labelsFormSchema = z.object({
|
|||||||
|
|
||||||
interface LabelsFormProps {
|
interface LabelsFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||||
@@ -52,7 +45,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -65,7 +57,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -121,7 +112,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
labelsSwarm: labelsToSend,
|
labelsSwarm: labelsToSend,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,7 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
interface ModeFormProps {
|
interface ModeFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||||
@@ -46,7 +39,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -59,7 +51,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -104,7 +95,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
modeSwarm: null,
|
modeSwarm: null,
|
||||||
});
|
});
|
||||||
toast.success("Mode updated successfully");
|
toast.success("Mode updated successfully");
|
||||||
@@ -132,7 +122,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
modeSwarm: modeData,
|
modeSwarm: modeData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,14 +35,7 @@ export const networkFormSchema = z.object({
|
|||||||
|
|
||||||
interface NetworkFormProps {
|
interface NetworkFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||||
@@ -58,7 +51,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -71,7 +63,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -141,7 +132,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
networkSwarm: networksToSend,
|
networkSwarm: networksToSend,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,14 +34,7 @@ export const placementFormSchema = z.object({
|
|||||||
|
|
||||||
interface PlacementFormProps {
|
interface PlacementFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||||
@@ -57,7 +50,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -70,7 +62,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -123,7 +114,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
placementSwarm: hasAnyValue
|
placementSwarm: hasAnyValue
|
||||||
? {
|
? {
|
||||||
...formData,
|
...formData,
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ export const restartPolicyFormSchema = z.object({
|
|||||||
|
|
||||||
interface RestartPolicyFormProps {
|
interface RestartPolicyFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||||
@@ -55,7 +48,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -68,7 +60,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -113,7 +104,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
restartPolicySwarm: hasAnyValue ? formData : null,
|
restartPolicySwarm: hasAnyValue ? formData : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,14 +34,7 @@ export const rollbackConfigFormSchema = z.object({
|
|||||||
|
|
||||||
interface RollbackConfigFormProps {
|
interface RollbackConfigFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||||
@@ -57,7 +50,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -70,7 +62,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -112,7 +103,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,21 +16,14 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
const hasStopGracePeriodSwarm = (
|
const hasStopGracePeriodSwarm = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is { stopGracePeriodSwarm: number | string | null } =>
|
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||||
typeof value === "object" &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
"stopGracePeriodSwarm" in value;
|
"stopGracePeriodSwarm" in value;
|
||||||
|
|
||||||
interface StopGracePeriodFormProps {
|
interface StopGracePeriodFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||||
@@ -46,7 +39,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -59,7 +51,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -68,7 +59,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
|
|
||||||
const form = useForm<any>({
|
const form = useForm<any>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
value: null as number | null,
|
value: null as bigint | null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +67,11 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
if (hasStopGracePeriodSwarm(data)) {
|
if (hasStopGracePeriodSwarm(data)) {
|
||||||
const value = data.stopGracePeriodSwarm;
|
const value = data.stopGracePeriodSwarm;
|
||||||
const normalizedValue =
|
const normalizedValue =
|
||||||
value === null || value === undefined ? null : Number(value);
|
value === null || value === undefined
|
||||||
|
? null
|
||||||
|
: typeof value === "bigint"
|
||||||
|
? value
|
||||||
|
: BigInt(value);
|
||||||
form.reset({
|
form.reset({
|
||||||
value: normalizedValue,
|
value: normalizedValue,
|
||||||
});
|
});
|
||||||
@@ -93,7 +88,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
stopGracePeriodSwarm: formData.value,
|
stopGracePeriodSwarm: formData.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,7 +126,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
}
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(
|
field.onChange(
|
||||||
e.target.value ? Number(e.target.value) : null,
|
e.target.value ? BigInt(e.target.value) : null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -34,14 +34,7 @@ export const updateConfigFormSchema = z.object({
|
|||||||
|
|
||||||
interface UpdateConfigFormProps {
|
interface UpdateConfigFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||||
@@ -57,7 +50,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -70,7 +62,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -118,7 +109,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
(domain, index) => (
|
(domain, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="rounded-lg border bg-card p-3 text-card-foreground shadow-xs"
|
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{domain.serviceName}
|
{domain.serviceName}
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const AddRedirectSchema = z.object({
|
const AddRedirectchema = z.object({
|
||||||
regex: z.string().min(1, "Regex required"),
|
regex: z.string().min(1, "Regex required"),
|
||||||
permanent: z.boolean().default(false),
|
permanent: z.boolean().default(false),
|
||||||
replacement: z.string().min(1, "Replacement required"),
|
replacement: z.string().min(1, "Replacement required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddRedirect = z.infer<typeof AddRedirectSchema>;
|
type AddRedirect = z.infer<typeof AddRedirectchema>;
|
||||||
|
|
||||||
// Default presets
|
// Default presets
|
||||||
const redirectPresets = [
|
const redirectPresets = [
|
||||||
@@ -110,7 +110,7 @@ export const HandleRedirect = ({
|
|||||||
regex: "",
|
regex: "",
|
||||||
replacement: "",
|
replacement: "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRedirectSchema),
|
resolver: zodResolver(AddRedirectchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
|
|||||||
|
|
||||||
const onDialogToggle = (open: boolean) => {
|
const onDialogToggle = (open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
||||||
// setPresetSelected("");
|
// setPresetSelected("");
|
||||||
// form.reset();
|
// form.reset();
|
||||||
};
|
};
|
||||||
@@ -246,7 +246,7 @@ export const HandleRedirect = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="permanent"
|
name="permanent"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-xs">
|
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Permanent</FormLabel>
|
<FormLabel>Permanent</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
|||||||
@@ -89,13 +89,12 @@ const ULIMIT_PRESETS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export type ServiceType =
|
export type ServiceType =
|
||||||
| "application"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
| "postgres"
|
||||||
| "redis";
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "mysql"
|
||||||
|
| "mariadb"
|
||||||
|
| "application";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -106,29 +105,27 @@ type AddResources = z.infer<typeof addResourcesSchema>;
|
|||||||
|
|
||||||
export const ShowResources = ({ id, type }: Props) => {
|
export const ShowResources = ({ id, type }: Props) => {
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
application: () =>
|
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
application: () => api.application.update.useMutation(),
|
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
mariadb: () => api.mariadb.update.useMutation(),
|
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
|
||||||
mysql: () => api.mysql.update.useMutation(),
|
|
||||||
postgres: () => api.postgres.update.useMutation(),
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
redis: () => api.redis.update.useMutation(),
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync, isPending } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
@@ -158,20 +155,19 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
cpuReservation: data?.cpuReservation || undefined,
|
cpuReservation: data?.cpuReservation || undefined,
|
||||||
memoryLimit: data?.memoryLimit || undefined,
|
memoryLimit: data?.memoryLimit || undefined,
|
||||||
memoryReservation: data?.memoryReservation || undefined,
|
memoryReservation: data?.memoryReservation || undefined,
|
||||||
ulimitsSwarm: (data as any)?.ulimitsSwarm || [],
|
ulimitsSwarm: data?.ulimitsSwarm || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form, form.reset]);
|
}, [data, form, form.reset]);
|
||||||
|
|
||||||
const onSubmit = async (formData: AddResources) => {
|
const onSubmit = async (formData: AddResources) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId: id || "",
|
|
||||||
libsqlId: id || "",
|
|
||||||
mariadbId: id || "",
|
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
mysqlId: id || "",
|
|
||||||
postgresId: id || "",
|
postgresId: id || "",
|
||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
applicationId: id || "",
|
||||||
cpuLimit: formData.cpuLimit || null,
|
cpuLimit: formData.cpuLimit || null,
|
||||||
cpuReservation: formData.cpuReservation || null,
|
cpuReservation: formData.cpuReservation || null,
|
||||||
memoryLimit: formData.memoryLimit || null,
|
memoryLimit: formData.memoryLimit || null,
|
||||||
@@ -224,7 +220,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>Memory Limit</FormLabel>
|
<FormLabel>Memory Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -263,7 +259,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>Memory Reservation</FormLabel>
|
<FormLabel>Memory Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -303,7 +299,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>CPU Limit</FormLabel>
|
<FormLabel>CPU Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -343,7 +339,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>CPU Reservation</FormLabel>
|
<FormLabel>CPU Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -379,7 +375,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-xs">
|
<TooltipContent className="max-w-xs">
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2 relative">
|
<div className="flex flex-col pt-2 relative">
|
||||||
<div className="flex flex-col gap-6 max-h-140 min-h-40 overflow-y-auto">
|
<div className="flex flex-col gap-6 max-h-[35rem] min-h-[10rem] overflow-y-auto">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
lineWrapping
|
lineWrapping
|
||||||
value={data || "Empty"}
|
value={data || "Empty"}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
lineWrapping
|
lineWrapping
|
||||||
wrapperClassName="h-140 font-mono"
|
wrapperClassName="h-[35rem] font-mono"
|
||||||
placeholder={`http:
|
placeholder={`http:
|
||||||
routers:
|
routers:
|
||||||
router-name:
|
router-name:
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ interface Props {
|
|||||||
serviceId: string;
|
serviceId: string;
|
||||||
serviceType:
|
serviceType:
|
||||||
| "application"
|
| "application"
|
||||||
| "compose"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
| "postgres"
|
||||||
| "redis";
|
| "redis"
|
||||||
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "mysql"
|
||||||
|
| "mariadb"
|
||||||
|
| "compose";
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -220,7 +220,7 @@ export const AddVolumes = ({
|
|||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor="bind"
|
htmlFor="bind"
|
||||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary has-data-[state=checked]:border-primary cursor-pointer"
|
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
||||||
>
|
>
|
||||||
Bind Mount
|
Bind Mount
|
||||||
</Label>
|
</Label>
|
||||||
@@ -240,7 +240,7 @@ export const AddVolumes = ({
|
|||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor="volume"
|
htmlFor="volume"
|
||||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary has-data-[state=checked]:border-primary cursor-pointer"
|
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
||||||
>
|
>
|
||||||
Volume Mount
|
Volume Mount
|
||||||
</Label>
|
</Label>
|
||||||
@@ -264,7 +264,7 @@ export const AddVolumes = ({
|
|||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor="file"
|
htmlFor="file"
|
||||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary has-data-[state=checked]:border-primary cursor-pointer"
|
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
||||||
>
|
>
|
||||||
File Mount
|
File Mount
|
||||||
</Label>
|
</Label>
|
||||||
@@ -324,7 +324,7 @@ export const AddVolumes = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="max-w-full max-w-180">
|
<FormItem className="max-w-full max-w-[45rem]">
|
||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>Content</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -29,25 +29,23 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
if (!canRead) return null;
|
if (!canRead) return null;
|
||||||
|
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
application: () =>
|
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
|
||||||
compose: () =>
|
|
||||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
compose: () =>
|
||||||
|
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
const { mutateAsync: deleteVolume, isPending: isRemoving } =
|
const { mutateAsync: deleteVolume, isPending: isRemoving } =
|
||||||
api.mounts.remove.useMutation();
|
api.mounts.remove.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
||||||
@@ -111,7 +109,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
{mount.type === "file" && (
|
{mount.type === "file" && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium">Content</span>
|
<span className="font-medium">Content</span>
|
||||||
<span className="text-sm text-muted-foreground line-clamp-10 whitespace-break-spaces">
|
<span className="text-sm text-muted-foreground line-clamp-[10] whitespace-break-spaces">
|
||||||
{mount.content}
|
{mount.content}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ interface Props {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
serviceType:
|
serviceType:
|
||||||
| "application"
|
| "application"
|
||||||
| "compose"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
| "postgres"
|
||||||
| "redis";
|
| "redis"
|
||||||
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "mysql"
|
||||||
|
| "mariadb"
|
||||||
|
| "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateVolume = ({
|
export const UpdateVolume = ({
|
||||||
@@ -253,7 +253,7 @@ export const UpdateVolume = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full max-w-180">
|
<FormItem className="max-w-full max-w-[45rem]">
|
||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>Content</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { Check, Copy, Loader2 } from "lucide-react";
|
import { Check, Copy, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -166,7 +165,6 @@ export const ShowDeployment = ({
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<AnalyzeLogs logs={filteredLogs} context="build" />
|
|
||||||
|
|
||||||
{serverId && (
|
{serverId && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -191,7 +189,7 @@ export const ShowDeployment = ({
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-background rounded custom-logs-scrollbar"
|
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||||
>
|
>
|
||||||
{" "}
|
{" "}
|
||||||
{filteredLogs.length > 0 ? (
|
{filteredLogs.length > 0 ? (
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import copy from "copy-to-clipboard";
|
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
@@ -147,7 +147,7 @@ export const ShowDeployments = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background border-0">
|
<Card className="bg-background border-none">
|
||||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<CardTitle className="text-xl">Deployments</CardTitle>
|
<CardTitle className="text-xl">Deployments</CardTitle>
|
||||||
@@ -233,6 +233,7 @@ export const ShowDeployments = ({
|
|||||||
<span>Webhook URL: </span>
|
<span>Webhook URL: </span>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Copy webhook URL to clipboard"
|
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"
|
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"
|
||||||
@@ -300,7 +301,7 @@ export const ShowDeployments = ({
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="wrap-break-word text-sm text-muted-foreground whitespace-pre-wrap">
|
<span className="break-words text-sm text-muted-foreground whitespace-pre-wrap">
|
||||||
{isExpanded || !needsTruncation
|
{isExpanded || !needsTruncation
|
||||||
? titleText
|
? titleText
|
||||||
: truncateDescription(titleText)}
|
: truncateDescription(titleText)}
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
import type { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
ArrowUpDown,
|
|
||||||
CheckCircle2,
|
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
|
||||||
PenBoxIcon,
|
|
||||||
RefreshCw,
|
|
||||||
Server,
|
|
||||||
Trash2,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import type { RouterOutputs } from "@/utils/api";
|
|
||||||
import { DnsHelperModal } from "./dns-helper-modal";
|
|
||||||
import { AddDomain } from "./handle-domain";
|
|
||||||
import type { ValidationStates } from "./show-domains";
|
|
||||||
|
|
||||||
export type Domain =
|
|
||||||
| RouterOutputs["domain"]["byApplicationId"][0]
|
|
||||||
| RouterOutputs["domain"]["byComposeId"][0];
|
|
||||||
|
|
||||||
interface ColumnsProps {
|
|
||||||
id: string;
|
|
||||||
type: "application" | "compose";
|
|
||||||
validationStates: ValidationStates;
|
|
||||||
handleValidateDomain: (host: string) => Promise<void>;
|
|
||||||
handleDeleteDomain: (domainId: string) => Promise<void>;
|
|
||||||
isDeleting: boolean;
|
|
||||||
serverIp?: string;
|
|
||||||
canCreateDomain: boolean;
|
|
||||||
canDeleteDomain: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createColumns = ({
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
validationStates,
|
|
||||||
handleValidateDomain,
|
|
||||||
handleDeleteDomain,
|
|
||||||
isDeleting,
|
|
||||||
serverIp,
|
|
||||||
canCreateDomain,
|
|
||||||
canDeleteDomain,
|
|
||||||
}: ColumnsProps): ColumnDef<Domain>[] => [
|
|
||||||
...(type === "compose"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
accessorKey: "serviceName",
|
|
||||||
header: "Service",
|
|
||||||
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
|
|
||||||
const serviceName = row.getValue("serviceName") as string | null;
|
|
||||||
if (!serviceName) return null;
|
|
||||||
return (
|
|
||||||
<Badge variant="outline">
|
|
||||||
<Server className="size-3 mr-1" />
|
|
||||||
{serviceName}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
} satisfies ColumnDef<Domain>,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
accessorKey: "host",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Host
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const domain = row.original;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
className="flex items-center gap-2 font-medium hover:underline"
|
|
||||||
target="_blank"
|
|
||||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
|
||||||
>
|
|
||||||
{domain.host}
|
|
||||||
<ExternalLink className="size-3" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "path",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Path
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const path = row.getValue("path") as string;
|
|
||||||
return <div className="font-mono text-sm">{path || "/"}</div>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "port",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Port
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const port = row.getValue("port") as number;
|
|
||||||
return <Badge variant="secondary">{port}</Badge>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "customEntrypoint",
|
|
||||||
header: "Entrypoint",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const entrypoint = row.getValue("customEntrypoint") as string | null;
|
|
||||||
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
|
|
||||||
return <div className="font-mono text-sm">{entrypoint}</div>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "https",
|
|
||||||
header: "Protocol",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const https = row.getValue("https") as boolean;
|
|
||||||
return (
|
|
||||||
<Badge variant={https ? "outline" : "secondary"}>
|
|
||||||
{https ? "HTTPS" : "HTTP"}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "certificate",
|
|
||||||
header: "Certificate",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const domain = row.original;
|
|
||||||
const validationState = validationStates[domain.host];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{domain.certificateType && (
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{domain.certificateType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{!domain.host.includes("sslip.io") && (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={
|
|
||||||
validationState?.isValid
|
|
||||||
? "bg-green-500/10 text-green-500 cursor-pointer"
|
|
||||||
: validationState?.error
|
|
||||||
? "bg-red-500/10 text-red-500 cursor-pointer"
|
|
||||||
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
|
||||||
}
|
|
||||||
onClick={() => handleValidateDomain(domain.host)}
|
|
||||||
>
|
|
||||||
{validationState?.isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="size-3 mr-1 animate-spin" />
|
|
||||||
Checking...
|
|
||||||
</>
|
|
||||||
) : validationState?.isValid ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="size-3 mr-1" />
|
|
||||||
{validationState.message && validationState.cdnProvider
|
|
||||||
? `${validationState.cdnProvider}`
|
|
||||||
: "Valid"}
|
|
||||||
</>
|
|
||||||
) : validationState?.error ? (
|
|
||||||
<>
|
|
||||||
<XCircle className="size-3 mr-1" />
|
|
||||||
Invalid
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="size-3 mr-1" />
|
|
||||||
Validate
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-xs">
|
|
||||||
{validationState?.error ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p className="font-medium text-red-500">Error:</p>
|
|
||||||
<p>{validationState.error}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
"Click to validate DNS configuration"
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "createdAt",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Created
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const createdAt = row.getValue("createdAt") as string;
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{new Date(createdAt).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: "Actions",
|
|
||||||
enableHiding: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const domain = row.original;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{!domain.host.includes("sslip.io") && (
|
|
||||||
<DnsHelperModal
|
|
||||||
domain={{
|
|
||||||
host: domain.host,
|
|
||||||
https: domain.https,
|
|
||||||
path: domain.path || undefined,
|
|
||||||
}}
|
|
||||||
serverIp={serverIp}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canCreateDomain && (
|
|
||||||
<AddDomain id={id} type={type} domainId={domain.domainId}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10 h-8 w-8"
|
|
||||||
>
|
|
||||||
<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 handleDeleteDomain(domain.domainId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-red-500/10 h-8 w-8"
|
|
||||||
isLoading={isDeleting}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import {
|
|
||||||
INVALID_HOSTNAME_MESSAGE,
|
|
||||||
VALID_HOSTNAME_REGEX,
|
|
||||||
} from "@dokploy/server/utils/hostname-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
|
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -57,10 +52,7 @@ export const domain = z
|
|||||||
.refine((val) => val === val.trim(), {
|
.refine((val) => val === val.trim(), {
|
||||||
message: "Domain name cannot have leading or trailing spaces",
|
message: "Domain name cannot have leading or trailing spaces",
|
||||||
})
|
})
|
||||||
.transform((val) => val.trim())
|
.transform((val) => val.trim()),
|
||||||
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
|
|
||||||
message: INVALID_HOSTNAME_MESSAGE,
|
|
||||||
}),
|
|
||||||
path: z.string().min(1).optional(),
|
path: z.string().min(1).optional(),
|
||||||
internalPath: z.string().optional(),
|
internalPath: z.string().optional(),
|
||||||
stripPath: z.boolean().optional(),
|
stripPath: z.boolean().optional(),
|
||||||
@@ -69,14 +61,11 @@ export const domain = z
|
|||||||
.min(1, { message: "Port must be at least 1" })
|
.min(1, { message: "Port must be at least 1" })
|
||||||
.max(65535, { message: "Port must be 65535 or below" })
|
.max(65535, { message: "Port must be 65535 or below" })
|
||||||
.optional(),
|
.optional(),
|
||||||
useCustomEntrypoint: z.boolean(),
|
|
||||||
customEntrypoint: z.string().optional(),
|
|
||||||
https: z.boolean().optional(),
|
https: z.boolean().optional(),
|
||||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||||
customCertResolver: z.string().optional(),
|
customCertResolver: z.string().optional(),
|
||||||
serviceName: z.string().optional(),
|
serviceName: z.string().optional(),
|
||||||
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
||||||
middlewares: z.array(z.string()).optional(),
|
|
||||||
})
|
})
|
||||||
.superRefine((input, ctx) => {
|
.superRefine((input, ctx) => {
|
||||||
if (input.https && !input.certificateType) {
|
if (input.https && !input.certificateType) {
|
||||||
@@ -125,14 +114,6 @@ export const domain = z
|
|||||||
message: "Internal path must start with '/'",
|
message: "Internal path must start with '/'",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.useCustomEntrypoint && !input.customEntrypoint) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["customEntrypoint"],
|
|
||||||
message: "Custom entry point must be specified",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Domain = z.infer<typeof domain>;
|
type Domain = z.infer<typeof domain>;
|
||||||
@@ -215,24 +196,20 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
internalPath: undefined,
|
internalPath: undefined,
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
port: undefined,
|
port: undefined,
|
||||||
useCustomEntrypoint: false,
|
|
||||||
customEntrypoint: undefined,
|
|
||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
serviceName: undefined,
|
serviceName: undefined,
|
||||||
domainType: type,
|
domainType: type,
|
||||||
middlewares: [],
|
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const certificateType = form.watch("certificateType");
|
const certificateType = form.watch("certificateType");
|
||||||
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
|
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domainType = form.watch("domainType");
|
const domainType = form.watch("domainType");
|
||||||
const host = form.watch("host");
|
const host = form.watch("host");
|
||||||
const isTraefikMeDomain = host?.includes("sslip.io") || false;
|
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -243,13 +220,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
internalPath: data?.internalPath || undefined,
|
internalPath: data?.internalPath || undefined,
|
||||||
stripPath: data?.stripPath || false,
|
stripPath: data?.stripPath || false,
|
||||||
port: data?.port || undefined,
|
port: data?.port || undefined,
|
||||||
useCustomEntrypoint: !!data.customEntrypoint,
|
|
||||||
customEntrypoint: data.customEntrypoint || undefined,
|
|
||||||
certificateType: data?.certificateType || undefined,
|
certificateType: data?.certificateType || undefined,
|
||||||
customCertResolver: data?.customCertResolver || undefined,
|
customCertResolver: data?.customCertResolver || undefined,
|
||||||
serviceName: data?.serviceName || undefined,
|
serviceName: data?.serviceName || undefined,
|
||||||
domainType: data?.domainType || type,
|
domainType: data?.domainType || type,
|
||||||
middlewares: data?.middlewares || [],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,13 +234,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
internalPath: undefined,
|
internalPath: undefined,
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
port: undefined,
|
port: undefined,
|
||||||
useCustomEntrypoint: false,
|
|
||||||
customEntrypoint: undefined,
|
|
||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
domainType: type,
|
domainType: type,
|
||||||
middlewares: [],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data, isPending, domainId]);
|
}, [form, data, isPending, domainId]);
|
||||||
@@ -297,7 +268,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
composeId: id,
|
composeId: id,
|
||||||
}),
|
}),
|
||||||
...data,
|
...data,
|
||||||
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(dictionary.success);
|
toast.success(dictionary.success);
|
||||||
@@ -356,7 +326,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
{domainType === "compose" && (
|
{domainType === "compose" && (
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
{errorServices && (
|
{errorServices && (
|
||||||
<AlertBlock type="warning" className="wrap-anywhere">
|
<AlertBlock
|
||||||
|
type="warning"
|
||||||
|
className="[overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
{errorServices?.message}
|
{errorServices?.message}
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
@@ -424,7 +397,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="left"
|
side="left"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-40"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Fetch: Will clone the repository and
|
Fetch: Will clone the repository and
|
||||||
@@ -454,7 +427,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="left"
|
side="left"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-40"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Cache: If you previously deployed this
|
Cache: If you previously deployed this
|
||||||
@@ -492,7 +465,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="left"
|
side="left"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-40"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{isManualInput
|
{isManualInput
|
||||||
@@ -517,7 +490,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
{!canGenerateTraefikMeDomains &&
|
{!canGenerateTraefikMeDomains &&
|
||||||
field.value.includes("sslip.io") && (
|
field.value.includes("traefik.me") && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to set an IP address in your{" "}
|
You need to set an IP address in your{" "}
|
||||||
<Link
|
<Link
|
||||||
@@ -528,12 +501,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
: "Web Server -> Server -> Update Server IP"}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to make your sslip.io domain work.
|
to make your traefik.me domain work.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> sslip.io is a public HTTP
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
service and does not support SSL/HTTPS. HTTPS and
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
certificate options will not have any effect.
|
certificate options will not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -569,9 +542,9 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="left"
|
side="left"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-40"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>Generate sslip.io domain</p>
|
<p>Generate traefik.me domain</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -622,7 +595,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="stripPath"
|
name="stripPath"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-xs">
|
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Strip Path</FormLabel>
|
<FormLabel>Strip Path</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -662,60 +635,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="useCustomEntrypoint"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-xs">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Custom Entrypoint</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Use custom entrypoint for domain
|
|
||||||
<br />
|
|
||||||
"web" and/or "websecure" is used by default.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
field.onChange(checked);
|
|
||||||
if (!checked) {
|
|
||||||
form.setValue("customEntrypoint", undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{useCustomEntrypoint && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="customEntrypoint"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
<FormLabel>Entrypoint Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Enter entrypoint name manually"
|
|
||||||
{...field}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="https"
|
name="https"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-xs">
|
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>HTTPS</FormLabel>
|
<FormLabel>HTTPS</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -767,37 +691,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
<SelectItem value={"custom"}>Custom</SelectItem>
|
<SelectItem value={"custom"}>Custom</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
|
||||||
{field.value === "none" && (
|
|
||||||
<>
|
|
||||||
<strong>None</strong> serves TLS using any
|
|
||||||
certificate you created in the{" "}
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings/certificates"
|
|
||||||
className="text-primary"
|
|
||||||
>
|
|
||||||
Certificates
|
|
||||||
</Link>{" "}
|
|
||||||
section whose CN/SAN matches this host —
|
|
||||||
Traefik selects it automatically via SNI.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{field.value === "letsencrypt" && (
|
|
||||||
<>
|
|
||||||
<strong>Let's Encrypt</strong> auto-provisions
|
|
||||||
a certificate automatically for this host.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{field.value === "custom" && (
|
|
||||||
<>
|
|
||||||
<strong>Custom</strong> uses a Traefik cert
|
|
||||||
resolver by name (defined in your static
|
|
||||||
configuration).
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!field.value &&
|
|
||||||
"Select a certificate provider to see how TLS will be served for this host."}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
@@ -812,19 +705,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Custom Certificate Resolver</FormLabel>
|
<FormLabel>Custom Certificate Resolver</FormLabel>
|
||||||
<FormDescription>
|
|
||||||
Enter the <strong>name</strong> of a Traefik
|
|
||||||
cert resolver defined in your static
|
|
||||||
configuration (e.g. <code>letsencrypt</code>) —
|
|
||||||
not certificate or private key content. To use a
|
|
||||||
certificate you pasted in the Certificates
|
|
||||||
section, choose <strong>None</strong> instead
|
|
||||||
and Traefik will match it by SNI.
|
|
||||||
</FormDescription>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
placeholder="e.g. letsencrypt"
|
placeholder="Enter your custom certificate resolver"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -841,88 +725,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="middlewares"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FormLabel>Middlewares</FormLabel>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger type="button">
|
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-[300px]">
|
|
||||||
<p>
|
|
||||||
Add Traefik middleware references. Middlewares
|
|
||||||
must be defined in your Traefik configuration.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
|
||||||
{field.value?.map((name, index) => (
|
|
||||||
<Badge key={index} variant="secondary">
|
|
||||||
{name}
|
|
||||||
<X
|
|
||||||
className="ml-1 size-3 cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
const newMiddlewares = [...(field.value || [])];
|
|
||||||
newMiddlewares.splice(index, 1);
|
|
||||||
form.setValue("middlewares", newMiddlewares);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="e.g., rate-limit@file, auth@file"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = e.currentTarget;
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (value && !field.value?.includes(value)) {
|
|
||||||
form.setValue("middlewares", [
|
|
||||||
...(field.value || []),
|
|
||||||
value,
|
|
||||||
]);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
const input = document.querySelector(
|
|
||||||
'input[placeholder="e.g., rate-limit@file, auth@file"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (value && !field.value?.includes(value)) {
|
|
||||||
form.setValue("middlewares", [
|
|
||||||
...(field.value || []),
|
|
||||||
value,
|
|
||||||
]);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import { ShieldCheck } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
domainId: string;
|
|
||||||
applicationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HandleForwardAuth = ({ domainId, applicationId }: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: haveValidLicense } =
|
|
||||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const { data: status } = api.forwardAuth.status.useQuery(
|
|
||||||
{ domainId },
|
|
||||||
{ enabled: isOpen },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: enable, isPending: isEnabling } =
|
|
||||||
api.forwardAuth.enable.useMutation();
|
|
||||||
const { mutateAsync: disable, isPending: isDisabling } =
|
|
||||||
api.forwardAuth.disable.useMutation();
|
|
||||||
|
|
||||||
if (!haveValidLicense) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEnabled = !!status?.enabled;
|
|
||||||
const isPending = isEnabling || isDisabling;
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
await utils.forwardAuth.status.invalidate({ domainId });
|
|
||||||
await utils.domain.byApplicationId.invalidate({ applicationId });
|
|
||||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle = async (next: boolean) => {
|
|
||||||
try {
|
|
||||||
if (next) {
|
|
||||||
await enable({ domainId });
|
|
||||||
toast.success("SSO authentication enabled for this domain");
|
|
||||||
} else {
|
|
||||||
await disable({ domainId });
|
|
||||||
toast.success("SSO authentication disabled for this domain");
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Error updating SSO authentication",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-emerald-500/10"
|
|
||||||
title="SSO authentication"
|
|
||||||
>
|
|
||||||
<ShieldCheck
|
|
||||||
className={`size-4 ${
|
|
||||||
isEnabled
|
|
||||||
? "text-emerald-500"
|
|
||||||
: "text-primary group-hover:text-emerald-500"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>SSO Authentication</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Require visitors to authenticate against your identity provider
|
|
||||||
before reaching this application.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Requirements</span>
|
|
||||||
<ol className="list-decimal pl-4 text-sm">
|
|
||||||
<li>
|
|
||||||
The authentication proxy container must be deployed and running
|
|
||||||
on this app's server. Configure it under{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
Settings → SSO → Application Authentication
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
This domain must share the same base domain as the
|
|
||||||
authentication domain (e.g. <code>app.acme.com</code> and{" "}
|
|
||||||
<code>auth.acme.com</code>).
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</AlertBlock>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4 mt-2">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Protect this domain with SSO
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{isEnabled
|
|
||||||
? "Visitors must log in via your identity provider."
|
|
||||||
: "The domain is publicly accessible."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={isEnabled}
|
|
||||||
disabled={isPending}
|
|
||||||
onCheckedChange={handleToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,22 +1,8 @@
|
|||||||
import {
|
|
||||||
type ColumnFiltersState,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
type SortingState,
|
|
||||||
useReactTable,
|
|
||||||
type VisibilityState,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
LayoutGrid,
|
|
||||||
LayoutList,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -37,21 +23,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -59,10 +30,8 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { createColumns } from "./columns";
|
|
||||||
import { DnsHelperModal } from "./dns-helper-modal";
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
import { AddDomain } from "./handle-domain";
|
import { AddDomain } from "./handle-domain";
|
||||||
import { HandleForwardAuth } from "./handle-forward-auth";
|
|
||||||
|
|
||||||
export type ValidationState = {
|
export type ValidationState = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -105,19 +74,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
return (
|
|
||||||
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
|
|
||||||
"grid"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return "grid";
|
|
||||||
});
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
|
||||||
const { data: ip } = api.settings.getIp.useQuery();
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -147,16 +103,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
api.domain.delete.useMutation();
|
||||||
|
|
||||||
const handleDeleteDomain = async (domainId: string) => {
|
|
||||||
try {
|
|
||||||
await deleteDomain({ domainId });
|
|
||||||
refetch();
|
|
||||||
toast.success("Domain deleted successfully");
|
|
||||||
} catch {
|
|
||||||
toast.error("Error deleting domain");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleValidateDomain = async (host: string) => {
|
const handleValidateDomain = async (host: string) => {
|
||||||
setValidationStates((prev) => ({
|
setValidationStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -194,37 +140,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = createColumns({
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
validationStates,
|
|
||||||
handleValidateDomain,
|
|
||||||
handleDeleteDomain,
|
|
||||||
isDeleting: isRemoving,
|
|
||||||
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
|
||||||
canCreateDomain,
|
|
||||||
canDeleteDomain,
|
|
||||||
});
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: data ?? [],
|
|
||||||
columns,
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
state: {
|
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
columnVisibility,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
@@ -236,32 +151,13 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-2 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
{data && data?.length > 0 && (
|
{canCreateDomain && data && data?.length > 0 && (
|
||||||
<>
|
<AddDomain id={id} type={type}>
|
||||||
<Button
|
<Button>
|
||||||
variant="outline"
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
const next = viewMode === "grid" ? "table" : "grid";
|
|
||||||
localStorage.setItem("domains-view-mode", next);
|
|
||||||
setViewMode(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{viewMode === "grid" ? (
|
|
||||||
<LayoutList className="size-4" />
|
|
||||||
) : (
|
|
||||||
<LayoutGrid className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
{canCreateDomain && (
|
</AddDomain>
|
||||||
<AddDomain id={id} type={type}>
|
|
||||||
<Button>
|
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
|
||||||
</Button>
|
|
||||||
</AddDomain>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -290,122 +186,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "table" ? (
|
|
||||||
<div className="flex flex-col gap-4 w-full">
|
|
||||||
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
|
||||||
<Input
|
|
||||||
placeholder="Filter by host..."
|
|
||||||
value={
|
|
||||||
(table.getColumn("host")?.getFilterValue() as string) ?? ""
|
|
||||||
}
|
|
||||||
onChange={(event) =>
|
|
||||||
table.getColumn("host")?.setFilterValue(event.target.value)
|
|
||||||
}
|
|
||||||
className="md:max-w-sm"
|
|
||||||
/>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="sm:ml-auto max-sm:w-full"
|
|
||||||
>
|
|
||||||
Columns <ChevronDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter((column) => column.getCanHide())
|
|
||||||
.map((column) => {
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={column.id}
|
|
||||||
className="capitalize"
|
|
||||||
checked={column.getIsVisible()}
|
|
||||||
onCheckedChange={(value) =>
|
|
||||||
column.toggleVisibility(!!value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{column.id}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
return (
|
|
||||||
<TableHead key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table?.getRowModel()?.rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id}>
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext(),
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
No results.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
{data && data?.length > 0 && (
|
|
||||||
<div className="flex items-center justify-end space-x-2 py-4">
|
|
||||||
<div className="space-x-2 flex flex-wrap">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||||
{data?.map((item) => {
|
{data?.map((item) => {
|
||||||
@@ -426,7 +206,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{!item.host.includes("sslip.io") && (
|
{!item.host.includes("traefik.me") && (
|
||||||
<DnsHelperModal
|
<DnsHelperModal
|
||||||
domain={{
|
domain={{
|
||||||
host: item.host,
|
host: item.host,
|
||||||
@@ -454,12 +234,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</AddDomain>
|
</AddDomain>
|
||||||
)}
|
)}
|
||||||
{canCreateDomain && type === "application" && (
|
|
||||||
<HandleForwardAuth
|
|
||||||
domainId={item.domainId}
|
|
||||||
applicationId={id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canDeleteDomain && (
|
{canDeleteDomain && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete Domain"
|
title="Delete Domain"
|
||||||
@@ -567,22 +341,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.middlewares?.map((middleware, index) => (
|
|
||||||
<TooltipProvider key={`${middleware}-${index}`}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<InfoIcon className="size-3 mr-1" />
|
|
||||||
Middleware: {middleware}
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Traefik middleware reference</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -39,16 +39,15 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
const canWrite = permissions?.envVars.write ?? false;
|
const canWrite = permissions?.envVars.write ?? false;
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
compose: () =>
|
|
||||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
compose: () =>
|
||||||
|
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -56,17 +55,16 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
compose: () => api.compose.saveEnvironment.useMutation(),
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
libsql: () => api.libsql.saveEnvironment.useMutation(),
|
redis: () => api.redis.update.useMutation(),
|
||||||
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
mongo: () => api.mongo.saveEnvironment.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
mysql: () => api.mysql.saveEnvironment.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
postgres: () => api.postgres.saveEnvironment.useMutation(),
|
compose: () => api.compose.update.useMutation(),
|
||||||
redis: () => api.redis.saveEnvironment.useMutation(),
|
|
||||||
};
|
};
|
||||||
const { mutateAsync, isPending } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.saveEnvironment.useMutation();
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<EnvironmentSchema>({
|
const form = useForm<EnvironmentSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -89,13 +87,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
const onSubmit = async (formData: EnvironmentSchema) => {
|
const onSubmit = async (formData: EnvironmentSchema) => {
|
||||||
mutateAsync({
|
mutateAsync({
|
||||||
composeId: id || "",
|
|
||||||
libsqlId: id || "",
|
|
||||||
mariadbId: id || "",
|
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
mysqlId: id || "",
|
|
||||||
postgresId: id || "",
|
postgresId: id || "",
|
||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
composeId: id || "",
|
||||||
env: formData.environment,
|
env: formData.environment,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -116,7 +113,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="createEnvFile"
|
name="createEnvFile"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-xs">
|
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Create Environment File</FormLabel>
|
<FormLabel>Create Environment File</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
@@ -188,9 +184,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Bitbucket Account</FormLabel>
|
<FormLabel>Bitbucket Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -199,6 +192,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
|
defaultValue={field.value}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -247,7 +241,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -256,19 +250,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
: isLoadingRepositories
|
: isLoadingRepositories
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: (repositories?.find(
|
: (repositories?.find(
|
||||||
(repo) =>
|
(repo) => repo.name === field.value.repo,
|
||||||
repo.name === field.value.repo &&
|
|
||||||
repo.owner.username === field.value.owner,
|
|
||||||
)?.name ?? "Select repository")}
|
)?.name ?? "Select repository")}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
@@ -288,7 +277,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{repositories?.map((repo) => (
|
{repositories?.map((repo) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={`${repo.owner.username}/${repo.name}`}
|
value={repo.name}
|
||||||
key={repo.url}
|
key={repo.url}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
@@ -299,8 +288,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate">{repo.name}</span>
|
<span>{repo.name}</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{repo.owner.username}
|
{repo.owner.username}
|
||||||
</span>
|
</span>
|
||||||
@@ -308,8 +297,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
repo.name === field.value.repo &&
|
repo.name === field.value.repo
|
||||||
repo.owner.username === field.value.owner
|
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
@@ -341,7 +329,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -356,10 +344,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
@@ -387,7 +372,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("branch", branch.name);
|
form.setValue("branch", branch.name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{branch.name}</span>
|
{branch.name}
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
@@ -509,7 +494,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
|
|||||||
repositoryURL: z.string().min(1, {
|
repositoryURL: z.string().min(1, {
|
||||||
message: "Repository URL is required",
|
message: "Repository URL is required",
|
||||||
}),
|
}),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -59,7 +55,7 @@ interface Props {
|
|||||||
|
|
||||||
export const SaveGitProvider = ({ applicationId }: Props) => {
|
export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isPending } =
|
const { mutateAsync, isPending } =
|
||||||
@@ -111,103 +107,110 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start">
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
<FormField
|
className="flex flex-col gap-4"
|
||||||
control={form.control}
|
>
|
||||||
name="repositoryURL"
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
render={({ field }) => (
|
<div className="flex items-end col-span-2 gap-4">
|
||||||
<FormItem className="col-span-2 lg:col-span-3">
|
<div className="grow">
|
||||||
<div className="flex items-center justify-between h-5">
|
<FormField
|
||||||
<FormLabel>Repository URL</FormLabel>
|
control={form.control}
|
||||||
{field.value?.startsWith("https://") && (
|
name="repositoryURL"
|
||||||
<Link
|
render={({ field }) => (
|
||||||
href={field.value}
|
<FormItem>
|
||||||
target="_blank"
|
<div className="flex items-center justify-between">
|
||||||
rel="noopener noreferrer"
|
<FormLabel>Repository URL</FormLabel>
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
{field.value?.startsWith("https://") && (
|
||||||
>
|
<Link
|
||||||
<GitIcon className="h-4 w-4" />
|
href={field.value}
|
||||||
<span>View Repository</span>
|
target="_blank"
|
||||||
</Link>
|
rel="noopener noreferrer"
|
||||||
)}
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
</div>
|
>
|
||||||
<FormControl>
|
<GitIcon className="h-4 w-4" />
|
||||||
<Input placeholder="Repository URL" {...field} />
|
<span>View Repository</span>
|
||||||
</FormControl>
|
</Link>
|
||||||
<FormMessage />
|
)}
|
||||||
</FormItem>
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Repository URL" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{sshKeys && sshKeys.length > 0 ? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="basis-40">
|
||||||
|
<FormLabel className="w-full inline-flex justify-between">
|
||||||
|
SSH Key
|
||||||
|
<LockIcon className="size-4 text-muted-foreground" />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
key={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a key" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{sshKeys?.map((sshKey) => (
|
||||||
|
<SelectItem
|
||||||
|
key={sshKey.sshKeyId}
|
||||||
|
value={sshKey.sshKeyId}
|
||||||
|
>
|
||||||
|
{sshKey.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<KeyRoundIcon className="size-4" /> Add SSH Key
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
{sshKeys && sshKeys.length > 0 ? (
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="sshKey"
|
name="branch"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2 lg:col-span-1">
|
<FormItem>
|
||||||
<FormLabel className="w-full inline-flex justify-between">
|
<FormLabel>Branch</FormLabel>
|
||||||
SSH Key
|
|
||||||
<LockIcon className="size-4 text-muted-foreground" />
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Input placeholder="Branch" {...field} />
|
||||||
key={field.value}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a key" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{sshKeys?.map((sshKey) => (
|
|
||||||
<SelectItem
|
|
||||||
key={sshKey.sshKeyId}
|
|
||||||
value={sshKey.sshKeyId}
|
|
||||||
>
|
|
||||||
{sshKey.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="none">None</SelectItem>
|
|
||||||
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
|
||||||
type="button"
|
|
||||||
className="col-span-2 lg:col-span-1 lg:mt-7"
|
|
||||||
>
|
|
||||||
<KeyRoundIcon className="size-4" /> Add SSH Key
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="branch"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-2">
|
|
||||||
<FormLabel>Branch</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Branch" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="buildPath"
|
name="buildPath"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2">
|
<FormItem>
|
||||||
<FormLabel>Build Path</FormLabel>
|
<FormLabel>Build Path</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="/" {...field} />
|
<Input placeholder="/" {...field} />
|
||||||
@@ -220,7 +223,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="watchPaths"
|
name="watchPaths"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2 lg:col-span-4">
|
<FormItem className="md:col-span-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -305,7 +308,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -73,10 +72,7 @@ const GiteaProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).default([]),
|
watchPaths: z.array(z.string()).default([]),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
@@ -201,9 +197,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Gitea Account</FormLabel>
|
<FormLabel>Gitea Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -211,6 +204,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
|
defaultValue={field.value}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -260,7 +254,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -270,18 +264,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
? "Loading...."
|
? "Loading...."
|
||||||
: (repositories?.find(
|
: (repositories?.find(
|
||||||
(repo: GiteaRepository) =>
|
(repo: GiteaRepository) =>
|
||||||
repo.name === field.value.repo &&
|
repo.name === field.value.repo,
|
||||||
repo.owner.username === field.value.owner,
|
|
||||||
)?.name ?? "Select repository")}
|
)?.name ?? "Select repository")}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
@@ -307,7 +297,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
{repositories?.map((repo: GiteaRepository) => {
|
{repositories?.map((repo: GiteaRepository) => {
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={`${repo.owner.username}/${repo.name}`}
|
value={repo.name}
|
||||||
key={repo.url}
|
key={repo.url}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
@@ -317,10 +307,8 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate">
|
<span>{repo.name}</span>
|
||||||
{repo.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{repo.owner.username}
|
{repo.owner.username}
|
||||||
</span>
|
</span>
|
||||||
@@ -328,9 +316,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
repo.name === field.value.repo &&
|
repo.name === field.value.repo
|
||||||
repo.owner.username ===
|
|
||||||
field.value.owner
|
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
@@ -363,7 +349,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -379,10 +365,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
@@ -413,7 +396,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("branch", branch.name);
|
form.setValue("branch", branch.name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{branch.name}</span>
|
{branch.name}
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
@@ -538,7 +521,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
@@ -177,9 +173,6 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Github Account</FormLabel>
|
<FormLabel>Github Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -192,14 +185,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a Github Account">
|
<SelectValue placeholder="Select a Github Account" />
|
||||||
{
|
|
||||||
githubProviders?.find(
|
|
||||||
(githubProvider) =>
|
|
||||||
githubProvider.githubId === field.value,
|
|
||||||
)?.gitProvider.name
|
|
||||||
}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -243,7 +229,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -252,19 +238,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
: isLoadingRepositories
|
: isLoadingRepositories
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: (repositories?.find(
|
: (repositories?.find(
|
||||||
(repo) =>
|
(repo) => repo.name === field.value.repo,
|
||||||
repo.name === field.value.repo &&
|
)?.name ?? "Select repository")}
|
||||||
repo.owner.login === field.value.owner,
|
|
||||||
)?.name ?? field.value.repo)}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
@@ -284,7 +265,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{repositories?.map((repo) => (
|
{repositories?.map((repo) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={`${repo.owner.login}/${repo.name}`}
|
value={repo.name}
|
||||||
key={repo.url}
|
key={repo.url}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
@@ -294,8 +275,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate">{repo.name}</span>
|
<span>{repo.name}</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{repo.owner.login}
|
{repo.owner.login}
|
||||||
</span>
|
</span>
|
||||||
@@ -303,8 +284,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
repo.name === field.value.repo &&
|
repo.name === field.value.repo
|
||||||
repo.owner.login === field.value.owner
|
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
@@ -336,25 +316,22 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "pending" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? (branches?.find(
|
? branches?.find(
|
||||||
(branch) => branch.name === field.value,
|
(branch) => branch.name === field.value,
|
||||||
)?.name ?? field.value)
|
)?.name
|
||||||
: "Select branch"}
|
: "Select branch"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
@@ -382,7 +359,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("branch", branch.name);
|
form.setValue("branch", branch.name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{branch.name}</span>
|
{branch.name}
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
@@ -550,7 +527,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
|
|||||||
id: z.number().nullable(),
|
id: z.number().nullable(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -196,9 +192,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Gitlab Account</FormLabel>
|
<FormLabel>Gitlab Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -208,6 +201,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
|
defaultValue={field.value}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -256,7 +250,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -272,10 +266,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
@@ -313,10 +304,8 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate">
|
<span>{repo.name}</span>
|
||||||
{repo.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{repo.owner.username}
|
{repo.owner.username}
|
||||||
</span>
|
</span>
|
||||||
@@ -358,7 +347,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -373,10 +362,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
@@ -404,7 +390,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("branch", branch.name);
|
form.setValue("branch", branch.name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{branch.name}</span>
|
{branch.name}
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
@@ -528,7 +514,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -154,10 +154,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full overflow-auto">
|
<div className="flex flex-row items-center justify-between w-full overflow-auto">
|
||||||
<TabsList
|
<TabsList className="flex gap-4 justify-start bg-transparent">
|
||||||
variant="line"
|
|
||||||
className="flex gap-4 justify-start bg-transparent"
|
|
||||||
>
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="github"
|
value="github"
|
||||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -7,7 +8,6 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
|
import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
|
||||||
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
|
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
|
||||||
@@ -58,7 +58,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-2 lg:flex lg:flex-row lg:flex-wrap gap-4">
|
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||||
{canDeploy && (
|
{canDeploy && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
@@ -94,7 +94,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-60">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>
|
<p>
|
||||||
Downloads the source code and performs a complete
|
Downloads the source code and performs a complete
|
||||||
build
|
build
|
||||||
@@ -137,7 +137,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-60">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>Reload the application without rebuilding it</p>
|
<p>Reload the application without rebuilding it</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
@@ -176,7 +176,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-60">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>
|
<p>
|
||||||
Only rebuilds the application without downloading new
|
Only rebuilds the application without downloading new
|
||||||
code
|
code
|
||||||
@@ -219,7 +219,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-60">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>
|
<p>
|
||||||
Start the application (requires a previous successful
|
Start the application (requires a previous successful
|
||||||
build)
|
build)
|
||||||
@@ -259,7 +259,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-60">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>Stop the currently running application</p>
|
<p>Stop the currently running application</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
@@ -274,14 +274,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2 col-span-2"
|
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Terminal className="size-4 mr-1" />
|
<Terminal className="size-4 mr-1" />
|
||||||
Open Terminal
|
Open Terminal
|
||||||
</Button>
|
</Button>
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
{canUpdateService && (
|
{canUpdateService && (
|
||||||
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
<span className="text-sm font-medium">Autodeploy</span>
|
<span className="text-sm font-medium">Autodeploy</span>
|
||||||
<Switch
|
<Switch
|
||||||
aria-label="Toggle autodeploy"
|
aria-label="Toggle autodeploy"
|
||||||
@@ -305,7 +305,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{canUpdateService && (
|
{canUpdateService && (
|
||||||
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
|
<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>
|
<span className="text-sm font-medium">Clean Cache</span>
|
||||||
<Switch
|
<Switch
|
||||||
aria-label="Toggle clean cache"
|
aria-label="Toggle clean cache"
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
import DOMPurify from "dompurify";
|
|
||||||
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Dropzone } from "@/components/ui/dropzone";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
interface ShowIconSettingsProps {
|
|
||||||
applicationId: string;
|
|
||||||
icon?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const svgToDataUrl = (icon: BundledIcon): string => {
|
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#${icon.hex}"><path d="${icon.path}"/></svg>`;
|
|
||||||
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ShowIconSettings = ({
|
|
||||||
applicationId,
|
|
||||||
icon,
|
|
||||||
}: ShowIconSettingsProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [iconSearchQuery, setIconSearchQuery] = useState("");
|
|
||||||
const [iconsToShow, setIconsToShow] = useState(24);
|
|
||||||
|
|
||||||
const filteredIcons = useMemo(() => {
|
|
||||||
if (!iconSearchQuery) return bundledIcons;
|
|
||||||
const q = iconSearchQuery.toLowerCase();
|
|
||||||
return bundledIcons.filter(
|
|
||||||
(i) =>
|
|
||||||
i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [iconSearchQuery]);
|
|
||||||
|
|
||||||
const displayedIcons = filteredIcons.slice(0, iconsToShow);
|
|
||||||
const hasMoreIcons = filteredIcons.length > iconsToShow;
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync: updateApplication } =
|
|
||||||
api.application.update.useMutation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setIconSearchQuery("");
|
|
||||||
setIconsToShow(24);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleIconSelect = async (selectedIcon: BundledIcon) => {
|
|
||||||
try {
|
|
||||||
const dataUrl = svgToDataUrl(selectedIcon);
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: dataUrl,
|
|
||||||
});
|
|
||||||
toast.success("Icon saved successfully");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
setOpen(false);
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error saving icon");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveIcon = async () => {
|
|
||||||
try {
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: null,
|
|
||||||
});
|
|
||||||
toast.success("Icon removed");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error removing icon");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeSvg = (svgContent: string): string | null => {
|
|
||||||
const clean = DOMPurify.sanitize(svgContent, {
|
|
||||||
USE_PROFILES: { svg: true, svgFilters: true },
|
|
||||||
ADD_TAGS: ["use"],
|
|
||||||
});
|
|
||||||
if (!clean) return null;
|
|
||||||
return `data:image/svg+xml;base64,${btoa(clean)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async (files: FileList | null) => {
|
|
||||||
if (!files || files.length === 0) return;
|
|
||||||
const file = files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const allowedTypes = [
|
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/png",
|
|
||||||
"image/svg+xml",
|
|
||||||
];
|
|
||||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
|
||||||
const allowedExtensions = ["jpg", "jpeg", "png", "svg"];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!allowedTypes.includes(file.type) &&
|
|
||||||
!allowedExtensions.includes(fileExtension || "")
|
|
||||||
) {
|
|
||||||
toast.error("Only JPG, JPEG, PNG, and SVG files are allowed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
|
||||||
toast.error("Image size must be less than 2MB");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
|
|
||||||
|
|
||||||
if (isSvg) {
|
|
||||||
const text = await file.text();
|
|
||||||
const sanitizedDataUrl = sanitizeSvg(text);
|
|
||||||
if (!sanitizedDataUrl) {
|
|
||||||
toast.error("Invalid SVG file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: sanitizedDataUrl,
|
|
||||||
});
|
|
||||||
toast.success("Icon saved!");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
setOpen(false);
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error saving icon");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async (event) => {
|
|
||||||
const result = event.target?.result as string;
|
|
||||||
try {
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: result,
|
|
||||||
});
|
|
||||||
toast.success("Icon saved!");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
setOpen(false);
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error saving icon");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group flex items-center justify-center"
|
|
||||||
>
|
|
||||||
{icon ? (
|
|
||||||
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
|
|
||||||
<img
|
|
||||||
src={icon}
|
|
||||||
alt="Application icon"
|
|
||||||
className="h-8 w-8 object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Pencil className="h-3 w-3 text-white" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center justify-between">
|
|
||||||
Change Icon
|
|
||||||
{icon && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRemoveIcon}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X className="size-4 mr-1" />
|
|
||||||
Remove icon
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search icons (e.g. react, vue, docker)..."
|
|
||||||
value={iconSearchQuery}
|
|
||||||
onChange={(e) => setIconSearchQuery(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[300px] overflow-y-auto border rounded-lg p-4">
|
|
||||||
{displayedIcons.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
|
||||||
No icons found
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
|
||||||
{displayedIcons.map((i) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={i.slug}
|
|
||||||
onClick={() => handleIconSelect(i)}
|
|
||||||
className="flex flex-col items-center gap-1.5 p-2 rounded-lg border hover:border-primary hover:bg-muted transition-colors group"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="size-7 group-hover:scale-110 transition-transform"
|
|
||||||
fill={`#${i.hex}`}
|
|
||||||
>
|
|
||||||
<path d={i.path} />
|
|
||||||
</svg>
|
|
||||||
<span className="text-[10px] text-muted-foreground capitalize truncate w-full text-center">
|
|
||||||
{i.title}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{hasMoreIcons && (
|
|
||||||
<div className="flex justify-center mt-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIconsToShow((prev) => prev + 24)}
|
|
||||||
>
|
|
||||||
Load More ({filteredIcons.length - iconsToShow} remaining)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative pt-3 border-t">
|
|
||||||
<p className="text-sm text-muted-foreground text-center mb-3">
|
|
||||||
or upload a custom icon
|
|
||||||
</p>
|
|
||||||
<Dropzone
|
|
||||||
dropMessage="Drag & drop an icon or click to upload"
|
|
||||||
accept=".jpg,.jpeg,.png,.svg,image/jpeg,image/png,image/svg+xml"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
|
|
||||||
/>
|
|
||||||
<div className="mt-2 text-center text-xs text-muted-foreground">
|
|
||||||
Supported formats: JPG, JPEG, PNG, SVG (max 2MB)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -91,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
}, [option, services, containers]);
|
}, [option, services, containers]);
|
||||||
|
|
||||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||||
const containersLength =
|
const containersLenght =
|
||||||
option === "native" ? containers?.length : services?.length;
|
option === "native" ? containers?.length : services?.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SelectLabel>Containers ({containersLength})</SelectLabel>
|
<SelectLabel>Containers ({containersLenght})</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from "./patch-editor";
|
|
||||||
export * from "./show-patches";
|
export * from "./show-patches";
|
||||||
|
export * from "./patch-editor";
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const AddPreviewDomain = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const host = form.watch("host");
|
const host = form.watch("host");
|
||||||
const isTraefikMeDomain = host?.includes("sslip.io") || false;
|
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -162,7 +162,7 @@ export const AddPreviewDomain = ({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> sslip.io is a public HTTP
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
service and does not support SSL/HTTPS. HTTPS and
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
certificate options will not have any effect.
|
certificate options will not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -200,9 +200,9 @@ export const AddPreviewDomain = ({
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="left"
|
side="left"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-40"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>Generate sslip.io domain</p>
|
<p>Generate traefik.me domain</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -249,7 +249,7 @@ export const AddPreviewDomain = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="https"
|
name="https"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-xs">
|
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>HTTPS</FormLabel>
|
<FormLabel>HTTPS</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
import {
|
import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -8,7 +9,6 @@ import {
|
|||||||
RocketIcon,
|
RocketIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
@@ -132,7 +132,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<GitPullRequest className="size-5 text-muted-foreground mt-1 shrink-0" />
|
<GitPullRequest className="size-5 text-muted-foreground mt-1 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-sm">
|
<div className="font-medium text-sm">
|
||||||
{deployment.pullRequestTitle}
|
{deployment.pullRequestTitle}
|
||||||
@@ -152,7 +152,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pl-8 space-y-3">
|
<div className="pl-8 space-y-3">
|
||||||
<div className="relative grow">
|
<div className="relative flex-grow">
|
||||||
<Input
|
<Input
|
||||||
value={deploymentUrl}
|
value={deploymentUrl}
|
||||||
readOnly
|
readOnly
|
||||||
@@ -244,7 +244,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="z-60"
|
className="z-[60]"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Rebuild the preview deployment without
|
Rebuild the preview deployment without
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
env: "",
|
env: "",
|
||||||
wildcardDomain: "*.sslip.io",
|
wildcardDomain: "*.traefik.me",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
previewLimit: 3,
|
previewLimit: 3,
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
@@ -102,7 +102,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
const previewHttps = form.watch("previewHttps");
|
const previewHttps = form.watch("previewHttps");
|
||||||
const wildcardDomain = form.watch("wildcardDomain");
|
const wildcardDomain = form.watch("wildcardDomain");
|
||||||
const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false;
|
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||||
@@ -114,7 +114,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
env: data.previewEnv || "",
|
env: data.previewEnv || "",
|
||||||
buildArgs: data.previewBuildArgs || "",
|
buildArgs: data.previewBuildArgs || "",
|
||||||
buildSecrets: data.previewBuildSecrets || "",
|
buildSecrets: data.previewBuildSecrets || "",
|
||||||
wildcardDomain: data.previewWildcard || "*.sslip.io",
|
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
||||||
port: data.previewPort || 3000,
|
port: data.previewPort || 3000,
|
||||||
previewLabels: data.previewLabels || [],
|
previewLabels: data.previewLabels || [],
|
||||||
previewLimit: data.previewLimit || 3,
|
previewLimit: data.previewLimit || 3,
|
||||||
@@ -173,7 +173,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> sslip.io is a public HTTP service and
|
<strong>Note:</strong> traefik.me is a public HTTP service and
|
||||||
does not support SSL/HTTPS. HTTPS and certificate options will
|
does not support SSL/HTTPS. HTTPS and certificate options will
|
||||||
not have any effect.
|
not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -192,7 +192,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Wildcard Domain</FormLabel>
|
<FormLabel>Wildcard Domain</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="*.sslip.io" {...field} />
|
<Input placeholder="*.traefik.me" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -325,7 +325,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="previewHttps"
|
name="previewHttps"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-xs">
|
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>HTTPS</FormLabel>
|
<FormLabel>HTTPS</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -431,7 +431,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="previewRequireCollaboratorPermissions"
|
name="previewRequireCollaboratorPermissions"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-xs col-span-2">
|
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm col-span-2">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
Require Collaborator Permissions
|
Require Collaborator Permissions
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ export const commonCronExpressions = [
|
|||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
description: z.string().optional(),
|
|
||||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||||
shellType: z.enum(["bash", "sh"]).default("bash"),
|
shellType: z.enum(["bash", "sh"]).default("bash"),
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
@@ -225,7 +224,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
resolver: standardSchemaResolver(formSchema),
|
resolver: standardSchemaResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
|
||||||
cronExpression: "",
|
cronExpression: "",
|
||||||
shellType: "bash",
|
shellType: "bash",
|
||||||
command: "",
|
command: "",
|
||||||
@@ -265,7 +263,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
if (scheduleId && schedule) {
|
if (scheduleId && schedule) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: schedule.name,
|
name: schedule.name,
|
||||||
description: schedule.description || "",
|
|
||||||
cronExpression: schedule.cronExpression,
|
cronExpression: schedule.cronExpression,
|
||||||
shellType: schedule.shellType,
|
shellType: schedule.shellType,
|
||||||
command: schedule.command,
|
command: schedule.command,
|
||||||
@@ -355,7 +352,10 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
{scheduleTypeForm === "compose" && (
|
{scheduleTypeForm === "compose" && (
|
||||||
<div className="flex flex-col w-full gap-4">
|
<div className="flex flex-col w-full gap-4">
|
||||||
{errorServices && (
|
{errorServices && (
|
||||||
<AlertBlock type="warning" className="wrap-anywhere">
|
<AlertBlock
|
||||||
|
type="warning"
|
||||||
|
className="[overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
{errorServices?.message}
|
{errorServices?.message}
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
@@ -411,7 +411,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="left"
|
side="left"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-40"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Fetch: Will clone the repository and load the
|
Fetch: Will clone the repository and load the
|
||||||
@@ -441,7 +441,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="left"
|
side="left"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-40"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Cache: If you previously deployed this compose,
|
Cache: If you previously deployed this compose,
|
||||||
@@ -479,26 +479,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Backs up the database every day at midnight"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Optional description of what this schedule does
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScheduleFormField
|
<ScheduleFormField
|
||||||
name="cronExpression"
|
name="cronExpression"
|
||||||
formControl={form.control}
|
formControl={form.control}
|
||||||
@@ -531,7 +511,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||||
<CardHeader className="px-0">
|
<CardHeader className="px-0">
|
||||||
<div className="flex justify-between items-center gap-y-2 flex-wrap">
|
<div className="flex justify-between items-center gap-y-2 flex-wrap">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -110,12 +110,12 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
|
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 w-full sm:w-auto">
|
<div className="flex items-start gap-3 w-full sm:w-auto">
|
||||||
<div className="flex shrink-0 h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
<div className="flex flex-shrink-0 h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||||
<Clock className="size-4 text-primary/70" />
|
<Clock className="size-4 text-primary/70" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 w-full sm:w-auto">
|
<div className="space-y-1.5 w-full sm:w-auto">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h3 className="text-sm font-medium leading-none wrap-anywhere line-clamp-3">
|
<h3 className="text-sm font-medium leading-none [overflow-wrap:anywhere] line-clamp-3">
|
||||||
{schedule.name}
|
{schedule.name}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -125,11 +125,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
{schedule.enabled ? "Enabled" : "Disabled"}
|
{schedule.enabled ? "Enabled" : "Disabled"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{schedule.description && (
|
|
||||||
<p className="text-xs text-muted-foreground/70 wrap-anywhere line-clamp-2">
|
|
||||||
{schedule.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -154,7 +149,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
{schedule.command && (
|
{schedule.command && (
|
||||||
<div className="flex items-start gap-2 max-w-full">
|
<div className="flex items-start gap-2 max-w-full">
|
||||||
<Terminal className="size-3.5 text-muted-foreground/70 shrink-0 mt-0.5" />
|
<Terminal className="size-3.5 text-muted-foreground/70 flex-shrink-0 mt-0.5" />
|
||||||
<code className="font-mono text-[10px] text-muted-foreground/70 break-all max-w-[calc(100%-20px)]">
|
<code className="font-mono text-[10px] text-muted-foreground/70 break-all max-w-[calc(100%-20px)]">
|
||||||
{schedule.command}
|
{schedule.command}
|
||||||
</code>
|
</code>
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ const formSchema = z
|
|||||||
"mongo",
|
"mongo",
|
||||||
"mysql",
|
"mysql",
|
||||||
"redis",
|
"redis",
|
||||||
"libsql",
|
|
||||||
]),
|
]),
|
||||||
serviceName: z.string(),
|
serviceName: z.string(),
|
||||||
destinationId: z.string().min(1, "Destination required"),
|
destinationId: z.string().min(1, "Destination required"),
|
||||||
@@ -349,7 +348,10 @@ export const HandleVolumeBackups = ({
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col w-full gap-4">
|
<div className="flex flex-col w-full gap-4">
|
||||||
{errorServices && (
|
{errorServices && (
|
||||||
<AlertBlock type="warning" className="wrap-anywhere">
|
<AlertBlock
|
||||||
|
type="warning"
|
||||||
|
className="[overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
{errorServices?.message}
|
{errorServices?.message}
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
@@ -405,7 +407,7 @@ export const HandleVolumeBackups = ({
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="left"
|
side="left"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-40"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Fetch: Will clone the repository and load the
|
Fetch: Will clone the repository and load the
|
||||||
@@ -435,7 +437,7 @@ export const HandleVolumeBackups = ({
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="left"
|
side="left"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-40"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Cache: If you previously deployed this
|
Cache: If you previously deployed this
|
||||||
@@ -480,7 +482,7 @@ export const HandleVolumeBackups = ({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Choose the volume to backup. If you do not see the
|
Choose the volume to backup, if you dont see the
|
||||||
volume here, you can type the volume name manually
|
volume here, you can type the volume name manually
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -507,24 +509,15 @@ export const HandleVolumeBackups = ({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{mounts && mounts.length > 0 ? (
|
{mounts?.map((mount) => (
|
||||||
mounts.map((mount) => (
|
<SelectItem key={mount.Name} value={mount.Name || ""}>
|
||||||
<SelectItem
|
{mount.Name}
|
||||||
key={mount.Name}
|
|
||||||
value={mount.Name || ""}
|
|
||||||
>
|
|
||||||
{mount.Name}
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<SelectItem value="none" disabled>
|
|
||||||
No volumes found
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Choose the volume to backup. If you do not see the volume
|
Choose the volume to backup, if you dont see the volume
|
||||||
here, you can type the volume name manually
|
here, you can type the volume name manually
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const ShowVolumeBackups = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||||
<CardHeader className="px-0">
|
<CardHeader className="px-0">
|
||||||
<div className="flex justify-between items-center flex-wrap gap-2">
|
<div className="flex justify-between items-center flex-wrap gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="isolatedDeployment"
|
name="isolatedDeployment"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
|
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
Enable Isolated Deployment ({data?.appName})
|
Enable Isolated Deployment ({data?.appName})
|
||||||
|
|||||||
@@ -1,290 +0,0 @@
|
|||||||
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
|
|
||||||
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
|
|
||||||
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
|
|
||||||
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const DockerLogsId = dynamic(
|
|
||||||
() =>
|
|
||||||
import("@/components/dashboard/docker/logs/docker-logs-id").then(
|
|
||||||
(e) => e.DockerLogsId,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
appName: string;
|
|
||||||
serverId?: string;
|
|
||||||
appType: "stack" | "docker-compose";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShowComposeContainers = ({
|
|
||||||
appName,
|
|
||||||
appType,
|
|
||||||
serverId,
|
|
||||||
}: Props) => {
|
|
||||||
const { data, isPending, refetch } =
|
|
||||||
api.docker.getContainersByAppNameMatch.useQuery(
|
|
||||||
{
|
|
||||||
appName,
|
|
||||||
appType,
|
|
||||||
serverId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!appName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-background">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-xl">Containers</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Inspect each container in this compose and run basic lifecycle
|
|
||||||
actions.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isPending ? (
|
|
||||||
<div className="flex items-center justify-center h-[20vh]">
|
|
||||||
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : !data || data.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-[20vh]">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
No containers found. Deploy the compose to see containers here.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>State</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Container ID</TableHead>
|
|
||||||
<TableHead className="text-right" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{data.map((container) => (
|
|
||||||
<ContainerRow
|
|
||||||
key={container.containerId}
|
|
||||||
container={container}
|
|
||||||
serverId={serverId}
|
|
||||||
onActionComplete={() => refetch()}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ContainerRowProps {
|
|
||||||
container: {
|
|
||||||
containerId: string;
|
|
||||||
name: string;
|
|
||||||
state: string;
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
serverId?: string;
|
|
||||||
onActionComplete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContainerRow = ({
|
|
||||||
container,
|
|
||||||
serverId,
|
|
||||||
onActionComplete,
|
|
||||||
}: ContainerRowProps) => {
|
|
||||||
const [logsOpen, setLogsOpen] = useState(false);
|
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const restartMutation = api.docker.restartContainer.useMutation();
|
|
||||||
const startMutation = api.docker.startContainer.useMutation();
|
|
||||||
const stopMutation = api.docker.stopContainer.useMutation();
|
|
||||||
const killMutation = api.docker.killContainer.useMutation();
|
|
||||||
|
|
||||||
const handleAction = async (
|
|
||||||
action: string,
|
|
||||||
mutationFn: typeof restartMutation,
|
|
||||||
) => {
|
|
||||||
setActionLoading(action);
|
|
||||||
try {
|
|
||||||
await mutationFn.mutateAsync({
|
|
||||||
containerId: container.containerId,
|
|
||||||
serverId,
|
|
||||||
});
|
|
||||||
toast.success(`Container ${action} successfully`);
|
|
||||||
onActionComplete();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell className="font-medium">{container.name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
container.state === "running"
|
|
||||||
? "default"
|
|
||||||
: container.state === "exited"
|
|
||||||
? "secondary"
|
|
||||||
: "destructive"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{container.state}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{container.status}</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm text-muted-foreground">
|
|
||||||
{container.containerId}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
{actionLoading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
View Logs
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogTrigger>
|
|
||||||
<ShowContainerConfig
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={serverId || ""}
|
|
||||||
/>
|
|
||||||
<ShowContainerMounts
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={serverId || ""}
|
|
||||||
/>
|
|
||||||
<ShowContainerNetworks
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={serverId || ""}
|
|
||||||
/>
|
|
||||||
<DockerTerminalModal
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={serverId || ""}
|
|
||||||
>
|
|
||||||
Terminal
|
|
||||||
</DockerTerminalModal>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
onClick={() => handleAction("restart", restartMutation)}
|
|
||||||
>
|
|
||||||
Restart
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
onClick={() => handleAction("start", startMutation)}
|
|
||||||
>
|
|
||||||
Start
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
onClick={() => handleAction("stop", stopMutation)}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer text-red-500 focus:text-red-600"
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
onClick={() => handleAction("kill", killMutation)}
|
|
||||||
>
|
|
||||||
Kill
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<DialogContent className="sm:max-w-7xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>View Logs</DialogTitle>
|
|
||||||
<DialogDescription>Logs for {container.name}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
|
||||||
<DockerLogsId
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={serverId}
|
|
||||||
runType="native"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -57,7 +57,6 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
mariadb: () =>
|
mariadb: () =>
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
@@ -73,7 +72,6 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
redis: () => api.redis.remove.useMutation(),
|
redis: () => api.redis.remove.useMutation(),
|
||||||
mysql: () => api.mysql.remove.useMutation(),
|
mysql: () => api.mysql.remove.useMutation(),
|
||||||
mariadb: () => api.mariadb.remove.useMutation(),
|
mariadb: () => api.mariadb.remove.useMutation(),
|
||||||
libsql: () => api.libsql.remove.useMutation(),
|
|
||||||
application: () => api.application.delete.useMutation(),
|
application: () => api.application.delete.useMutation(),
|
||||||
mongo: () => api.mongo.remove.useMutation(),
|
mongo: () => api.mongo.remove.useMutation(),
|
||||||
compose: () => api.compose.delete.useMutation(),
|
compose: () => api.compose.delete.useMutation(),
|
||||||
@@ -100,7 +98,6 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
applicationId: id || "",
|
applicationId: id || "",
|
||||||
composeId: id || "",
|
composeId: id || "",
|
||||||
deleteVolumes,
|
deleteVolumes,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
|
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -72,7 +72,7 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-60">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>
|
<p>
|
||||||
Downloads the source code and performs a complete build
|
Downloads the source code and performs a complete build
|
||||||
</p>
|
</p>
|
||||||
@@ -113,7 +113,7 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-60">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>Reload the compose without rebuilding it</p>
|
<p>Reload the compose without rebuilding it</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
@@ -154,7 +154,7 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-60">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>
|
<p>
|
||||||
Start the compose (requires a previous successful build)
|
Start the compose (requires a previous successful build)
|
||||||
</p>
|
</p>
|
||||||
@@ -193,7 +193,7 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-60">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>Stop the currently running compose</p>
|
<p>Stop the currently running compose</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
const composeFile = form.watch("composeFile");
|
const composeFile = form.watch("composeFile");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data && !composeFile) {
|
||||||
form.reset({
|
form.reset({
|
||||||
composeFile: data.composeFile || "",
|
composeFile: data.composeFile || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data]);
|
}, [form, form.reset, data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.composeFile !== undefined) {
|
if (data?.composeFile !== undefined) {
|
||||||
@@ -95,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
@@ -135,7 +135,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="overflow-auto">
|
<FormItem className="overflow-auto">
|
||||||
<FormControl className="">
|
<FormControl className="">
|
||||||
<div className="flex flex-col gap-4 w-full outline-hidden focus:outline-hidden overflow-auto">
|
<div className="flex flex-col gap-4 w-full outline-none focus:outline-none overflow-auto">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
// disabled
|
// disabled
|
||||||
language="yaml"
|
language="yaml"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -190,9 +186,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Bitbucket Account</FormLabel>
|
<FormLabel>Bitbucket Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -201,6 +194,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
|
defaultValue={field.value}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -249,7 +243,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -258,19 +252,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
: isLoadingRepositories
|
: isLoadingRepositories
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: (repositories?.find(
|
: (repositories?.find(
|
||||||
(repo) =>
|
(repo) => repo.name === field.value.repo,
|
||||||
repo.name === field.value.repo &&
|
|
||||||
repo.owner.username === field.value.owner,
|
|
||||||
)?.name ?? "Select repository")}
|
)?.name ?? "Select repository")}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
@@ -290,7 +279,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{repositories?.map((repo) => (
|
{repositories?.map((repo) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={`${repo.owner.username}/${repo.name}`}
|
value={repo.name}
|
||||||
key={repo.url}
|
key={repo.url}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
@@ -301,8 +290,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate">{repo.name}</span>
|
<span>{repo.name}</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{repo.owner.username}
|
{repo.owner.username}
|
||||||
</span>
|
</span>
|
||||||
@@ -310,8 +299,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
repo.name === field.value.repo &&
|
repo.name === field.value.repo
|
||||||
repo.owner.username === field.value.owner
|
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
@@ -343,7 +331,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -358,10 +346,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
@@ -389,7 +374,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
form.setValue("branch", branch.name);
|
form.setValue("branch", branch.name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{branch.name}</span>
|
{branch.name}
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
@@ -433,7 +418,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
?
|
?
|
||||||
</div>
|
</div>
|
||||||
@@ -513,7 +498,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
|
|||||||
repositoryURL: z.string().min(1, {
|
repositoryURL: z.string().min(1, {
|
||||||
message: "Repository URL is required",
|
message: "Repository URL is required",
|
||||||
}),
|
}),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -59,7 +55,7 @@ interface Props {
|
|||||||
|
|
||||||
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||||
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||||
@@ -313,7 +309,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -58,10 +57,7 @@ const GiteaProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -188,9 +184,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Gitea Account</FormLabel>
|
<FormLabel>Gitea Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -198,6 +191,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
|
defaultValue={field.value}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -246,7 +240,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -255,18 +249,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
: isLoadingRepositories
|
: isLoadingRepositories
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: (repositories?.find(
|
: (repositories?.find(
|
||||||
(repo) =>
|
(repo) => repo.name === field.value.repo,
|
||||||
repo.name === field.value.repo &&
|
|
||||||
repo.owner.username === field.value.owner,
|
|
||||||
)?.name ?? "Select repository")}
|
)?.name ?? "Select repository")}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
@@ -287,7 +276,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
{repositories?.map((repo) => (
|
{repositories?.map((repo) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={repo.url}
|
key={repo.url}
|
||||||
value={`${repo.owner.username}/${repo.name}`}
|
value={repo.name}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: repo.owner.username,
|
owner: repo.owner.username,
|
||||||
@@ -296,8 +285,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate">{repo.name}</span>
|
<span>{repo.name}</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{repo.owner.username}
|
{repo.owner.username}
|
||||||
</span>
|
</span>
|
||||||
@@ -305,8 +294,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
repo.name === field.value.repo &&
|
repo.name === field.value.repo
|
||||||
repo.owner.username === field.value.owner
|
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
@@ -339,7 +327,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -354,10 +342,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search branches..."
|
placeholder="Search branches..."
|
||||||
@@ -374,10 +359,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
form.setValue("branch", branch.name)
|
form.setValue("branch", branch.name)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate">
|
{branch.name}
|
||||||
{branch.name}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -426,8 +409,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger>
|
||||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
@@ -504,7 +489,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
@@ -138,7 +134,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules ?? false,
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data]);
|
}, [form.reset, data?.composeId, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: GithubProvider) => {
|
const onSubmit = async (data: GithubProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -179,9 +175,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Github Account</FormLabel>
|
<FormLabel>Github Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -189,6 +182,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
|
defaultValue={field.value}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -236,7 +230,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -245,19 +239,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
: isLoadingRepositories
|
: isLoadingRepositories
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: (repositories?.find(
|
: (repositories?.find(
|
||||||
(repo) =>
|
(repo) => repo.name === field.value.repo,
|
||||||
repo.name === field.value.repo &&
|
)?.name ?? "Select repository")}
|
||||||
repo.owner.login === field.value.owner,
|
|
||||||
)?.name ?? field.value.repo)}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
@@ -277,7 +266,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{repositories?.map((repo) => (
|
{repositories?.map((repo) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={`${repo.owner.login}/${repo.name}`}
|
value={repo.name}
|
||||||
key={repo.url}
|
key={repo.url}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
@@ -287,8 +276,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate">{repo.name}</span>
|
<span>{repo.name}</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{repo.owner.login}
|
{repo.owner.login}
|
||||||
</span>
|
</span>
|
||||||
@@ -296,8 +285,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
repo.name === field.value.repo &&
|
repo.name === field.value.repo
|
||||||
repo.owner.login === field.value.owner
|
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
@@ -329,25 +317,22 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "pending" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? (branches?.find(
|
? branches?.find(
|
||||||
(branch) => branch.name === field.value,
|
(branch) => branch.name === field.value,
|
||||||
)?.name ?? field.value)
|
)?.name
|
||||||
: "Select branch"}
|
: "Select branch"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
@@ -375,7 +360,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
form.setValue("branch", branch.name);
|
form.setValue("branch", branch.name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{branch.name}</span>
|
{branch.name}
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
@@ -460,7 +445,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
?
|
?
|
||||||
</div>
|
</div>
|
||||||
@@ -545,7 +530,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
|
|||||||
gitlabPathNamespace: z.string().min(1),
|
gitlabPathNamespace: z.string().min(1),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -199,9 +195,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Gitlab Account</FormLabel>
|
<FormLabel>Gitlab Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -211,6 +204,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
|
defaultValue={field.value}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -258,7 +252,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -274,10 +268,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
@@ -315,10 +306,8 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate">
|
<span>{repo.name}</span>
|
||||||
{repo.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{repo.owner.username}
|
{repo.owner.username}
|
||||||
</span>
|
</span>
|
||||||
@@ -360,7 +349,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -375,10 +364,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0" align="start">
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
@@ -406,7 +392,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
form.setValue("branch", branch.name);
|
form.setValue("branch", branch.name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{branch.name}</span>
|
{branch.name}
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
@@ -450,7 +436,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
?
|
?
|
||||||
</div>
|
</div>
|
||||||
@@ -530,7 +516,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -143,10 +143,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full overflow-auto">
|
<div className="flex flex-row items-center justify-between w-full overflow-auto">
|
||||||
<TabsList
|
<TabsList className="flex gap-4 justify-start bg-transparent">
|
||||||
variant="line"
|
|
||||||
className="flex gap-4 justify-start bg-transparent"
|
|
||||||
>
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="github"
|
value="github"
|
||||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const RandomizeCompose = ({ composeId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="randomize"
|
name="randomize"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
|
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Apply Randomize</FormLabel>
|
<FormLabel>Apply Randomize</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user