Compare commits

..

3 Commits

Author SHA1 Message Date
Mauricio Siu a7adc3c36b refactor: update Vite application structure and dependencies
- Removed the `index.html` file as part of the transition to a new routing system.
- Updated `package.json` scripts to streamline development commands using `concurrently`.
- Enhanced dependency management by adding new packages and updating existing ones in `package.json`.
- Introduced a new server function for branding and session management, improving API interactions.
- Refactored routing to utilize TanStack Start, ensuring better integration with the existing Dokploy architecture.
- Improved static file handling and server response management in the custom Node.js server.
- Updated README to reflect changes in architecture and development processes.
2026-08-11 01:15:43 -06:00
Mauricio Siu f838c07348 Merge branch 'canary' into feat/vite-tanstack-router 2026-08-10 23:38:20 -06:00
Mauricio Siu 2b225a1e4f feat: introduce Vite-based Dokploy application
- Added a new Vite application (`apps/vite`) to serve as a standalone SPA for the Dokploy dashboard, leveraging TanStack Router for routing.
- Implemented server-side handling with a custom Node.js server to manage API requests and serve static files.
- Integrated existing components and utilities from the Dokploy monorepo, ensuring no duplication of code.
- Updated `.dockerignore` and `.gitignore` to include new build artifacts and environment files.
- Enhanced `pnpm-lock.yaml` with new dependencies for the Vite application.
- Created configuration files for Vite, TypeScript, and ESBuild to support the new application structure.
2026-08-10 23:38:04 -06:00
268 changed files with 13239 additions and 49925 deletions
-22
View File
@@ -1,22 +0,0 @@
{
"worktree": {
"baseRef": "fresh"
},
"hooks": {
"PostToolUse": [
{
"matcher": "EnterWorktree",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
}
]
}
]
}
}
-55
View File
@@ -1,55 +0,0 @@
---
name: fix-issue
description: Implement a GitHub issue with reproduction and verification
allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
---
The issue number is passed as $1.
## Instance
No instance is running yet — start your own, isolated to this worktree:
1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
until it answers (usually ~10-15s).
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
discovered mid-session. If those tools are unavailable or point at the wrong
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
to relaunch with `DOKPLOY_BASE_URL` exported first.
## Tools
- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up
state (create a project, an app, an env var) and to verify backend behavior.
Search for the tool you need; they are not all loaded upfront.
- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything
a user would see or click.
Pick by where the bug lives, not by convenience:
- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright.
The API returning correct data proves nothing here.
- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP.
A green screenshot proves nothing here.
- Unclear → do both.
Use the MCP to reach the state you need quickly, then verify in the UI. Do not
click through ten screens to create a project the API can create in one call.
## Steps
1. Run `gh issue view $1` and read the full issue, including comments.
2. Reproduce the bug with the appropriate tool. If you cannot reproduce it,
comment on the issue explaining what you tried and STOP.
Do not implement anything.
3. Implement the fix. Keep the change minimal and scoped to the issue.
4. Run `pnpm test`, then re-run the same reproduction from step 2.
5. Only if both pass: commit and run `gh pr create`. The PR description must
include the before/after reproduction steps and reference the issue.
Never skip step 2. A fix you cannot reproduce and then verify is not a fix.
+5 -1
View File
@@ -2,4 +2,8 @@ node_modules
.git
.gitignore
*.md
dist
dist
**/.next
**/dist
**/dist-server
**/.env
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
@@ -19,4 +19,4 @@ jobs:
- name: Run Biome formatter
run: biome format --write
- uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2
-13
View File
@@ -34,16 +34,3 @@ jobs:
fi
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
git push origin main
- name: Sync cherry-pick back into canary
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git fetch origin canary main
git checkout -B canary origin/canary
if ! git merge origin/main -m "chore: sync hotfix from main into canary [skip ci]"; then
git merge --abort
echo "::error::Could not auto-sync the hotfix into canary (real conflict, not just history divergence). Merge main into canary manually to avoid a conflict on the next release PR."
exit 1
fi
git push origin canary
+3 -3
View File
@@ -14,9 +14,9 @@ jobs:
matrix:
job: [build, test, typecheck]
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: "pnpm"
@@ -1,437 +0,0 @@
# Upgrade Integration Test
#
# Tests that Dokploy can upgrade from version A to version B while keeping
# user projects (Postgres, MongoDB, two web apps, one static site) alive.
#
# Generates upgrade pairs: each stable tag in [floor_version, target_version)
# is paired with target_version. If target_version is empty, the highest tag
# available on Docker Hub is used as the target.
#
# Only triggered manually to avoid burning Actions minutes.
name: Upgrade Integration Test
on:
workflow_dispatch:
inputs:
floor_version:
description: 'Oldest version tag to include in pairs (e.g. v0.29.4)'
required: false
default: 'v0.29.4'
target_version:
description: 'Target version to upgrade to (version B). Leave empty to use the highest available.'
required: false
default: ''
env:
DOKPLOY_IMAGE: dokploy/dokploy
DOKPLOY_SERVICE: dokploy
DOKPLOY_PORT: 3000
# ──────────────────────────────────────────────────────────────────────────────
jobs:
# ── 1. Build the matrix ─────────────────────────────────────────────────────
build-matrix:
name: Build upgrade pair matrix
runs-on: ubuntu-latest
outputs:
pairs: ${{ steps.pairs.outputs.pairs }}
steps:
- name: Generate pairs
id: pairs
env:
FLOOR: ${{ inputs.floor_version }}
TARGET: ${{ inputs.target_version }}
run: |
set -euo pipefail
# semver "a >= b" comparison helper (vX.Y.Z, strips leading v)
ge() {
[ "$(printf '%s\n%s\n' "${1#v}" "${2#v}" | sort -V | tail -n1)" = "${1#v}" ]
}
# Fetch all semver tags from Docker Hub (dokploy/dokploy)
ALL_TAGS=$(curl -fsSL \
"https://hub.docker.com/v2/repositories/dokploy/dokploy/tags?page_size=100" | \
jq -r '.results[].name' | \
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
sort -V)
echo "All tags found:"
echo "$ALL_TAGS"
# Target version (version B): explicit input, else highest available.
if [ -n "$TARGET" ]; then
TO="$TARGET"
echo "Using user-supplied target version: $TO"
else
TO=$(echo "$ALL_TAGS" | tail -n1)
echo "No target supplied; using highest available: $TO"
fi
# Floor semver components
FLOOR_CLEAN="${FLOOR#v}"
IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN"
# Each tag in [FLOOR, TO) → TO
PAIRS='[]'
while read -r TAG; do
[ -z "$TAG" ] && continue
# skip tags above-or-equal to the target (incl. the target itself)
ge "$TAG" "$TO" && continue
TAG_CLEAN="${TAG#v}"
IFS='.' read -r T_MAJ T_MIN T_PAT <<< "$TAG_CLEAN"
if [ "$T_MAJ" -gt "$F_MAJ" ] || \
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -gt "$F_MIN" ]; } || \
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -eq "$F_MIN" ] && [ "$T_PAT" -ge "$F_PAT" ]; }; then
PAIRS=$(echo "$PAIRS" | jq -c \
--arg f "$TAG" --arg t "$TO" \
'. + [{"from":$f,"to":$t}]')
fi
done <<< "$ALL_TAGS"
COUNT=$(echo "$PAIRS" | jq 'length')
echo "Total pairs: $COUNT"
echo "$PAIRS" | jq -r '.[] | " \(.from) → \(.to)"'
echo "pairs=$PAIRS" >> "$GITHUB_OUTPUT"
# ── 2. Run one upgrade test per pair ────────────────────────────────────────
upgrade-test:
name: "${{ matrix.pair.from }} → ${{ matrix.pair.to }}"
needs: build-matrix
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
pair: ${{ fromJSON(needs.build-matrix.outputs.pairs) }}
steps:
# ── Environment setup ──────────────────────────────────────────────────
- name: Free disk space
run: |
sudo rm -rf \
/usr/share/dotnet /opt/ghc /usr/local/share/boost \
"$AGENT_TOOLSDIRECTORY" /usr/local/lib/android \
/usr/local/share/chromium /opt/hostedtoolcache
docker system prune -af --volumes
df -h
# ── Install Dokploy directly at VERSION A ──────────────────────────────
# install.sh:
# • requires root (run via sudo bash)
# • respects DOKPLOY_VERSION → installs that tag directly
# (so we don't need a separate "downgrade" step that would risk
# running B's migrations on A's expected schema)
# • respects ADVERTISE_ADDR → skip the external IP lookup
# • initializes Docker Swarm + dokploy-network itself
- name: Install Dokploy at VERSION A (${{ matrix.pair.from }})
run: |
curl -fsSL https://dokploy.com/install.sh -o /tmp/install.sh
chmod +x /tmp/install.sh
sudo -E env \
DOKPLOY_VERSION="${{ matrix.pair.from }}" \
ADVERTISE_ADDR="127.0.0.1" \
bash /tmp/install.sh
- name: Wait for dokploy service to converge on ${{ matrix.pair.from }}
run: |
echo "Waiting for 'dokploy' Swarm service to reach 1/1..."
timeout 240 bash -c '
until docker service ls --filter name=dokploy \
--format "{{.Name}} {{.Replicas}}" \
| grep "^dokploy " | grep -q " 1/1"; do
sleep 4
done
'
docker service ls
echo "✅ Service running on ${{ matrix.pair.from }}"
- name: Wait for Dokploy API to accept requests
run: |
timeout 180 bash -c '
until curl -sf -o /dev/null \
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
sleep 3
done
'
echo "✅ Dokploy API is up"
# ── Bootstrap admin user ───────────────────────────────────────────────
- name: Register first admin user
id: auth
run: |
COOKIE_JAR="$RUNNER_TEMP/dokploy-cookies.txt"
: > "$COOKIE_JAR"
chmod 600 "$COOKIE_JAR"
echo "cookie_jar=$COOKIE_JAR" >> "$GITHUB_OUTPUT"
# better-auth: sign-up/email (allowed only before any owner exists)
set -x
curl -sS -i -X POST \
"http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/sign-up/email" \
-H "Content-Type: application/json" \
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
-d '{"name":"CI Admin","email":"ci@dokploy.test","password":"CiTest1234!"}' \
| tee /tmp/signup.out
set +x
# Verify we got a session cookie
if ! grep -qE '(better-auth|auth)\.session' "$COOKIE_JAR"; then
echo "⚠️ No session cookie matched expected name; jar contents:"
cat "$COOKIE_JAR"
fi
# ── Create test resources ──────────────────────────────────────────────
- name: Create project, databases and applications
id: create
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
run: |
set -euo pipefail
# --- tRPC POST helper ---
trpc_mut() {
curl -sf -X POST "$BASE/api/trpc/$1" \
-H "Content-Type: application/json" \
-b "$COOKIE" -c "$COOKIE" \
-d "{\"json\":$2}"
}
# --- Project ---
PROJECT=$(trpc_mut project.create \
'{"name":"ci-upgrade-test","description":"CI upgrade integration test"}')
echo "project.create → $PROJECT"
# project.create in v0.29+ returns nested {project:{projectId}, environment:{environmentId}}
PROJECT_ID=$(echo "$PROJECT" | jq -r \
'.result.data.json.project.projectId // .result.data.json.projectId // empty')
ENV_ID=$(echo "$PROJECT" | jq -r \
'.result.data.json.environment.environmentId // empty')
if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then
echo "❌ Could not extract projectId from project.create response:"
echo "$PROJECT" | jq .
exit 1
fi
if [ -z "$ENV_ID" ] || [ "$ENV_ID" = "null" ]; then
echo "❌ Could not extract environmentId — this version may not support environments."
echo " project.create response: $PROJECT"
exit 1
fi
echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT"
echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT"
echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=$ENV_ID"
# --- PostgreSQL 15 ---
PG=$(trpc_mut postgres.create \
"{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
\"databasePassword\":\"CiPg1pass\",\
\"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}")
echo "postgres.create → $PG"
PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId')
# deploy (not start) — deploy creates the Swarm service; start only
# scales an already-deployed service and 500s on a fresh resource.
trpc_mut postgres.deploy "{\"postgresId\":\"$PG_ID\"}" > /dev/null
echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT"
# --- MongoDB 7.0 ---
MG=$(trpc_mut mongo.create \
"{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
\"databasePassword\":\"CiMg1pass\",\
\"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}")
echo "mongo.create → $MG"
MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId')
trpc_mut mongo.deploy "{\"mongoId\":\"$MG_ID\"}" > /dev/null
echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT"
# --- Docker-image application helper ---
make_app() {
local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3
APP=$(trpc_mut application.create \
"{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",\"environmentId\":\"$ENV_ID\"}")
APP_ID=$(echo "$APP" | jq -r '.result.data.json.applicationId')
trpc_mut application.saveDockerProvider \
"{\"applicationId\":\"$APP_ID\",\"dockerImage\":\"$IMAGE\",\
\"username\":\"\",\"password\":\"\",\"registryUrl\":\"\"}" \
> /dev/null
trpc_mut application.deploy "{\"applicationId\":\"$APP_ID\"}" \
> /dev/null
echo "$APP_ID"
}
# Static site (nginx)
APP_STATIC=$(make_app "ci-static" "ci-static-app" "nginx:alpine")
echo "app_static_id=$APP_STATIC" >> "$GITHUB_OUTPUT"
# Node.js hello-world (echo server image — small, no args needed)
APP_NODE=$(make_app "ci-node" "ci-node-app" "ealen/echo-server:latest")
echo "app_node_id=$APP_NODE" >> "$GITHUB_OUTPUT"
# Go HTTP server (traefik/whoami is a tiny Go binary, port 80)
APP_GO=$(make_app "ci-go" "ci-go-app" "traefik/whoami:latest")
echo "app_go_id=$APP_GO" >> "$GITHUB_OUTPUT"
echo "✅ All resources created"
# ── Pre-upgrade health check ───────────────────────────────────────────
- name: Wait for all services → 'done' (pre-upgrade)
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
PG_ID: ${{ steps.create.outputs.pg_id }}
MG_ID: ${{ steps.create.outputs.mg_id }}
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
run: |
wait_done() {
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
echo "Waiting for $NAME to reach 'done'..."
timeout 360 bash -c "
until [ \"\$(curl -sf -G '$BASE/api/trpc/$ENDPOINT' \
--data-urlencode 'input={\"json\":{\"$ID_KEY\":\"$ID\"}}' \
-b '$COOKIE' | \
jq -r '.result.data.json.$STATUS_KEY // \"unknown\"')\" \
= 'done' ]; do
sleep 5
done
"
echo "✅ $NAME is done"
}
wait_done postgres postgres.one postgresId "$PG_ID" applicationStatus
wait_done mongo mongo.one mongoId "$MG_ID" applicationStatus
wait_done static application.one applicationId "$APP_STATIC_ID" applicationStatus
wait_done node-app application.one applicationId "$APP_NODE_ID" applicationStatus
wait_done go-app application.one applicationId "$APP_GO_ID" applicationStatus
- name: Assert Docker Swarm services healthy (pre-upgrade)
run: |
echo "=== docker service ls ==="
docker service ls
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
grep -E '^ci-' | grep -v ' 1/1' || true)
if [ -n "$FAIL" ]; then
echo "❌ User services not healthy before upgrade:"
echo "$FAIL"
exit 1
fi
echo "✅ All user services healthy before upgrade"
# ── Upgrade ────────────────────────────────────────────────────────────
- name: Upgrade Dokploy to VERSION B (${{ matrix.pair.to }})
run: |
docker service update \
--image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.to }}" \
--force \
"${{ env.DOKPLOY_SERVICE }}"
echo "Waiting for service to converge on ${{ matrix.pair.to }}..."
timeout 240 bash -c '
until ! docker service inspect dokploy \
--format "{{.UpdateStatus.State}}" 2>/dev/null \
| grep -q "^updating$"; do
sleep 4
done
until docker service ls --filter name=dokploy \
--format "{{.Name}} {{.Replicas}}" \
| grep "^dokploy " | grep -q " 1/1"; do
sleep 4
done
'
echo "✅ Service running on ${{ matrix.pair.to }}"
- name: Wait for Dokploy API to respond post-upgrade
run: |
timeout 180 bash -c '
until curl -sf -o /dev/null \
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
sleep 3
done
'
echo "✅ Dokploy API is up after upgrade"
# ── Post-upgrade health check ──────────────────────────────────────────
- name: Verify all services still healthy (post-upgrade)
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
PG_ID: ${{ steps.create.outputs.pg_id }}
MG_ID: ${{ steps.create.outputs.mg_id }}
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
run: |
check_status() {
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
STATUS=$(curl -sf -G "$BASE/api/trpc/$ENDPOINT" \
--data-urlencode "input={\"json\":{\"$ID_KEY\":\"$ID\"}}" \
-b "$COOKIE" | \
jq -r ".result.data.json.$STATUS_KEY // \"unknown\"")
if [ "$STATUS" != "done" ]; then
echo "❌ $NAME status after upgrade: $STATUS"
return 1
fi
echo "✅ $NAME: $STATUS"
}
check_status postgres postgres.one postgresId "$PG_ID" applicationStatus
check_status mongo mongo.one mongoId "$MG_ID" applicationStatus
check_status static application.one applicationId "$APP_STATIC_ID" applicationStatus
check_status node-app application.one applicationId "$APP_NODE_ID" applicationStatus
check_status go-app application.one applicationId "$APP_GO_ID" applicationStatus
echo "=== docker service ls (post-upgrade) ==="
docker service ls
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
grep -E '^ci-' | grep -v ' 1/1' || true)
if [ -n "$FAIL" ]; then
echo "❌ User services not healthy after upgrade:"
echo "$FAIL"
exit 1
fi
echo "✅ All services healthy after upgrade to ${{ matrix.pair.to }}"
# ── Diagnostics on failure ─────────────────────────────────────────────
- name: Dump state on failure
if: failure()
run: |
echo "=== docker service ls ===" && docker service ls || true
echo "=== dokploy service tasks ===" && \
docker service ps "${{ env.DOKPLOY_SERVICE }}" --no-trunc || true
echo "=== dokploy logs (last 200) ===" && \
docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 200 2>&1 || true
echo "=== install.sh tail ===" && \
tail -100 /tmp/install.sh 2>&1 || true
echo "=== signup response ===" && \
cat /tmp/signup.out 2>&1 || true
echo "=== disk usage ===" && df -h
# ── Job summary ────────────────────────────────────────────────────────
- name: Write job summary
if: always()
run: |
STATUS="${{ job.status }}"
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
| Service | Image |
|---------|-------|
| ci-pg-db | postgres:15 |
| ci-mongo-db | mongo:7.0 |
| ci-static-app | nginx:alpine (static site) |
| ci-node-app | ealen/echo-server (Node.js hello-world) |
| ci-go-app | traefik/whoami (Go HTTP server) |
EOF
+5 -1
View File
@@ -46,4 +46,8 @@ yarn-error.log*
.db
.playwright-*
.credentials
.credentials
dist/
dist-server/
.tanstack
.env.*
-13
View File
@@ -1,13 +0,0 @@
{
"mcpServers": {
"dokploy": {
"type": "http",
"url": "${DOKPLOY_BASE_URL}/api/mcp",
"headers": { "Authorization": "Bearer ${DOKPLOY_TOKEN}" }
},
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"]
}
}
}
-2
View File
@@ -1,2 +0,0 @@
.env
.env.local
+1 -4
View File
@@ -31,7 +31,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y tini curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@@ -69,8 +69,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
# tini reaps HEALTHCHECK child processes that Node (as PID 1) leaves defunct.
ENTRYPOINT ["/usr/bin/tini", "--"]
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
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"]
+54
View File
@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1
FROM node:24.4.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.22.0 --activate
FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --no-frozen-lockfile
ENV NODE_ENV=production
RUN pnpm --filter=dokploy-vite build
RUN pnpm --filter=dokploy-vite build:server
RUN pnpm --filter=dokploy-vite --prod deploy --legacy /prod/vite
# Drop auto-installed optional peers that are never used at runtime
# (better-auth peers next/prisma adapters; drizzle peers pglite/sqlite; prisma pulls effect/sharp)
RUN cd /prod/vite/node_modules && rm -rf \
.pnpm/next@* .pnpm/@next+swc* \
.pnpm/prisma@* .pnpm/@prisma+* \
.pnpm/effect@* .pnpm/@electric-sql+* \
.pnpm/better-sqlite3@* \
.pnpm/sharp@* .pnpm/@img+* \
.pnpm/typescript@* \
next prisma typescript better-sqlite3 sharp
FROM base AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# For a full production replacement of the official image, add the same
# tooling block as ./Dockerfile (docker cli, nixpacks, railpack, pack, rclone)
# — required for builds/deployments, not for serving the panel.
COPY --from=build /prod/vite/node_modules ./node_modules
COPY --from=build /prod/vite/package.json ./package.json
COPY --from=build /usr/src/app/apps/vite/dist ./dist
COPY --from=build /usr/src/app/apps/vite/dist-server ./dist-server
COPY --from=build /usr/src/app/apps/dokploy/drizzle ./drizzle
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \
CMD curl -fs http://localhost:3000/api/health || exit 1
CMD ["sh", "-c", "node dist-server/wait-for-postgres.mjs && node dist-server/migration.mjs && exec node dist-server/server.mjs"]
+1 -1
View File
@@ -28,7 +28,7 @@
"@types/react-dom": "^19.2.0",
"rimraf": "6.1.3",
"tsx": "^4.22.4",
"typescript": "^7.0.2"
"typescript": "^5.8.3"
},
"packageManager": "pnpm@10.22.0",
"engines": {
+3 -2
View File
@@ -2,12 +2,13 @@
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"moduleResolution": "Node",
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "./src",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@dokploy/server/*": ["../../packages/server/src/*"]
@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
const revokeSessionSchema = z.object({
sessionId: z.string(),
});
describe("revokeSession input validation", () => {
it("accepts a valid session id", () => {
const result = revokeSessionSchema.safeParse({ sessionId: "abc123" });
expect(result.success).toBe(true);
});
it("rejects missing sessionId", () => {
const result = revokeSessionSchema.safeParse({});
expect(result.success).toBe(false);
});
it("rejects non-string sessionId", () => {
const result = revokeSessionSchema.safeParse({ sessionId: 123 });
expect(result.success).toBe(false);
});
});
@@ -1,82 +0,0 @@
import { spawnSync } from "node:child_process";
import { createRestartSafeBackupCommand } from "@dokploy/server/utils/volume-backups/backup";
import { describe, expect, it } from "vitest";
const runCommand = (command: string) =>
spawnSync("bash", ["-c", command], {
encoding: "utf8",
});
const outputLines = (stdout: string) =>
stdout
.trim()
.split("\n")
.filter((line) => line.length > 0);
describe("createRestartSafeBackupCommand", () => {
it("restarts the service and preserves the backup error status", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"; exit 23',
startCommand: 'echo "start"',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(23);
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
});
it("preserves the backup error status when the restart also fails", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"; exit 23',
startCommand: 'echo "start"; exit 17',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(23);
expect(outputLines(result.stdout)).toEqual([
"stop",
"backup",
"start",
"Service restart also failed with exit code 17",
]);
});
it("returns the restart error status when the backup succeeds", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"',
startCommand: 'echo "start"; exit 17',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(17);
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
});
it("uploads only after a successful backup and service restart", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"',
startCommand: 'echo "start"',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(0);
expect(outputLines(result.stdout)).toEqual([
"stop",
"backup",
"start",
"upload",
]);
});
});
@@ -33,7 +33,6 @@ const makeDomain = (serviceName: string) =>
https: false,
uniqueConfigKey: 1,
port: 3000,
enabled: true,
}) as any;
// If the returned shell fragment is safe, parse() yields only string tokens.
@@ -1,306 +0,0 @@
import type { Compose } from "@dokploy/server/services/compose";
import type { Domain } from "@dokploy/server/services/domain";
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
import { beforeEach, describe, expect, it, vi } from "vitest";
// With sourceType "raw", addDomainToCompose parses compose.composeFile
// directly instead of reading from disk, so baseCompose exposes it as a
// getter that always reflects the current composeYaml for each test case.
const baseComposeYaml = `
services:
frigate:
image: frigate
`;
let composeYaml = baseComposeYaml;
const baseCompose = {
appName: "test-app",
composeType: "docker-compose",
composePath: "docker-compose.yml",
sourceType: "raw",
serverId: null,
isolatedDeployment: false,
randomize: false,
suffix: "",
get composeFile() {
return composeYaml;
},
} as unknown as Compose;
const baseDomain: Domain = {
host: "frigate.example.com",
port: 8971,
customEntrypoint: null,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "compose-id",
domainType: "compose",
serviceName: "frigate",
domainId: "domain-id",
path: "/",
createdAt: "",
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
const serviceLabels = (
result: Awaited<ReturnType<typeof addDomainToCompose>>,
) => (result?.services?.frigate?.labels as string[] | undefined) ?? [];
describe("addDomainToCompose enabled filtering", () => {
beforeEach(() => {
vi.clearAllMocks();
composeYaml = baseComposeYaml;
});
it("generates traefik labels for an enabled domain", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, enabled: true },
]);
const labels = serviceLabels(result);
expect(labels).toContain("traefik.enable=true");
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
true,
);
});
it("skips a disabled domain entirely (no traefik labels)", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, enabled: false },
]);
const labels = serviceLabels(result);
expect(labels).not.toContain("traefik.enable=true");
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
false,
);
});
it.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
- traefik.enable=true
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
- custom.label=preserved
`,
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
- traefik.enable=true
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
- custom.label=preserved
`,
],
] as const)(
"removes stale labels for a disabled domain from %s rebuilds",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: false },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toContain("custom.label=preserved");
expect(
(labels as string[]).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
legacy:
image: frigate
labels:
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- custom.label=preserved
frigate:
image: frigate
`,
],
[
"stack",
`services:
legacy:
image: frigate
deploy:
labels:
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
- custom.label=preserved
frigate:
image: frigate
`,
],
] as const)(
"removes stale labels from the previous %s service after reassignment",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, serviceName: "frigate", enabled: false },
]);
const previousService = result?.services?.legacy;
const labels =
composeType === "docker-compose"
? previousService?.labels
: previousService?.deploy?.labels;
expect(labels).toContain("custom.label=preserved");
expect(
(labels as string[]).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
custom.label: preserved
`,
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
custom.label: preserved
`,
],
] as const)(
"removes stale mapping labels for a disabled domain from %s rebuilds",
async (composeType, staleComposeYaml) => {
composeYaml = staleComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: false },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toMatchObject({ "custom.label": "preserved" });
expect(
Object.keys(labels ?? {}).some((label) => label.includes("test-app-1")),
).toBe(false);
},
);
it.each([
[
"docker-compose",
`services:
frigate:
image: frigate
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
custom.label: preserved
`,
"traefik.docker.network",
],
[
"stack",
`services:
frigate:
image: frigate
deploy:
labels:
traefik.enable: "true"
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
custom.label: preserved
`,
"traefik.swarm.network",
],
] as const)(
"regenerates routing in mapping labels for an enabled domain in %s",
async (composeType, mappingComposeYaml, networkLabel) => {
composeYaml = mappingComposeYaml;
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
{ ...baseDomain, enabled: true },
]);
const service = result?.services?.frigate;
const labels =
composeType === "docker-compose"
? service?.labels
: service?.deploy?.labels;
expect(labels).toMatchObject({
"custom.label": "preserved",
"traefik.enable": "true",
[networkLabel]: "dokploy-network",
"traefik.http.routers.test-app-1-web.rule":
"Host(`frigate.example.com`)",
"traefik.http.services.test-app-1-web.loadbalancer.server.port": "8971",
});
},
);
it("emits labels only for the enabled domain when both are present", async () => {
const result = await addDomainToCompose(baseCompose, [
{ ...baseDomain, host: "enabled.example.com", enabled: true },
{
...baseDomain,
host: "disabled.example.com",
uniqueConfigKey: 2,
enabled: false,
},
]);
const labels = serviceLabels(result);
expect(labels.some((l) => l.includes("Host(`enabled.example.com`)"))).toBe(
true,
);
expect(labels.some((l) => l.includes("Host(`disabled.example.com`)"))).toBe(
false,
);
});
});
@@ -35,7 +35,6 @@ describe("Host rule format regression tests", () => {
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("Host rule format validation", () => {
@@ -24,7 +24,6 @@ describe("createDomainLabels", () => {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
it("should create basic labels for web entrypoint", async () => {
@@ -1,59 +0,0 @@
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>()),
execAsyncRemote: vi.fn(),
}));
describe("raw remote compose conversion (#4794)", () => {
it("uses the saved raw source and preserves supported mount syntax", async () => {
vi.mocked(execAsyncRemote).mockResolvedValue({
stdout: "services:\n test:\n image: alpine:latest\n",
stderr: "",
});
const compose = {
appName: "raw-stack",
composeFile: `
services:
test:
image: alpine:latest
volumes:
- type: tmpfs
target: /scratch
- type: volume
source: test-data
target: /data
tmpfs:
- /cache
volumes:
test-data:
`,
composePath: "./docker-compose.yml",
composeType: "stack",
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
serverId: "remote-server",
sourceType: "raw",
suffix: "",
} as unknown as Parameters<typeof addDomainToCompose>[0];
const converted = await addDomainToCompose(compose, []);
expect(converted?.services?.test?.volumes).toEqual([
{ type: "tmpfs", target: "/scratch" },
{
type: "volume",
source: "test-data",
target: "/data",
},
]);
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
expect(execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -93,46 +93,4 @@ describe("getCreateEnvFileCommand", () => {
expect(actual[key], key).toBe(value);
}
}, 60000);
it("still interpolates a ${VAR} self-reference in the .env file", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = [
"APP_URL=https://example.com",
'ASSET_URL="${APP_URL}"',
].join("\n");
const command = getCreateEnvFileCommand({
appName,
composePath: "docker-compose.yml",
env: serviceEnv,
randomize: false,
suffix: "",
serverId: null,
environment: { project: { env: "" }, env: "" },
} as Parameters<typeof getCreateEnvFileCommand>[0]);
execFileSync("bash", ["-c", command]);
const composeFile =
"services:\n test:\n image: busybox\n environment:\n - ASSET_URL=${ASSET_URL}\n";
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
const out = execFileSync(
"docker",
[
"compose",
"run",
"--rm",
"-T",
"test",
"sh",
"-c",
"printf '%s' \"$ASSET_URL\"",
],
{ cwd: codePath, encoding: "utf8" },
);
expect(out).toBe("https://example.com");
}, 60000);
});
@@ -12,8 +12,6 @@ const mocks = vi.hoisted(() => ({
queueAdd: vi.fn(),
verify: vi.fn(),
shouldDeploy: vi.fn(),
createPreviewDeployment: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
}));
vi.mock("drizzle-orm", () => ({
@@ -66,11 +64,10 @@ vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
shouldDeploy: mocks.shouldDeploy,
checkUserRepositoryPermissions: vi.fn(),
createPreviewDeployment: mocks.createPreviewDeployment,
createPreviewDeployment: vi.fn(),
createSecurityBlockedComment: vi.fn(),
findGithubById: vi.fn(),
findPreviewDeploymentByApplicationId:
mocks.findPreviewDeploymentByApplicationId,
findPreviewDeploymentByApplicationId: vi.fn(),
findPreviewDeploymentsByPullRequestId: vi.fn(),
getBitbucketHeaders: vi.fn(() => ({})),
removePreviewDeployment: vi.fn(),
@@ -324,157 +321,3 @@ describe("GitHub app webhook auto-deploy", () => {
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
});
});
describe("GitHub app webhook preview deployments", () => {
const createApplication = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
applicationId: "application-id",
name: "my-app",
serverId: null,
previewLabels: [],
previewLimit: 3,
previewDeployments: [],
previewRequireCollaboratorPermissions: false,
...overrides,
});
const createPreviewDeployments = (total: number) =>
Array.from({ length: total }, (_, index) => ({
previewDeploymentId: `existing-preview-${index}`,
}));
const createPullRequestRequest = (action: string) =>
({
headers: {
"x-hub-signature-256": "sha256=test-signature",
"x-github-event": "pull_request",
},
body: {
installation: {
id: 12345,
},
action,
pull_request: {
id: 987,
number: 42,
title: "feat: add preview",
html_url: "https://github.com/agentHits/dokploy/pull/42",
labels: [],
user: {
login: "agentHits",
},
head: {
ref: "feature",
sha: "abc123",
},
base: {
ref: "main",
},
},
repository: {
name: "dokploy",
owner: {
login: "agentHits",
},
},
},
}) as unknown as NextApiRequest;
beforeEach(() => {
vi.clearAllMocks();
mocks.githubFindFirst.mockResolvedValue({
githubId: "github-provider-id",
githubInstallationId: 12345,
githubWebhookSecret: "webhook-secret",
});
mocks.verify.mockResolvedValue(true);
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
mocks.createPreviewDeployment.mockResolvedValue({
previewDeploymentId: "new-preview-id",
});
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined);
});
it("redeploys an existing preview even when the limit is reached", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: 2,
previewDeployments: createPreviewDeployments(3),
}),
]);
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({
previewDeploymentId: "existing-preview-0",
});
const res = createResponse();
await handler(createPullRequestRequest("synchronize"), res);
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application-preview",
previewDeploymentId: "existing-preview-0",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it("does not create a new preview once the limit is reached", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: 2,
previewDeployments: createPreviewDeployments(2),
}),
]);
const res = createResponse();
await handler(createPullRequestRequest("opened"), res);
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
expect(mocks.queueAdd).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
});
it("falls back to the default limit when none is configured", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: null,
previewDeployments: createPreviewDeployments(2),
}),
]);
const res = createResponse();
await handler(createPullRequestRequest("opened"), res);
expect(mocks.createPreviewDeployment).toHaveBeenCalledWith(
expect.objectContaining({
applicationId: "application-id",
branch: "feature",
pullRequestId: 987,
pullRequestNumber: 42,
}),
);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application-preview",
previewDeploymentId: "new-preview-id",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
});
@@ -1,214 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { cloudflareClient } from "@dokploy/server/utils/dns/cloudflare";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const cfSuccess = (result: unknown) =>
jsonResponse({ success: true, errors: [], result });
const cfError = (message: string, status = 400) =>
jsonResponse(
{ success: false, errors: [{ code: 1, message }] },
false,
status,
);
const config = { providerType: "cloudflare" as const, apiToken: "cf-token" };
beforeEach(() => {
mockFetch.mockReset();
});
describe("cloudflareClient.listZones", () => {
it("returns a single page of zones", async () => {
mockFetch.mockResolvedValue(cfSuccess([{ id: "z1", name: "example.com" }]));
const zones = await cloudflareClient.listZones(config);
expect(zones).toEqual([{ id: "z1", name: "example.com" }]);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain("/zones?per_page=50&page=1");
});
it("paginates until a short page is returned", async () => {
const page = (count: number) =>
cfSuccess(
Array.from({ length: count }, (_, i) => ({
id: `z${i}`,
name: `zone${i}.com`,
})),
);
mockFetch.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(3));
const zones = await cloudflareClient.listZones(config);
expect(zones).toHaveLength(53);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[1]?.[0]).toContain("page=2");
});
});
describe("cloudflareClient.listRecords", () => {
it("lists records for a zone", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "r1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records).toEqual([
{
id: "r1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
},
]);
expect(mockFetch.mock.calls[0]?.[0]).toContain("/zones/zone-1/dns_records");
});
});
describe("cloudflareClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
const result = await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "new-1" });
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(createInit.method).toBe("POST");
});
it("updates the existing record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
const result = await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "existing-1" });
const [updateUrl, updateInit] = mockFetch.mock.calls[1] as [
string,
RequestInit,
];
expect(updateUrl).toContain("/dns_records/existing-1");
expect(updateInit.method).toBe("PUT");
});
it("defaults ttl to 1 (automatic) when not provided", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CNAME",
name: "www.example.com",
content: "example.com",
});
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(createInit.body as string);
expect(body.ttl).toBe(1);
});
});
describe("cloudflareClient.updateRecord", () => {
it("PUTs directly to the given record id", async () => {
mockFetch.mockResolvedValue(cfSuccess({ id: "r1" }));
const result = await cloudflareClient.updateRecord(config, "zone-1", "r1", {
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
});
expect(result).toEqual({ id: "r1" });
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/zones/zone-1/dns_records/r1");
expect(init.method).toBe("PUT");
expect(JSON.parse(init.body as string)).toEqual({
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
});
});
});
describe("cloudflareClient.deleteRecord", () => {
it("DELETEs the given record id", async () => {
mockFetch.mockResolvedValue(cfSuccess({}));
await cloudflareClient.deleteRecord(config, "zone-1", "r1");
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/zones/zone-1/dns_records/r1");
expect(init.method).toBe("DELETE");
});
});
describe("cloudflareClient.testConnection", () => {
it("succeeds when the token can list zones", async () => {
mockFetch.mockResolvedValue(cfSuccess([]));
await expect(
cloudflareClient.testConnection(config),
).resolves.toBeUndefined();
});
it("surfaces Cloudflare's error message on an invalid token", async () => {
mockFetch.mockResolvedValue(cfError("Invalid API Token"));
await expect(cloudflareClient.testConnection(config)).rejects.toThrow(
"Invalid API Token",
);
});
});
describe("cloudflareClient auth header", () => {
it("trims whitespace pasted into the token", async () => {
mockFetch.mockResolvedValue(cfSuccess([]));
await cloudflareClient.testConnection({
providerType: "cloudflare",
apiToken: " cf-token\n",
});
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer cf-token",
);
});
});
@@ -1,116 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => ({
db: {
query: { dnsProvider: { findFirst: vi.fn(), findMany: vi.fn() } },
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
import {
DNS_SECRET_MASK,
maskDnsProviderConfig,
mergeDnsProviderConfig,
} from "@dokploy/server/services/dns-provider";
describe("maskDnsProviderConfig", () => {
it("masks the apiToken for a cloudflare config", () => {
const masked = maskDnsProviderConfig({
providerType: "cloudflare",
apiToken: "real-token",
});
expect(masked).toEqual({
providerType: "cloudflare",
apiToken: DNS_SECRET_MASK,
});
});
it("masks only the secretAccessKey for a route53 config, keeping accessKeyId visible", () => {
const masked = maskDnsProviderConfig({
providerType: "route53",
accessKeyId: "AKIA_VISIBLE",
secretAccessKey: "shh",
});
expect(masked).toEqual({
providerType: "route53",
accessKeyId: "AKIA_VISIBLE",
secretAccessKey: DNS_SECRET_MASK,
});
});
it("leaves an empty sensitive field untouched instead of masking a blank value", () => {
const masked = maskDnsProviderConfig({
providerType: "cloudflare",
apiToken: "",
});
expect(masked).toEqual({ providerType: "cloudflare", apiToken: "" });
});
});
describe("mergeDnsProviderConfig", () => {
it("restores the real secret when the incoming config still has the mask placeholder", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "real-token",
};
const incoming = {
providerType: "cloudflare" as const,
apiToken: DNS_SECRET_MASK,
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(existing);
});
it("keeps a freshly entered secret instead of the stored one", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "old-token",
};
const incoming = {
providerType: "cloudflare" as const,
apiToken: "new-token",
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(incoming);
});
it("throws when switching provider type while the field is still masked", () => {
const existing = {
providerType: "cloudflare" as const,
apiToken: "real-token",
};
const incoming = {
providerType: "route53" as const,
accessKeyId: "AKIA",
secretAccessKey: DNS_SECRET_MASK,
};
expect(() => mergeDnsProviderConfig(incoming, existing)).toThrow(
"Credentials must be re-entered",
);
});
it("does not require re-entry for fields that are not sensitive", () => {
const existing = {
providerType: "route53" as const,
accessKeyId: "AKIA_OLD",
secretAccessKey: "old-secret",
};
const incoming = {
providerType: "route53" as const,
accessKeyId: "AKIA_NEW",
secretAccessKey: DNS_SECRET_MASK,
};
expect(mergeDnsProviderConfig(incoming, existing)).toEqual({
providerType: "route53",
accessKeyId: "AKIA_NEW",
secretAccessKey: "old-secret",
});
});
});
-288
View File
@@ -1,288 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type HasInput = { input: any };
const {
send,
Route53Client,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
ChangeResourceRecordSetsCommand,
} = vi.hoisted(() => {
class FakeCommand {
input: any;
constructor(input: any) {
this.input = input;
}
}
const send = vi.fn();
class Route53Client {
send(command: unknown) {
return send(command);
}
}
return {
send,
Route53Client,
ListHostedZonesCommand: class extends FakeCommand {},
ListResourceRecordSetsCommand: class extends FakeCommand {},
ChangeResourceRecordSetsCommand: class extends FakeCommand {},
};
});
vi.mock("@aws-sdk/client-route-53", () => ({
Route53Client,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
ChangeResourceRecordSetsCommand,
}));
import { route53Client } from "@dokploy/server/utils/dns/route53";
const config = {
providerType: "route53" as const,
accessKeyId: "AKIA_TEST",
secretAccessKey: "secret",
};
beforeEach(() => {
send.mockReset();
});
describe("route53Client.listZones", () => {
it("strips the hostedzone prefix and the trailing dot", async () => {
send.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z123", Name: "example.com." }],
IsTruncated: false,
});
const zones = await route53Client.listZones(config);
expect(zones).toEqual([{ id: "Z123", name: "example.com" }]);
});
it("follows the marker until IsTruncated is false", async () => {
send
.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z1", Name: "a.com." }],
IsTruncated: true,
NextMarker: "marker-1",
})
.mockResolvedValueOnce({
HostedZones: [{ Id: "/hostedzone/Z2", Name: "b.com." }],
IsTruncated: false,
});
const zones = await route53Client.listZones(config);
expect(zones).toEqual([
{ id: "Z1", name: "a.com" },
{ id: "Z2", name: "b.com" },
]);
expect(send).toHaveBeenCalledTimes(2);
expect((send.mock.calls[1]?.[0] as HasInput).input.Marker).toBe("marker-1");
});
});
describe("route53Client.listRecords", () => {
it("builds a type:name id and strips the trailing dot from the name", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records).toEqual([
{
id: "A:app.example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
},
]);
});
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{ Name: "alias.example.com.", Type: "A", ResourceRecords: [] },
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records).toEqual([]);
});
it("joins multiple values for the same record", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "example.com.",
Type: "NS",
TTL: 172800,
ResourceRecords: [
{ Value: "ns1.example.com" },
{ Value: "ns2.example.com" },
],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
});
});
describe("route53Client.upsertRecord", () => {
it("sends a single UPSERT change", async () => {
send.mockResolvedValueOnce({});
const result = await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "A:app.example.com" });
const command = send.mock.calls[0]?.[0] as HasInput;
expect(command.input.HostedZoneId).toBe("Z123");
expect(command.input.ChangeBatch.Changes).toEqual([
{
Action: "UPSERT",
ResourceRecordSet: {
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
},
]);
});
});
describe("route53Client.updateRecord", () => {
it("only UPSERTs when the name/type identity did not change", async () => {
send.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "A:app.example.com", {
type: "A",
name: "app.example.com",
content: "9.9.9.9",
});
expect(send).toHaveBeenCalledTimes(1);
const command = send.mock.calls[0]?.[0] as HasInput;
expect(command.input.ChangeBatch.Changes).toHaveLength(1);
expect(command.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
});
it("deletes the old record set and creates the new one on rename", async () => {
const existingSet = {
Name: "old.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
};
send
.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }) // findExactRecordSet lookup
.mockResolvedValueOnce({}); // ChangeResourceRecordSets
await route53Client.updateRecord(config, "Z123", "A:old.example.com", {
type: "A",
name: "new.example.com",
content: "1.1.1.1",
});
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
{ Action: "DELETE", ResourceRecordSet: existingSet },
{
Action: "UPSERT",
ResourceRecordSet: {
Name: "new.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
},
},
]);
});
it("skips the DELETE when the old record no longer exists", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "A:gone.example.com", {
type: "A",
name: "new.example.com",
content: "1.1.1.1",
});
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toHaveLength(1);
expect(changeCommand.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
});
});
describe("route53Client.deleteRecord", () => {
it("deletes the record set found by name/type", async () => {
const existingSet = {
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
};
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
send.mockResolvedValueOnce({});
await route53Client.deleteRecord(config, "Z123", "A:app.example.com");
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
{ Action: "DELETE", ResourceRecordSet: existingSet },
]);
});
it("throws when the record no longer exists", async () => {
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
await expect(
route53Client.deleteRecord(config, "Z123", "A:gone.example.com"),
).rejects.toThrow("not found");
});
it("throws for a malformed record id", async () => {
await expect(
route53Client.deleteRecord(config, "Z123", "not-a-valid-id"),
).rejects.toThrow("Invalid Route53 record id");
});
});
describe("route53Client.testConnection", () => {
it("resolves when ListHostedZones succeeds", async () => {
send.mockResolvedValueOnce({ HostedZones: [] });
await expect(route53Client.testConnection(config)).resolves.toBeUndefined();
});
it("propagates SDK errors", async () => {
send.mockRejectedValueOnce(new Error("InvalidClientTokenId"));
await expect(route53Client.testConnection(config)).rejects.toThrow(
"InvalidClientTokenId",
);
});
});
-51
View File
@@ -1,6 +1,5 @@
import {
prepareEnvironmentVariables,
prepareEnvironmentVariablesForFile,
prepareEnvironmentVariablesForShell,
} from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
@@ -643,53 +642,3 @@ SPECIAL=café résumé naïve
expect(resolved[2]).toContain("café");
});
});
describe("prepareEnvironmentVariablesForFile (.env file escaping)", () => {
it("keeps a well-formed ${VAR} self-reference interpolatable", () => {
const serviceEnv = `
APP_URL=https://example.com
ASSET_URL=\${APP_URL}
`;
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
expect(resolved).toEqual([
`APP_URL="https://example.com"`,
`ASSET_URL="\${APP_URL}"`,
]);
});
it("keeps a ${VAR:-default} self-reference with a modifier interpolatable", () => {
const serviceEnv = `ASSET_URL=\${APP_URL:-https://default.com}`;
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
expect(resolved).toEqual([`ASSET_URL="\${APP_URL:-https://default.com}"`]);
});
it("escapes a literal dollar sign followed by a word so it isn't interpolated", () => {
const serviceEnv = "PASSWORD=pa$word";
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
expect(resolved).toEqual([`PASSWORD="pa\\$word"`]);
});
it("escapes a trailing dollar sign", () => {
const serviceEnv = "PRICE=100$";
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
expect(resolved).toEqual([`PRICE="100\\$"`]);
});
it("still escapes double quotes and backslashes", () => {
const serviceEnv = String.raw`MESSAGE=say "hi" and \backslash`;
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
expect(resolved).toEqual([
String.raw`MESSAGE="say \"hi\" and \\backslash"`,
]);
});
});
-176
View File
@@ -20,7 +20,6 @@ import {
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
@@ -441,178 +440,3 @@ describe("doppler client", () => {
).rejects.toThrow("status 401");
});
});
describe("scaleway client", () => {
const config = {
providerType: "scaleway" as const,
region: "fr-par",
projectId: "project-1",
secretKey: "scw-secret-key",
apiUrl: "https://api.scaleway.com",
};
const accessResponse = (value: string) =>
jsonResponse({
secret_id: "secret-1",
revision: 1,
data: Buffer.from(value).toString("base64"),
});
it("reads a secret by name from the root path and decodes the payload", async () => {
mockFetch.mockResolvedValue(accessResponse("postgres://real"));
const result = await scalewayClient.getSecrets(config, ["db-url"]);
expect(result).toEqual({ "db-url": "postgres://real" });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets-by-path/versions/latest_enabled/access?project_id=project-1&secret_name=db-url&secret_path=%2F",
);
expect((init.headers as Record<string, string>)["X-Auth-Token"]).toBe(
"scw-secret-key",
);
});
it("sends the folder of a path-qualified ref as secret_path", async () => {
mockFetch.mockResolvedValue(accessResponse("value"));
await scalewayClient.getSecrets(config, ["prod/db/password"]);
expect(mockFetch.mock.calls[0]?.[0]).toContain(
"secret_name=password&secret_path=%2Fprod%2Fdb",
);
});
it("extracts a field from a JSON secret and fetches the secret once", async () => {
mockFetch.mockResolvedValue(
accessResponse(JSON.stringify({ user: "admin", port: 5432 })),
);
const result = await scalewayClient.getSecrets(config, [
"db-creds:user",
"db-creds:port",
]);
expect(result).toEqual({
"db-creds:user": "admin",
"db-creds:port": "5432",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws when the secret is not JSON but a field is requested", async () => {
mockFetch.mockResolvedValue(accessResponse("plain-value"));
await expect(
scalewayClient.getSecrets(config, ["db-creds:user"]),
).rejects.toThrow("is not JSON");
});
it("throws when the requested field is missing", async () => {
mockFetch.mockResolvedValue(accessResponse(JSON.stringify({ user: "a" })));
await expect(
scalewayClient.getSecrets(config, ["db-creds:password"]),
).rejects.toThrow('field "password" not found');
});
it("throws a clear error for a missing secret", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ message: "not found" }, false, 404),
);
await expect(scalewayClient.getSecrets(config, ["nope"])).rejects.toThrow(
'secret "nope" not found in path "/"',
);
});
it("reports authentication failures with the API message", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ message: "denied authentication" }, false, 403),
);
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
"authentication failed (status 403: denied authentication)",
);
});
it("throws when the secret has no enabled version", async () => {
mockFetch.mockResolvedValue(jsonResponse({ secret_id: "secret-1" }));
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
"has no enabled version",
);
});
it("rejects a ref without a secret name", async () => {
await expect(scalewayClient.getSecrets(config, ["prod/"])).rejects.toThrow(
"expected format <name> or <folder>/<name>[:field]",
);
});
it("tests the connection against the secrets listing", async () => {
mockFetch.mockResolvedValue(jsonResponse({ secrets: [], total_count: 0 }));
await scalewayClient.testConnection(config);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets?project_id=project-1&page_size=1",
);
});
it("lists secret names with their folder prefix", async () => {
mockFetch.mockResolvedValue(
jsonResponse({
secrets: [
{ id: "1", name: "db-url", path: "/" },
{ id: "2", name: "password", path: "/prod/db" },
],
total_count: 2,
}),
);
const names = await scalewayClient.listSecretNames?.(config);
expect(names).toEqual(["db-url", "prod/db/password"]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("follows pagination until a short page is returned", async () => {
const page = (start: number, size: number) =>
jsonResponse({
secrets: Array.from({ length: size }, (_, index) => ({
id: String(start + index),
name: `secret-${start + index}`,
path: "/",
})),
});
mockFetch.mockImplementation(async (url: string) =>
url.includes("page=2") ? page(101, 2) : page(1, 100),
);
const names = await scalewayClient.listSecretNames?.(config);
expect(names).toHaveLength(102);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("resolves env refs end to end through a scaleway provider", async () => {
findMany.mockResolvedValue([
{
name: "scw-prod",
providerType: "scaleway",
config,
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(accessResponse("s3cret"));
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.scw-prod.prod/db-password}}",
scope,
);
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
@@ -1,41 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
const containers = [
{ containerId: "first-container" },
{ containerId: "selected-container" },
];
describe("resolveContainerSelection", () => {
it("selects the first container when no container is selected", () => {
expect(resolveContainerSelection(undefined, containers)).toBe(
"first-container",
);
});
it("preserves a manual selection when refreshed data contains it", () => {
const refreshedContainers = containers.map((container) => ({
...container,
}));
expect(
resolveContainerSelection("selected-container", refreshedContainers),
).toBe("selected-container");
});
it("falls back to the first container when the selection disappears", () => {
expect(resolveContainerSelection("removed-container", containers)).toBe(
"first-container",
);
});
it("keeps the current selection while container data is loading", () => {
expect(resolveContainerSelection("selected-container", undefined)).toBe(
"selected-container",
);
});
it("clears the selection when no containers are available", () => {
expect(resolveContainerSelection("selected-container", [])).toBeUndefined();
});
});
@@ -40,7 +40,6 @@ const ENTERPRISE_RESOURCES = [
"monitoring",
"auditLog",
"vaultProvider",
"dnsProvider",
];
describe("enterpriseOnlyResources set", () => {
@@ -1,24 +0,0 @@
import { getSubnetCapacity } from "@dokploy/server/services/server-health";
import { describe, expect, test } from "vitest";
describe("getSubnetCapacity", () => {
test("returns null for missing/invalid input", () => {
expect(getSubnetCapacity(undefined)).toBeNull();
expect(getSubnetCapacity("")).toBeNull();
expect(getSubnetCapacity("10.0.0.0")).toBeNull();
expect(getSubnetCapacity("not-a-subnet")).toBeNull();
expect(getSubnetCapacity("10.0.0.0/33")).toBeNull();
expect(getSubnetCapacity("10.0.0.0/-1")).toBeNull();
});
test("excludes network and broadcast addresses", () => {
expect(getSubnetCapacity("10.0.1.0/24")).toBe(254);
expect(getSubnetCapacity("10.0.0.0/16")).toBe(65534);
expect(getSubnetCapacity("10.99.99.0/30")).toBe(2);
});
test("returns 0 for subnets too small to hold a host", () => {
expect(getSubnetCapacity("10.0.0.0/31")).toBe(0);
expect(getSubnetCapacity("10.0.0.0/32")).toBe(0);
});
});
@@ -1,113 +0,0 @@
import {
getBackupOverviewIcon,
getServiceOverviewIcon,
} from "@dokploy/server/services/overview";
import { describe, expect, test } from "vitest";
describe("getServiceOverviewIcon", () => {
test("returns a db icon for every known DB engine type", () => {
for (const type of [
"postgres",
"mariadb",
"mysql",
"mongo",
"redis",
"libsql",
] as const) {
expect(getServiceOverviewIcon({ type, icon: null })).toEqual({
kind: "db",
engine: type,
});
}
});
test("returns a custom icon for application/compose with a service icon set", () => {
expect(
getServiceOverviewIcon({
type: "application",
icon: "data:image/png;base64,x",
}),
).toEqual({ kind: "custom", url: "data:image/png;base64,x" });
expect(
getServiceOverviewIcon({
type: "compose",
icon: "data:image/png;base64,y",
}),
).toEqual({ kind: "custom", url: "data:image/png;base64,y" });
});
test("falls back to a generic icon for application/compose without a service icon", () => {
expect(getServiceOverviewIcon({ type: "application", icon: null })).toEqual(
{
kind: "generic",
type: "application",
},
);
expect(getServiceOverviewIcon({ type: "compose", icon: null })).toEqual({
kind: "generic",
type: "compose",
});
});
});
describe("getBackupOverviewIcon", () => {
test("returns a db icon for a database backup with a known databaseType", () => {
expect(
getBackupOverviewIcon({
databaseType: "postgres",
serviceType: null,
serviceOwnerType: "postgres",
}),
).toEqual({ kind: "db", engine: "postgres" });
});
test("returns a webServer icon for a web-server database backup", () => {
expect(
getBackupOverviewIcon({
databaseType: "web-server",
serviceType: null,
serviceOwnerType: "web-server",
}),
).toEqual({ kind: "webServer" });
});
test("returns a db icon for a compose-type backup dumping a known DB engine", () => {
expect(
getBackupOverviewIcon({
databaseType: "mysql",
serviceType: null,
serviceOwnerType: "compose",
}),
).toEqual({ kind: "db", engine: "mysql" });
});
test("returns a db icon for a volume backup of a known DB engine service", () => {
expect(
getBackupOverviewIcon({
databaseType: null,
serviceType: "mongo",
serviceOwnerType: "mongo",
}),
).toEqual({ kind: "db", engine: "mongo" });
});
test("returns a generic application icon for a volume backup of an application", () => {
expect(
getBackupOverviewIcon({
databaseType: null,
serviceType: "application",
serviceOwnerType: "application",
}),
).toEqual({ kind: "generic", type: "application" });
});
test("returns a generic compose icon for a volume backup of a compose service", () => {
expect(
getBackupOverviewIcon({
databaseType: null,
serviceType: "compose",
serviceOwnerType: "compose",
}),
).toEqual({ kind: "generic", type: "compose" });
});
});
@@ -1,79 +0,0 @@
import type { OverviewDomain } from "@dokploy/server/services/overview";
import { sortOverviewDomains } from "@dokploy/server/services/overview";
import { describe, expect, test } from "vitest";
const makeDomain = (overrides: Partial<OverviewDomain>): OverviewDomain => ({
domainId: "id",
host: "example.com",
path: "/",
port: 3000,
customEntrypoint: null,
https: true,
certificateType: "letsencrypt",
createdAt: "2024-01-01T00:00:00.000Z",
enabled: true,
domainType: "application",
serviceOwnerId: "app",
serviceOwnerType: "application",
serviceName: "App",
projectId: "project",
projectName: "Project",
environmentId: "environment",
environmentName: "Environment",
...overrides,
});
describe("sortOverviewDomains", () => {
test("sorts by createdAt asc/desc", () => {
const domains = [
makeDomain({ domainId: "old", createdAt: "2023-01-01T00:00:00.000Z" }),
makeDomain({ domainId: "new", createdAt: "2024-06-01T00:00:00.000Z" }),
makeDomain({ domainId: "mid", createdAt: "2023-12-01T00:00:00.000Z" }),
];
expect(
sortOverviewDomains(domains, "createdAt-asc").map((d) => d.domainId),
).toEqual(["old", "mid", "new"]);
expect(
sortOverviewDomains(domains, "createdAt-desc").map((d) => d.domainId),
).toEqual(["new", "mid", "old"]);
});
test("sorts by port asc/desc, with portless domains always last", () => {
const domains = [
makeDomain({ domainId: "none", port: null }),
makeDomain({ domainId: "low", port: 80 }),
makeDomain({ domainId: "high", port: 8080 }),
];
expect(
sortOverviewDomains(domains, "port-asc").map((d) => d.domainId),
).toEqual(["low", "high", "none"]);
expect(
sortOverviewDomains(domains, "port-desc").map((d) => d.domainId),
).toEqual(["high", "low", "none"]);
});
test("port sort with all domains portless is a no-op", () => {
const domains = [
makeDomain({ domainId: "a", port: null }),
makeDomain({ domainId: "b", port: null }),
];
expect(
sortOverviewDomains(domains, "port-desc").map((d) => d.domainId),
).toEqual(["a", "b"]);
});
test("does not mutate the input array", () => {
const domains = [
makeDomain({ domainId: "b", port: 8080 }),
makeDomain({ domainId: "a", port: 80 }),
];
const original = [...domains];
sortOverviewDomains(domains, "port-asc");
expect(domains).toEqual(original);
});
});
@@ -1,106 +0,0 @@
import type { OverviewService } from "@dokploy/server/services/overview";
import { sortOverviewServices } from "@dokploy/server/services/overview";
import { describe, expect, test } from "vitest";
const makeService = (overrides: Partial<OverviewService>): OverviewService => ({
id: "id",
type: "application",
name: "name",
appName: "app-name",
status: "running",
createdAt: "2024-01-01T00:00:00.000Z",
serverId: null,
serverName: null,
icon: null,
projectId: "project",
projectName: "Project",
environmentId: "environment",
environmentName: "Environment",
lastDeployAt: null,
...overrides,
});
describe("sortOverviewServices", () => {
test("sorts by name asc/desc", () => {
const services = [
makeService({ id: "b", name: "Bravo" }),
makeService({ id: "a", name: "Alpha" }),
makeService({ id: "c", name: "Charlie" }),
];
expect(sortOverviewServices(services, "name-asc").map((s) => s.id)).toEqual(
["a", "b", "c"],
);
expect(
sortOverviewServices(services, "name-desc").map((s) => s.id),
).toEqual(["c", "b", "a"]);
});
test("sorts by type asc/desc", () => {
const services = [
makeService({ id: "app", type: "application" }),
makeService({ id: "pg", type: "postgres" }),
makeService({ id: "comp", type: "compose" }),
];
expect(sortOverviewServices(services, "type-asc").map((s) => s.id)).toEqual(
["app", "comp", "pg"],
);
expect(
sortOverviewServices(services, "type-desc").map((s) => s.id),
).toEqual(["pg", "comp", "app"]);
});
test("sorts by createdAt asc/desc", () => {
const services = [
makeService({ id: "old", createdAt: "2023-01-01T00:00:00.000Z" }),
makeService({ id: "new", createdAt: "2024-06-01T00:00:00.000Z" }),
makeService({ id: "mid", createdAt: "2023-12-01T00:00:00.000Z" }),
];
expect(
sortOverviewServices(services, "createdAt-asc").map((s) => s.id),
).toEqual(["old", "mid", "new"]);
expect(
sortOverviewServices(services, "createdAt-desc").map((s) => s.id),
).toEqual(["new", "mid", "old"]);
});
test("sorts by lastDeploy asc/desc, with never-deployed services always last", () => {
const services = [
makeService({ id: "never", lastDeployAt: null }),
makeService({ id: "old", lastDeployAt: "2023-01-01T00:00:00.000Z" }),
makeService({ id: "new", lastDeployAt: "2024-06-01T00:00:00.000Z" }),
];
expect(
sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id),
).toEqual(["new", "old", "never"]);
expect(
sortOverviewServices(services, "lastDeploy-asc").map((s) => s.id),
).toEqual(["old", "new", "never"]);
});
test("lastDeploy sort with all services never deployed is a no-op", () => {
const services = [
makeService({ id: "a", lastDeployAt: null }),
makeService({ id: "b", lastDeployAt: null }),
];
expect(
sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id),
).toEqual(["a", "b"]);
});
test("does not mutate the input array", () => {
const services = [
makeService({ id: "b", name: "Bravo" }),
makeService({ id: "a", name: "Alpha" }),
];
const original = [...services];
sortOverviewServices(services, "name-asc");
expect(services).toEqual(original);
});
});
@@ -1,236 +0,0 @@
import { execSync } from "node:child_process";
import { docker } from "@dokploy/server/constants";
import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 120000;
const SERVICE_NAME = "dokploy-monitoring";
const TEST_IMAGE = "busybox:latest";
// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to
// the local daemon, so the legacy-container cleanup is exercised for real.
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn().mockResolvedValue({
serverId: "test-server",
serverType: "deploy",
sshKeyId: null, // -> getRemoteDocker returns the local docker instance
metricsConfig: {
server: {
type: "Remote",
port: 4500,
token: "test-token",
urlCallback: "http://localhost/callback",
cronJob: "0 0 * * *",
retentionDays: 2,
refreshRate: 60,
thresholds: { cpu: 0, memory: 0 },
},
containers: { refreshRate: 60, services: { include: [], exclude: [] } },
},
}),
}));
vi.mock("@dokploy/server/services/settings", () => ({
getDokployImageTag: vi.fn(() => "latest"),
}));
vi.mock("@dokploy/server/utils/docker/utils", () => ({
pullImage: vi.fn().mockResolvedValue(undefined),
pullRemoteImage: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
}));
const containerExists = async (name: string) => {
try {
await docker.getContainer(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
const serviceExists = async (name: string) => {
try {
await docker.getService(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
const swarmTaskNames = async () => {
const list = await docker.listContainers({ all: true });
return list
.flatMap((c) => c.Names.map((n) => n.replace(/^\//, "")))
.filter((n) => n.startsWith(`${SERVICE_NAME}.`));
};
const cleanup = async () => {
try {
await docker.getService(SERVICE_NAME).remove();
} catch {}
try {
await docker.getContainer(SERVICE_NAME).remove({ force: true });
} catch {}
for (const name of await swarmTaskNames()) {
try {
await docker.getContainer(name).remove({ force: true });
} catch {}
}
};
// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop.
const createLegacyZombie = async () => {
const container = await docker.createContainer({
name: SERVICE_NAME,
Image: TEST_IMAGE,
Cmd: [
"sh",
"-c",
"echo 'Error starting metrics cleanup system: empty spec string'; exit 1",
],
HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" },
});
await container.start();
};
const pullTestImage = async () => {
try {
await docker.getImage(TEST_IMAGE).inspect();
return;
} catch {}
await new Promise<void>((resolve, reject) =>
docker.pull(TEST_IMAGE, (err: any, stream: any) =>
err
? reject(err)
: docker.modem.followProgress(stream, (e: any) =>
e ? reject(e) : resolve(),
),
),
);
};
// The code under test hardcodes the production name, so this suite cannot
// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host.
const hasRealMonitoring = () => {
const query = (cmd: string) => {
try {
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return "";
}
};
return (
query(
`docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`,
) !== "" ||
query(
`docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`,
) !== ""
);
};
describe.skipIf(hasRealMonitoring())(
"setupMonitoring - legacy container cleanup (real docker)",
() => {
beforeEach(async () => {
await pullTestImage();
await cleanup();
}, REAL_TEST_TIMEOUT);
afterAll(async () => {
await cleanup();
}, REAL_TEST_TIMEOUT);
it(
"removes the legacy standalone container left behind by the swarm migration",
async () => {
await createLegacyZombie();
expect(await containerExists(SERVICE_NAME)).toBe(true);
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await serviceExists(SERVICE_NAME)).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"removes the legacy container without touching swarm tasks, which are named dokploy-monitoring.<slot>.<id>",
async () => {
const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`;
const task = await docker.createContainer({
name: taskName,
Image: TEST_IMAGE,
Cmd: ["sleep", "3600"],
});
await task.start();
await createLegacyZombie();
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await containerExists(taskName)).toBe(true);
const inspect = await docker.getContainer(taskName).inspect();
expect(inspect.State.Running).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"is idempotent when no legacy container exists",
async () => {
expect(await containerExists(SERVICE_NAME)).toBe(false);
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(await serviceExists(SERVICE_NAME)).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"deploys the service even when removing the legacy container fails",
async () => {
const failingDocker = {
getContainer: () => ({
remove: async () => {
const error: any = new Error("device or resource busy");
error.statusCode = 500;
throw error;
},
}),
getService: docker.getService.bind(docker),
createService: docker.createService.bind(docker),
};
const remoteDocker = await import(
"@dokploy/server/utils/servers/remote-docker"
);
const spy = vi
.spyOn(remoteDocker, "getRemoteDocker")
.mockResolvedValue(failingDocker as any);
try {
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
expect(await serviceExists(SERVICE_NAME)).toBe(true);
} finally {
spy.mockRestore();
}
},
REAL_TEST_TIMEOUT,
);
},
);
@@ -35,7 +35,6 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("forwardAuthMiddlewareName", () => {
@@ -1,71 +0,0 @@
import { reconnectServicesToTraefik } from "@dokploy/server/services/settings";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findMany: vi.fn(),
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
compose: {
findMany: mocks.findMany,
},
},
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: mocks.execAsync,
execAsyncRemote: mocks.execAsyncRemote,
}));
describe("reconnectServicesToTraefik", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findMany.mockResolvedValue([]);
});
it("does not execute an empty local command when no isolated deployments exist", async () => {
await reconnectServicesToTraefik();
expect(mocks.execAsync).not.toHaveBeenCalled();
});
it("does not execute an empty remote command when no isolated deployments exist", async () => {
await reconnectServicesToTraefik("server-id");
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
it("reconnects isolated deployments to the local Traefik network", async () => {
mocks.findMany.mockResolvedValue([
{ appName: "first-compose" },
{ appName: "second-compose" },
]);
await reconnectServicesToTraefik();
expect(mocks.execAsync).toHaveBeenCalledOnce();
expect(mocks.execAsync).toHaveBeenCalledWith(
'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' +
'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
);
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
it("reconnects isolated deployments on a remote server", async () => {
mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]);
await reconnectServicesToTraefik("server-id");
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
"server-id",
'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
);
expect(mocks.execAsync).not.toHaveBeenCalled();
});
});
@@ -151,7 +151,6 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
const baseRedirect: Redirect = {
@@ -94,32 +94,4 @@ describe("readValidDirectory (path traversal)", () => {
),
).toBe(true);
});
it("returns true for SvelteKit routes with + prefix and @ symbols", () => {
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+page.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+layout.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+server.ts`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+error.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`,
),
).toBe(true);
});
});
@@ -187,7 +187,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
To use a cluster feature, you need to configure at least
a registry first. Please, go to{" "}
<Link
href="/dashboard/docker?tab=swarm&subtab=nodes"
href="/dashboard/settings/cluster"
className="text-foreground"
>
Settings
@@ -234,7 +234,7 @@ export const HandleRedirect = ({
<FormItem>
<FormLabel>Replacement</FormLabel>
<FormControl>
<Input placeholder="http://mydomain/$1" {...field} />
<Input placeholder="http://mydomain/$${1}" {...field} />
</FormControl>
<FormMessage />
@@ -148,7 +148,7 @@ export const ShowDeployment = ({
<DialogDescription className="flex items-center gap-2">
<span className="flex items-center gap-2">
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs tabular-nums">
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
@@ -14,7 +14,6 @@ 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 { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
@@ -36,9 +35,7 @@ interface ColumnsProps {
validationStates: ValidationStates;
handleValidateDomain: (host: string) => Promise<void>;
handleDeleteDomain: (domainId: string) => Promise<void>;
handleToggleEnable: (domainId: string) => Promise<void>;
isDeleting: boolean;
isToggling: boolean;
serverIp?: string;
canCreateDomain: boolean;
canDeleteDomain: boolean;
@@ -50,9 +47,7 @@ export const createColumns = ({
validationStates,
handleValidateDomain,
handleDeleteDomain,
handleToggleEnable,
isDeleting,
isToggling,
serverIp,
canCreateDomain,
canDeleteDomain,
@@ -254,42 +249,6 @@ export const createColumns = ({
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => {
const domain = row.original;
if (!canCreateDomain) {
return (
<Badge variant={domain.enabled ? "outline" : "secondary"}>
{domain.enabled ? "Enabled" : "Disabled"}
</Badge>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Switch
checked={domain.enabled}
onCheckedChange={() => handleToggleEnable(domain.domainId)}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{domain.enabled
? "Domain is active. Toggle to disable routing without deleting it."
: "Domain is disabled and not routed. Toggle to enable it again."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
id: "actions",
header: "Actions",
@@ -46,7 +46,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
export type CacheType = "fetch" | "cache";
@@ -301,12 +300,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
})
.then(async () => {
toast.success(
dictionary.success,
data.domainType === "compose"
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
toast.success(dictionary.success);
if (data.domainType === "application") {
await utils.domain.byApplicationId.invalidate({
@@ -343,7 +337,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{type === "compose" && <ComposeRedeployAlert className="mb-4" />}
{type === "compose" && (
<AlertBlock type="info" className="mb-4">
Whenever you make changes to domains, remember to redeploy your
compose to apply the changes.
</AlertBlock>
)}
<Form {...form}>
<form
@@ -1,19 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
/**
* Domains attached to a compose service are rendered as docker labels and only
* take effect on the next deployment. These strings keep the "redeploy required"
* wording consistent across the add/edit dialog, the domains list and the
* toasts shown after create/update/delete/toggle operations.
*/
export const COMPOSE_REDEPLOY_HINT =
"Whenever you make changes to domains, remember to redeploy your compose to apply the changes.";
export const COMPOSE_REDEPLOY_TOAST =
"Redeploy the compose to apply the changes.";
export const ComposeRedeployAlert = ({ className }: { className?: string }) => (
<AlertBlock type="info" className={className}>
{COMPOSE_REDEPLOY_HINT}
</AlertBlock>
);
@@ -44,7 +44,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
@@ -59,13 +58,11 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import { HandleForwardAuth } from "./handle-forward-auth";
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
export type ValidationState = {
isLoading: boolean;
@@ -149,34 +146,12 @@ export const ShowDomains = ({ id, type }: Props) => {
api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation();
const { mutateAsync: toggleEnable, isPending: isToggling } =
api.domain.toggleEnable.useMutation();
const handleToggleEnable = async (domainId: string) => {
try {
const result = await toggleEnable({ domainId });
refetch();
toast.success(
result.enabled ? "Domain enabled" : "Domain disabled",
result.requiresRedeploy
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
} catch {
toast.error("Error updating the domain");
}
};
const handleDeleteDomain = async (domainId: string) => {
try {
await deleteDomain({ domainId });
refetch();
toast.success(
"Domain deleted successfully",
type === "compose"
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
toast.success("Domain deleted successfully");
} catch {
toast.error("Error deleting domain");
}
@@ -225,9 +200,7 @@ export const ShowDomains = ({ id, type }: Props) => {
validationStates,
handleValidateDomain,
handleDeleteDomain,
handleToggleEnable,
isDeleting: isRemoving,
isToggling,
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
canCreateDomain,
canDeleteDomain,
@@ -292,11 +265,6 @@ export const ShowDomains = ({ id, type }: Props) => {
)}
</div>
</CardHeader>
{type === "compose" && data && data.length > 0 && (
<div className="px-6 pb-4">
<ComposeRedeployAlert />
</div>
)}
<CardContent className="flex w-full flex-row gap-4">
{isLoadingDomains ? (
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
@@ -445,10 +413,7 @@ export const ShowDomains = ({ id, type }: Props) => {
return (
<Card
key={item.domainId}
className={cn(
"relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit",
!item.enabled && "opacity-60",
)}
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
@@ -501,7 +466,18 @@ export const ShowDomains = ({ id, type }: Props) => {
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await handleDeleteDomain(item.domainId);
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
@@ -516,41 +492,15 @@ export const ShowDomains = ({ id, type }: Props) => {
)}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="w-full break-all">
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4 min-w-4" />
</Link>
</div>
{canCreateDomain && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center shrink-0">
<Switch
checked={item.enabled}
onCheckedChange={() =>
handleToggleEnable(item.domainId)
}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{item.enabled
? "Domain is active. Toggle to disable routing without deleting it."
: "Domain is disabled and not routed. Toggle to enable it again."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<div className="w-full break-all">
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4 min-w-4" />
</Link>
</div>
{/* Domain Details */}
@@ -1,7 +1,6 @@
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -80,13 +79,17 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
},
);
const availableContainers = option === "native" ? containers : services;
useEffect(() => {
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, availableContainers),
);
}, [availableContainers]);
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
@@ -111,7 +114,6 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setContainerId(undefined);
setOption(checked ? "native" : "swarm");
}}
/>
@@ -50,7 +50,6 @@ interface Props {
id: string;
type: "application" | "compose";
serverId?: string;
trigger?: React.ReactNode;
}
const RestoreBackupSchema = z.object({
@@ -65,12 +64,7 @@ const RestoreBackupSchema = z.object({
}),
});
export const RestoreVolumeBackups = ({
id,
type,
serverId,
trigger,
}: Props) => {
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
@@ -150,12 +144,10 @@ export const RestoreVolumeBackups = ({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Volume Backup
</Button>
)}
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Volume Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
@@ -2,7 +2,6 @@ import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -71,13 +70,18 @@ export const ShowDockerLogsStack = ({
);
const containers = data?.filter((container) => container.containerId);
const availableContainers = option === "native" ? containers : services;
useEffect(() => {
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, availableContainers),
);
}, [availableContainers]);
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
@@ -102,7 +106,6 @@ export const ShowDockerLogsStack = ({
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setContainerId(undefined);
setOption(checked ? "native" : "swarm");
}}
/>
@@ -74,7 +74,6 @@ interface Props {
databaseType?: DatabaseType;
serverId?: string | null;
backupType?: "database" | "compose";
trigger?: React.ReactNode;
}
const RestoreBackupSchema = z
@@ -201,7 +200,6 @@ export const RestoreBackup = ({
databaseType,
serverId,
backupType = "database",
trigger,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -313,12 +311,10 @@ export const RestoreBackup = ({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</Button>
)}
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
@@ -14,11 +14,10 @@ import {
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Boxes,
ChevronLeft,
ChevronRight,
CircuitBoard,
ExternalLink,
GlobeIcon,
Loader2,
Rocket,
Server,
@@ -72,26 +71,24 @@ function getServiceInfo(d: DeploymentRow) {
return {
type: "Application" as const,
name: app.name,
icon: app.icon,
projectId: app.environment.project.projectId,
environmentId: app.environment.environmentId,
projectName: app.environment.project.name,
environmentName: app.environment.name,
serviceId: app.applicationId,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}?tab=deployments`,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
type: "Compose" as const,
name: comp.name,
icon: comp.icon,
projectId: comp.environment.project.projectId,
environmentId: comp.environment.environmentId,
projectName: comp.environment.project.name,
environmentName: comp.environment.name,
serviceId: comp.composeId,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}?tab=deployments`,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
@@ -178,16 +175,10 @@ export function ShowDeploymentsTable() {
if (!info) return <span className="text-muted-foreground">—</span>;
return (
<div className="flex items-center gap-2">
{info.icon ? (
<img
src={info.icon}
alt={info.name}
className="size-4 object-contain shrink-0"
/>
) : info.type === "Application" ? (
<GlobeIcon className="size-4 text-muted-foreground shrink-0" />
{info.type === "Application" ? (
<Rocket className="size-4 text-muted-foreground shrink-0" />
) : (
<CircuitBoard className="size-4 text-muted-foreground shrink-0" />
<Boxes className="size-4 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{info.name}</span>
@@ -1,428 +0,0 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Boxes,
Database,
Gauge,
HardDrive,
Layers,
Loader2,
type LucideIcon,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type DiskUsageItem =
inferRouterOutputs<AppRouter>["dockerDiskUsage"]["getDiskUsage"][number];
type BuildCacheBase =
inferRouterOutputs<AppRouter>["dockerDiskUsage"]["getBuildCache"][number];
type BuildCacheRow = BuildCacheBase & { key: string };
interface Props {
serverId?: string;
}
const STAT_CARDS: { type: string; title: string; icon: LucideIcon }[] = [
{ type: "Images", title: "Images", icon: Layers },
{ type: "Containers", title: "Containers", icon: Boxes },
{ type: "Local Volumes", title: "Volumes", icon: HardDrive },
{ type: "Build Cache", title: "Build Cache", icon: Database },
];
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
const StatCard = ({
icon: Icon,
title,
item,
isLoading,
}: {
icon: LucideIcon;
title: string;
item?: DiskUsageItem;
isLoading: boolean;
}) => (
<Card className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Icon className="size-4" />
{title}
</div>
{isLoading ? (
<div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
) : (
<>
<div className="mt-2 text-2xl font-semibold">{item?.size ?? "-"}</div>
<div className="mt-1 flex flex-wrap gap-x-3 text-xs text-muted-foreground">
<span>{item?.totalCount ?? 0} total</span>
<span>{item?.active ?? 0} active</span>
<span>{item?.reclaimable ?? "-"} reclaimable</span>
</div>
</>
)}
</Card>
);
export const ShowDiskUsage = ({ serverId }: Props) => {
const utils = api.useUtils();
const [sorting, setSorting] = useState<SortingState>([
{ id: "Size", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: diskUsage, isLoading: isLoadingDiskUsage } =
api.dockerDiskUsage.getDiskUsage.useQuery({ serverId });
const { data: buildCache, isLoading: isLoadingBuildCache } =
api.dockerDiskUsage.getBuildCache.useQuery({ serverId });
const { mutateAsync: pruneBuildCache, isPending: isPruning } =
api.dockerDiskUsage.pruneBuildCache.useMutation();
const usageByType = useMemo(
() => new Map((diskUsage ?? []).map((item) => [item.type, item])),
[diskUsage],
);
const rows = useMemo<BuildCacheRow[]>(
() =>
(buildCache ?? []).map((entry) => ({
...entry,
key: entry.id,
})),
[buildCache],
);
const filteredData = useMemo(() => {
if (!globalFilter.trim()) {
return rows;
}
const query = globalFilter.toLowerCase();
return rows.filter(
(entry) =>
entry.id.toLowerCase().includes(query) ||
entry.description.toLowerCase().includes(query) ||
entry.type.toLowerCase().includes(query),
);
}, [rows, globalFilter]);
const handlePrune = async () => {
try {
await pruneBuildCache({ serverId });
toast.success("Build cache pruned");
await Promise.all([
utils.dockerDiskUsage.getBuildCache.invalidate(),
utils.dockerDiskUsage.getDiskUsage.invalidate(),
]);
} catch (error) {
toast.error("Error pruning build cache", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const columns = useMemo<ColumnDef<BuildCacheRow>[]>(
() => [
{
accessorKey: "id",
header: ({ column }) => (
<SortableHeader column={column} title="Cache ID" />
),
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.id.slice(0, 16)}
</span>
),
},
{
accessorKey: "type",
header: ({ column }) => <SortableHeader column={column} title="Type" />,
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
},
{
accessorKey: "description",
header: "Description",
enableSorting: false,
cell: ({ row }) => (
<div
className="max-w-[360px] truncate text-xs text-muted-foreground"
title={row.original.description}
>
{row.original.description || "-"}
</div>
),
},
{
id: "Size",
accessorFn: (entry) => entry.sizeBytes,
header: ({ column }) => <SortableHeader column={column} title="Size" />,
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.size}</span>
),
},
{
id: "created",
accessorKey: "createdSince",
header: "Created",
enableSorting: false,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{row.original.createdSince}
</span>
),
},
{
id: "lastUsed",
accessorKey: "lastUsedSince",
header: "Last Used",
enableSorting: false,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{row.original.lastUsedSince}
</span>
),
},
{
accessorKey: "usageCount",
header: ({ column }) => (
<SortableHeader column={column} title="Usage" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.usageCount}
</span>
),
},
{
id: "flags",
enableSorting: false,
header: "Flags",
cell: ({ row }) => (
<div className="flex gap-1">
{row.original.inUse && <Badge variant="blue">In use</Badge>}
{row.original.shared && <Badge variant="outline">Shared</Badge>}
</div>
),
},
],
[],
);
const table = useReactTable({
data: filteredData,
columns,
getRowId: (row) => row.key,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{STAT_CARDS.map((stat) => (
<StatCard
key={stat.type}
icon={stat.icon}
title={stat.title}
item={usageByType.get(stat.type)}
isLoading={isLoadingDiskUsage}
/>
))}
</div>
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<Gauge className="size-6 text-muted-foreground self-center" />
Build Cache
</CardTitle>
<CardDescription>
Cache layers kept by the Docker builder on the selected
server.
</CardDescription>
</div>
<DialogAction
title="Prune build cache"
description="This will remove all build cache entries that are not currently in use. This action cannot be undone."
onClick={handlePrune}
disabled={isPruning}
>
<Button variant="outline" size="sm" disabled={isPruning}>
<Trash2 className="size-4 mr-1" />
Prune build cache
</Button>
</DialogAction>
</div>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoadingBuildCache ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !buildCache?.length ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<Database className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No build cache found</p>
<p className="max-w-sm text-sm text-muted-foreground">
Layers cached by "docker build" on this server will appear
here.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by id, type or description..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<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}>
{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 text-muted-foreground"
>
No build cache entries match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<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>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -1,425 +0,0 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Activity, ArrowUpDown, Loader2, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api, type RouterOutputs } from "@/utils/api";
interface Props {
serverId?: string;
}
type DockerEvent = RouterOutputs["docker"]["getEvents"]["events"][number];
type EventRow = DockerEvent & { key: string };
const RANGE_OPTIONS = [
{ label: "Last 5 minutes", value: 5 },
{ label: "Last 15 minutes", value: 15 },
{ label: "Last hour", value: 60 },
{ label: "Last 6 hours", value: 360 },
{ label: "Last 24 hours", value: 1440 },
];
type BadgeVariant = "blue" | "green" | "yellow" | "orange" | "red" | "blank";
const TYPE_VARIANTS: Record<string, BadgeVariant> = {
container: "blue",
image: "green",
volume: "yellow",
network: "orange",
service: "blue",
node: "orange",
};
const ACTION_VARIANTS: Record<string, BadgeVariant> = {
create: "green",
start: "green",
pull: "green",
connect: "green",
die: "red",
destroy: "red",
kill: "red",
stop: "red",
remove: "red",
disconnect: "red",
pause: "yellow",
unpause: "yellow",
};
const getTypeVariant = (type?: string): BadgeVariant =>
(type && TYPE_VARIANTS[type]) || "blank";
const getActionVariant = (action?: string): BadgeVariant =>
(action && ACTION_VARIANTS[action]) || "blank";
const getResource = (event: DockerEvent) =>
event.Actor?.Attributes?.name ?? event.Actor?.ID ?? "-";
const getAttributesText = (event: DockerEvent) => {
const attributes = Object.entries(event.Actor?.Attributes ?? {}).filter(
([key]) => key !== "name",
);
return attributes.map(([key, value]) => `${key}=${value}`).join(" ") || "-";
};
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
export const ShowDockerEvents = ({ serverId }: Props) => {
const [minutes, setMinutes] = useState(15);
const [search, setSearch] = useState("");
const [sorting, setSorting] = useState<SortingState>([
{ id: "time", desc: true },
]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 15,
});
const { data, isLoading, isRefetching, refetch, error } =
api.docker.getEvents.useQuery({
serverId,
minutes,
});
const events = useMemo<EventRow[]>(
() =>
(data?.events ?? []).map((event, index) => ({
...event,
key: `${event.time}-${event.Action}-${index}`,
})),
[data],
);
const filteredEvents = useMemo(() => {
if (!search.trim()) return events;
const query = search.toLowerCase();
return events.filter((event) => {
return (
event.Type?.toLowerCase().includes(query) ||
event.Action?.toLowerCase().includes(query) ||
event.Actor?.Attributes?.name?.toLowerCase().includes(query) ||
event.Actor?.ID?.toLowerCase().includes(query)
);
});
}, [events, search]);
const columns = useMemo<ColumnDef<EventRow>[]>(
() => [
{
id: "time",
accessorFn: (event) => event.time ?? 0,
header: ({ column }) => <SortableHeader column={column} title="Time" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{row.original.time
? new Date(row.original.time * 1000).toLocaleTimeString()
: "-"}
</span>
),
},
{
id: "type",
accessorFn: (event) => event.Type ?? "",
header: ({ column }) => <SortableHeader column={column} title="Type" />,
cell: ({ row }) => (
<Badge variant={getTypeVariant(row.original.Type)}>
{row.original.Type ?? "unknown"}
</Badge>
),
},
{
id: "action",
accessorFn: (event) => event.Action ?? "",
header: ({ column }) => (
<SortableHeader column={column} title="Action" />
),
cell: ({ row }) => (
<Badge variant={getActionVariant(row.original.Action)}>
{row.original.Action ?? "-"}
</Badge>
),
},
{
id: "resource",
accessorFn: (event) => getResource(event),
header: ({ column }) => (
<SortableHeader column={column} title="Resource" />
),
cell: ({ row }) => {
const resource = getResource(row.original);
return (
<div
className="max-w-[220px] truncate font-mono text-xs"
title={resource}
>
{resource}
</div>
);
},
},
{
id: "attributes",
accessorFn: (event) => getAttributesText(event),
header: "Attributes",
enableSorting: false,
cell: ({ row }) => {
const attributesText = getAttributesText(row.original);
return (
<div
className="max-w-[360px] truncate text-xs text-muted-foreground"
title={attributesText}
>
{attributesText}
</div>
);
},
},
],
[],
);
const table = useReactTable({
data: filteredEvents,
columns,
getRowId: (row) => row.key,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<Activity className="size-6 text-muted-foreground self-center" />
Docker Events
</CardTitle>
<CardDescription>
Events reported by the Docker daemon, equivalent to running
"docker events".
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`size-4 mr-1 ${isRefetching ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{error && (
<p className="text-sm text-destructive">{error.message}</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Filter by type, action or name..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
className="max-w-xs"
/>
<Select
value={String(minutes)}
onValueChange={(value) => setMinutes(Number(value))}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{RANGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
<div className="flex flex-row items-center justify-center gap-2">
<span>Loading events...</span>
<Loader2 className="size-4 animate-spin" />
</div>
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{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 text-muted-foreground"
>
No events found in the selected time range.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Go to</span>
<Input
type="number"
min={1}
max={table.getPageCount()}
defaultValue={table.getState().pagination.pageIndex + 1}
key={table.getState().pagination.pageIndex}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
const value = Number(
(e.target as HTMLInputElement).value,
);
if (!Number.isFinite(value)) return;
const page = Math.min(
Math.max(value, 1),
table.getPageCount(),
);
table.setPageIndex(page - 1);
}}
onBlur={(e) => {
const value = Number(e.target.value);
if (!Number.isFinite(value)) return;
const page = Math.min(
Math.max(value, 1),
table.getPageCount(),
);
table.setPageIndex(page - 1);
}}
className="w-16 h-8"
/>
</div>
<div className="flex gap-2">
<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>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -1,454 +0,0 @@
import {
AlertTriangle,
Cpu,
Download,
HardDrive,
Loader2,
Network,
RefreshCw,
Server as ServerIcon,
} from "lucide-react";
import { useState } from "react";
import { CodeEditor } from "@/components/shared/code-editor";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
interface Props {
serverId?: string;
}
const bytesToGb = (bytes: number) => (bytes / 1024 ** 3).toFixed(1);
const nanoCpusToCores = (nanoCpus: number) => (nanoCpus / 1e9).toFixed(2);
const pct = (used: number, total: number) =>
Math.min(100, Math.round((used / (total || 1)) * 100));
const SINCE_HOURS_OPTIONS = [
{ label: "Last hour", value: 1 },
{ label: "Last 6 hours", value: 6 },
{ label: "Last 24 hours", value: 24 },
{ label: "Last 7 days", value: 168 },
];
export const ShowHealth = ({ serverId }: Props) => {
const [sinceHours, setSinceHours] = useState(24);
const {
data: health,
isFetching,
refetch,
isFetched,
} = api.docker.getServerHealth.useQuery(
{ serverId, sinceHours },
{ refetchOnMount: false, refetchOnWindowFocus: false },
);
const memUsedPct = health
? pct(health.resources.memUsedBytes, health.resources.memTotalBytes)
: 0;
const diskUsedPct = health
? pct(health.disk.usedBytes, health.disk.totalBytes)
: 0;
const inotifyUsedPct = health
? pct(health.inotify.currentInstances, health.inotify.maxInstances)
: 0;
const daemonLogsWindowText = health?.daemonLogsWindow
? `Logs from ${new Date(health.daemonLogsWindow.fromEpoch * 1000).toLocaleString()} to ${new Date(health.daemonLogsWindow.toEpoch * 1000).toLocaleString()}`
: null;
const daemonErrorsText = [
daemonLogsWindowText,
health && health.daemonErrors.length > 0
? health.daemonErrors.join("\n")
: "(no daemon errors matched in this window)",
]
.filter(Boolean)
.join("\n\n");
const buildHealthReport = () => {
if (!health) return "";
const lines: string[] = [];
lines.push("# Dokploy server health report");
lines.push(`Generated: ${new Date(health.checkedAt).toLocaleString()}`);
lines.push(
`Window: ${SINCE_HOURS_OPTIONS.find((o) => o.value === sinceHours)?.label ?? `${sinceHours}h`}`,
);
lines.push("");
lines.push("## Containers & services");
lines.push(`Containers: ${health.containers.containerCount}`);
lines.push(`Swarm services: ${health.containers.serviceCount}`);
lines.push("");
lines.push("## Host resources");
lines.push(
`Memory: ${bytesToGb(health.resources.memUsedBytes)} / ${bytesToGb(health.resources.memTotalBytes)} GB`,
);
lines.push(`CPU cores: ${health.resources.cpuCount}`);
lines.push("");
lines.push("## Disk (/)");
lines.push(
`${bytesToGb(health.disk.usedBytes)} / ${bytesToGb(health.disk.totalBytes)} GB`,
);
lines.push("");
lines.push("## Inotify");
lines.push(
`max_user_instances: ${health.inotify.currentInstances} / ${health.inotify.maxInstances}`,
);
lines.push(`max_user_watches: ${health.inotify.maxWatches}`);
lines.push(`max_queued_events: ${health.inotify.maxQueuedEvents}`);
lines.push(
`Persisted in sysctl: ${health.inotify.persisted ? "yes" : "no"}`,
);
lines.push("");
lines.push("## Docker networks");
lines.push(`Total networks: ${health.dockerNetworks.count}`);
if (health.dockerNetworks.addressPools) {
lines.push(
`default-address-pools: ${JSON.stringify(health.dockerNetworks.addressPools)}`,
);
}
if (health.dockerNetworks.usageError) {
lines.push(
`Could not read network IP usage: ${health.dockerNetworks.usageError}`,
);
} else if (health.dockerNetworks.usage.length > 0) {
lines.push("");
lines.push(
"| Network | Driver | Subnet | IPs in use | Capacity | Usage |",
);
lines.push("|---|---|---|---|---|---|");
for (const n of health.dockerNetworks.usage) {
lines.push(
`| ${n.name} | ${n.driver} | ${n.subnet ?? "auto"} | ${n.containersInUse} | ${n.capacity ?? "—"} | ${n.percentUsed ?? "—"}% |`,
);
}
}
lines.push("");
if (health.reservation) {
lines.push("## Memory/CPU reservation");
lines.push(
`Reserved memory: ${bytesToGb(health.reservation.memoryReservedBytes)} GB`,
);
lines.push(
`Reserved CPU: ${nanoCpusToCores(health.reservation.cpuReservedNanoCpus)} cores`,
);
lines.push(`Across ${health.reservation.appCount} Application services`);
if (health.reservation.unsupportedComposeCount > 0) {
lines.push(
`${health.reservation.unsupportedComposeCount} Compose service(s) not included (not tracked per-service)`,
);
}
lines.push("");
}
lines.push("## Docker daemon errors (raw)");
lines.push("```");
lines.push(daemonErrorsText);
lines.push("```");
return lines.join("\n");
};
const handleDownload = () => {
const report = buildHealthReport();
if (!report) return;
const blob = new Blob([report], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `dokploy-health-report-${new Date().toISOString().replace(/[:.]/g, "-")}.md`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col gap-4 w-full">
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h3 className="text-lg font-medium">Server diagnostics</h3>
<p className="text-sm text-muted-foreground max-w-xl">
Runs a read-only check over SSH (inotify limits, disk, Docker
network pool, daemon errors). Runs automatically when you open
this tab — click Re-check to refresh.
</p>
</div>
<div className="flex items-center gap-2">
<Select
value={String(sinceHours)}
onValueChange={(v) => setSinceHours(Number(v))}
>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SINCE_HOURS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => refetch()} disabled={isFetching}>
{isFetching ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<RefreshCw className="size-4 mr-2" />
)}
{isFetched ? "Re-check" : "Check server health"}
</Button>
{health && !health.error && (
<Button variant="outline" onClick={handleDownload}>
<Download className="size-4 mr-2" />
Download report
</Button>
)}
</div>
</div>
{isFetching && !health && (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground border border-dashed rounded-xl">
<Loader2 className="size-8 animate-spin" />
<span>Checking server health…</span>
</div>
)}
{health?.error && (
<div className="text-sm text-destructive border border-destructive/30 bg-destructive/10 rounded-lg p-3">
Couldn't read server health: {health.error}
</div>
)}
{health && !health.error && (
<div
className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 transition-opacity ${isFetching ? "opacity-50" : ""}`}
>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<ServerIcon className="size-4 text-muted-foreground" />
Containers &amp; services
</div>
<div className="text-2xl font-semibold">
{health.containers.containerCount}
</div>
<div className="text-xs text-muted-foreground">
containers · {health.containers.serviceCount} swarm services
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<Cpu className="size-4 text-muted-foreground" />
Host resources
</div>
<div className="text-sm">
Memory: {bytesToGb(health.resources.memUsedBytes)} /{" "}
{bytesToGb(health.resources.memTotalBytes)} GB
</div>
<Progress value={memUsedPct} className="h-2 mt-1" />
<div className="text-xs text-muted-foreground mt-2">
{health.resources.cpuCount} CPU cores
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<HardDrive className="size-4 text-muted-foreground" />
Disk (/)
</div>
<div className="text-sm">
{bytesToGb(health.disk.usedBytes)} /{" "}
{bytesToGb(health.disk.totalBytes)} GB
</div>
<Progress value={diskUsedPct} className="h-2 mt-1" />
</Card>
<Card className="p-4">
<div className="flex items-center justify-between gap-2 text-sm font-medium mb-2">
<span className="flex items-center gap-2">
<AlertTriangle className="size-4 text-muted-foreground" />
Inotify
</span>
<Badge
variant={health.inotify.persisted ? "default" : "secondary"}
>
{health.inotify.persisted ? "persisted" : "runtime only"}
</Badge>
</div>
<div className="text-sm">
max_user_instances: {health.inotify.currentInstances} /{" "}
{health.inotify.maxInstances.toLocaleString()} (
{inotifyUsedPct}%)
</div>
<Progress value={inotifyUsedPct} className="h-2 mt-1" />
<div className="text-xs text-muted-foreground mt-2">
max_user_watches: {health.inotify.maxWatches.toLocaleString()}{" "}
· max_queued_events:{" "}
{health.inotify.maxQueuedEvents.toLocaleString()}
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<Network className="size-4 text-muted-foreground" />
Docker networks
</div>
<div className="text-2xl font-semibold">
{health.dockerNetworks.count}
</div>
<div className="text-xs text-muted-foreground">
The default Docker address pool is exhausted around ~30
networks unless <code>default-address-pools</code> is
configured in <code>/etc/docker/daemon.json</code>.
{health.dockerNetworks.addressPools
? " Custom pool detected on this server."
: ""}
</div>
</Card>
{health.reservation && (
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<Cpu className="size-4 text-muted-foreground" />
Memory/CPU reservation
</div>
<div className="text-sm">
{bytesToGb(health.reservation.memoryReservedBytes)} GB
reserved ·{" "}
{nanoCpusToCores(health.reservation.cpuReservedNanoCpus)}{" "}
CPUs reserved
</div>
<div className="text-xs text-muted-foreground mt-2">
Across {health.reservation.appCount} Application services.
{health.reservation.unsupportedComposeCount > 0
? ` ${health.reservation.unsupportedComposeCount} Compose service(s) on this server aren't included — reservations aren't tracked per-service for Compose.`
: ""}
</div>
</Card>
)}
</div>
)}
{health &&
!health.error &&
(health.dockerNetworks.usage.length > 0 ||
health.dockerNetworks.usageError) && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium flex items-center gap-2">
<Network className="size-4 text-muted-foreground" />
Network IP usage
</h4>
<p className="text-xs text-muted-foreground">
IPs assigned to containers vs. the subnet's usable capacity,
per network — including reserved networks like{" "}
<code>dokploy-network</code>. A network stuck near 100% blocks
new containers from starting even when the host has plenty of
other resources free.
</p>
{health.dockerNetworks.usageError ? (
<div className="text-sm text-destructive border border-destructive/30 bg-destructive/10 rounded-lg p-3">
Couldn't read network IP usage:{" "}
{health.dockerNetworks.usageError}
</div>
) : (
<div className="rounded-lg border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Network</TableHead>
<TableHead>Driver</TableHead>
<TableHead>Subnet</TableHead>
<TableHead>IPs in use</TableHead>
<TableHead>Usage</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{health.dockerNetworks.usage.map((n) => (
<TableRow key={n.name}>
<TableCell className="font-medium">
{n.name}
</TableCell>
<TableCell className="text-muted-foreground">
{n.driver}
</TableCell>
<TableCell className="text-muted-foreground">
{n.subnet ?? "Auto"}
</TableCell>
<TableCell className="text-muted-foreground">
{n.capacity !== null
? `${n.containersInUse} / ${n.capacity}`
: n.containersInUse}
</TableCell>
<TableCell>
{n.percentUsed !== null ? (
<Badge
variant={
n.percentUsed >= 90
? "red"
: n.percentUsed >= 70
? "yellow"
: "green"
}
>
{n.percentUsed}%
</Badge>
) : (
<span className="text-muted-foreground text-xs">
—
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
{health && !health.error && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">
Docker daemon errors (
{SINCE_HOURS_OPTIONS.find(
(o) => o.value === sinceHours,
)?.label.toLowerCase()}
)
</h4>
<CodeEditor
value={daemonErrorsText}
language="shell"
disabled
lineWrapping
wrapperClassName="h-64 rounded-lg border"
/>
</div>
)}
</div>
</Card>
</div>
);
};
@@ -1,470 +0,0 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import { ArrowUpDown, Eye, Layers, Loader2, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { DialogAction } from "@/components/shared/dialog-action";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
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 { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type ImageBase =
inferRouterOutputs<AppRouter>["dockerImage"]["getImages"][number];
type ImageRow = ImageBase & { key: string };
interface Props {
serverId?: string;
}
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1e3,
mb: 1e6,
gb: 1e9,
tb: 1e12,
};
const parseSize = (size?: string) => {
if (!size) return -1;
const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim());
if (!match?.[1] || !match[2]) return -1;
return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1);
};
const getReference = (image: ImageBase) =>
image.Repository !== "<none>" && image.Tag !== "<none>"
? `${image.Repository}:${image.Tag}`
: image.ID;
const FORCE_HINT_REGEX =
/must be forced|image is being used|referenced in multiple repositories/i;
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
const ShowImageConfig = ({
imageRef,
serverId,
}: {
imageRef: string;
serverId?: string;
}) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.dockerImage.getImageConfig.useQuery(
{ imageRef, serverId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View image config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Image Config</DialogTitle>
<DialogDescription>
docker image inspect output for "{imageRef}"
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap wrap-break-word">
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
)}
</DialogContent>
</Dialog>
);
};
export const ShowImages = ({ serverId }: Props) => {
const utils = api.useUtils();
const [sorting, setSorting] = useState<SortingState>([
{ id: "Repository", desc: false },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [forcePrompt, setForcePrompt] = useState<{
image: ImageRow;
message: string;
} | null>(null);
const { data: images, isLoading } = api.dockerImage.getImages.useQuery({
serverId,
});
const { mutateAsync: removeImage } =
api.dockerImage.removeImage.useMutation();
const rows = useMemo<ImageRow[]>(
() =>
(images ?? []).map((image) => ({
...image,
key: `${image.ID}-${image.Repository}-${image.Tag}`,
})),
[images],
);
const filteredData = useMemo(() => {
if (!globalFilter.trim()) {
return rows;
}
const query = globalFilter.toLowerCase();
return rows.filter(
(image) =>
image.Repository.toLowerCase().includes(query) ||
image.Tag.toLowerCase().includes(query) ||
image.ID.toLowerCase().includes(query),
);
}, [rows, globalFilter]);
const handleDelete = async (image: ImageRow, force = false) => {
try {
await removeImage({
repository: image.Repository,
tag: image.Tag,
id: image.ID,
force,
serverId,
});
toast.success("Image deleted");
setForcePrompt(null);
await utils.dockerImage.getImages.invalidate();
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (!force && FORCE_HINT_REGEX.test(message)) {
setForcePrompt({ image, message });
} else {
toast.error("Error deleting image", { description: message });
}
}
};
const columns = useMemo<ColumnDef<ImageRow>[]>(
() => [
{
accessorKey: "Repository",
header: ({ column }) => (
<SortableHeader column={column} title="Repository" />
),
cell: ({ row }) => (
<div
className="max-w-[280px] truncate font-medium"
title={row.original.Repository}
>
{row.original.Repository}
</div>
),
},
{
accessorKey: "Tag",
header: ({ column }) => <SortableHeader column={column} title="Tag" />,
cell: ({ row }) => <Badge variant="outline">{row.original.Tag}</Badge>,
},
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Image ID" />
),
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.ID}
</span>
),
},
{
id: "Size",
accessorFn: (image) => parseSize(image.Size),
header: ({ column }) => <SortableHeader column={column} title="Size" />,
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.Size}</span>
),
},
{
id: "Created",
accessorFn: (image) => new Date(image.CreatedAt).getTime() || 0,
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap">
{row.original.CreatedSince}
</span>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<ShowImageConfig
imageRef={getReference(row.original)}
serverId={serverId}
/>
<DialogAction
title="Delete image"
description={`The image "${getReference(row.original)}" will be removed from Docker. This action cannot be undone.`}
onClick={() => handleDelete(row.original)}
>
<Button variant="ghost" size="icon-sm" aria-label="Delete image">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[serverId],
);
const table = useReactTable({
data: filteredData,
columns,
getRowId: (row) => row.key,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Layers className="size-6 text-muted-foreground self-center" />
Images
</CardTitle>
<CardDescription>
Manage the Docker images of the selected server.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !images?.length ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<Layers className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No images found</p>
<p className="max-w-sm text-sm text-muted-foreground">
Docker images pulled or built on this server will appear
here.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by repository, tag or id..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<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}>
{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 text-muted-foreground"
>
No images match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<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>
)}
</>
)}
</CardContent>
</div>
</Card>
<AlertDialog
open={!!forcePrompt}
onOpenChange={(open) => !open && setForcePrompt(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Image is in use</AlertDialogTitle>
<AlertDialogDescription>
{forcePrompt?.message} Do you want to force the deletion? This may
affect containers still using this image.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setForcePrompt(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() =>
forcePrompt && handleDelete(forcePrompt.image, true)
}
>
Force delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
@@ -26,11 +26,6 @@ interface Props {
serviceId?: string;
}
// Sentinel the container-picker views fall back to before a real container
// is selected/auto-selected — querying logs for it just surfaces Docker's
// raw "No such container: select-a-container" daemon error.
const PLACEHOLDER_CONTAINER_ID = "select-a-container";
export const priorities = [
{
label: "Info",
@@ -60,16 +55,13 @@ export const DockerLogsId: React.FC<Props> = ({
runType,
serviceId,
}) => {
const hasContainer =
!!containerId && containerId !== PLACEHOLDER_CONTAINER_ID;
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId: serverId ?? undefined,
},
{
enabled: hasContainer,
enabled: !!containerId,
},
);
@@ -142,7 +134,7 @@ export const DockerLogsId: React.FC<Props> = ({
};
useEffect(() => {
if (!hasContainer) return;
if (!containerId) return;
let isCurrentConnection = true;
let noDataTimeout: NodeJS.Timeout;
@@ -433,15 +425,10 @@ export const DockerLogsId: React.FC<Props> = ({
<div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : hasContainer ? (
) : (
<div className="flex justify-center items-center h-full text-muted-foreground">
No logs found
</div>
) : (
<div className="flex justify-center items-center h-full text-center text-sm text-muted-foreground px-8">
Select a container above to view its logs. If none are listed,
make sure the service is deployed and running.
</div>
)}
</div>
</div>
@@ -7,28 +7,6 @@ export interface LogLine {
message: string;
}
interface ContainerOption {
containerId: string;
}
export const resolveContainerSelection = (
currentContainerId: string | undefined,
containers: readonly ContainerOption[] | undefined,
) => {
if (!containers) {
return currentContainerId;
}
if (
currentContainerId &&
containers.some(({ containerId }) => containerId === currentContainerId)
) {
return currentContainerId;
}
return containers[0]?.containerId;
};
interface LogStyle {
type: LogType;
variant: LogVariant;
@@ -3,7 +3,6 @@ import React, { useEffect, useRef } from "react";
import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { useTheme } from "next-themes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
@@ -15,10 +14,6 @@ interface Props {
serviceId?: string;
}
// Sentinel the container-picker modal renders with before a real container
// is selected/auto-selected — never worth opening a connection for.
const PLACEHOLDER_CONTAINER_ID = "select-a-container";
export const DockerTerminal: React.FC<Props> = ({
id,
containerId,
@@ -29,105 +24,56 @@ export const DockerTerminal: React.FC<Props> = ({
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
const { resolvedTheme } = useTheme();
useEffect(() => {
if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) {
return;
const container = document.getElementById(id);
if (container) {
container.innerHTML = "";
}
let cancelled = false;
let term: Terminal | null = null;
let ws: WebSocket | null = null;
let resizeObserver: ResizeObserver | null = null;
// Deferred a frame so React StrictMode's dev-only phantom mount+cleanup
// (which runs synchronously, before anything paints) never creates a
// terminal in the first place — dodges a known xterm.js dispose race
// (xtermjs/xterm.js#5011) where a deferred internal callback outlives
// term.dispose() and reads renderer state that's already gone.
const frame = requestAnimationFrame(() => {
if (cancelled) return;
const container = document.getElementById(id);
if (container) {
container.innerHTML = "";
}
term = new Terminal({
cursorBlink: true,
lineHeight: 1.4,
convertEol: true,
theme: {
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
background: "rgba(0, 0, 0, 0)",
foreground: "currentColor",
},
});
const addonFit = new FitAddon();
const clipboardAddon = new ClipboardAddon();
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-expect-error
term.open(termRef.current);
term.loadAddon(addonFit);
addonFit.fit();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}&cols=${term.cols}&rows=${term.rows}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
term.loadAddon(addonAttach);
const sendResize = (cols: number, rows: number) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
};
term.onResize(({ cols, rows }) => sendResize(cols, rows));
resizeObserver = new ResizeObserver(() => addonFit.fit());
if (termRef.current) {
resizeObserver.observe(termRef.current);
}
const term = new Terminal({
cursorBlink: true,
lineHeight: 1.4,
convertEol: true,
theme: {
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
background: "rgba(0, 0, 0, 0)",
foreground: "currentColor",
},
});
const addonFit = new FitAddon();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
fixMacOsAltKeys(term);
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
term.loadAddon(addonAttach);
addonFit.fit();
return () => {
cancelled = true;
cancelAnimationFrame(frame);
resizeObserver?.disconnect();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
term?.dispose();
ws.readyState === WebSocket.OPEN && ws.close();
};
}, [containerId, activeWay, id]);
const hasContainer =
!!containerId && containerId !== PLACEHOLDER_CONTAINER_ID;
return (
<div className="flex flex-col gap-4">
{hasContainer && (
<div className="flex flex-col gap-2 mt-4">
<span>
Select way to connect to <b>{containerId}</b>
</span>
<Tabs value={activeWay} onValueChange={setActiveWay}>
<TabsList>
<TabsTrigger value="bash">Bash</TabsTrigger>
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
{hasContainer ? (
<div className="w-full h-[420px] rounded-lg p-2 bg-transparent border">
<div id={id} ref={termRef} className="h-full" />
</div>
) : (
<div className="flex h-[420px] w-full items-center justify-center rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
Select a container above to open a terminal. If none are listed, make
sure the service is deployed and running.
</div>
)}
<div className="flex flex-col gap-2 mt-4">
<span>
Select way to connect to <b>{containerId}</b>
</span>
<Tabs value={activeWay} onValueChange={setActiveWay}>
<TabsList>
<TabsTrigger value="bash">Bash</TabsTrigger>
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="w-full h-full rounded-lg p-2 bg-transparent border">
<div id={id} ref={termRef} />
</div>
</div>
);
};
@@ -223,7 +223,7 @@ export const ShowHome = () => {
</div>
{canReadDeployments && (
<Link
href="/dashboard/overview?tab=deployments"
href="/dashboard/deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all →
@@ -14,6 +14,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
@@ -102,19 +103,16 @@ export function AddOrganization({ organizationId }: Props) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{organizationId ? (
<Button
type="button"
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
title="Edit organization"
<DropdownMenuItem
className="group cursor-pointer hover:bg-blue-500/10"
onSelect={(e) => e.preventDefault()}
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DropdownMenuItem>
) : (
<button
type="button"
className="flex w-full items-center gap-2 rounded-md p-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
<DropdownMenuItem
className="gap-2 p-2"
onSelect={(e) => e.preventDefault()}
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
@@ -122,7 +120,7 @@ export function AddOrganization({ organizationId }: Props) {
<div className="font-medium text-muted-foreground">
Add organization
</div>
</button>
</DropdownMenuItem>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
@@ -1,254 +0,0 @@
import type { OverviewBackup } from "@dokploy/server/services/overview-shared";
import { getBackupOverviewIcon } from "@dokploy/server/services/overview-shared";
import {
ArrowUpDown,
CircuitBoard,
GlobeIcon,
Loader2,
RefreshCw,
ServerIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const STATUS_VARIANT: Record<string, "default" | "destructive" | "secondary"> =
{
done: "default",
running: "secondary",
error: "destructive",
cancelled: "destructive",
};
const renderRowIcon = (row: OverviewBackup) => {
const icon = getBackupOverviewIcon(row);
if (icon.kind === "db") {
const Icon = DB_ENGINE_ICONS[icon.engine as keyof typeof DB_ENGINE_ICONS];
return Icon ? <Icon className="h-5 w-5" /> : null;
}
if (icon.kind === "webServer") {
return <ServerIcon className="h-5 w-5 text-muted-foreground" />;
}
return icon.type === "compose" ? (
<CircuitBoard className="h-5 w-5" />
) : (
<GlobeIcon className="h-5 w-5" />
);
};
const detailHref = (row: OverviewBackup) => {
if (!row.projectId || !row.environmentId || !row.serviceOwnerId) return null;
return `/dashboard/project/${row.projectId}/environment/${row.environmentId}/services/${row.serviceOwnerType}/${row.serviceOwnerId}`;
};
export const ShowOverviewBackups = () => {
const {
data: backups,
isFetching,
isFetched,
isError,
refetch,
} = api.overview.backups.useQuery(undefined, {
staleTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
});
const [destinationId, setDestinationId] = useState("all");
const [serviceOwnerId, setServiceOwnerId] = useState("all");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const destinations = useMemo(() => {
if (!backups) return [];
const map = new Map<string, string>();
for (const row of backups) map.set(row.destinationId, row.destinationName);
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [backups]);
const services = useMemo(() => {
if (!backups) return [];
const map = new Map<string, string>();
for (const row of backups) {
if (row.serviceOwnerId) map.set(row.serviceOwnerId, row.serviceName);
}
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [backups]);
const rows = useMemo(() => {
if (!backups) return [];
const filtered = backups.filter(
(row) =>
(destinationId === "all" || row.destinationId === destinationId) &&
(serviceOwnerId === "all" || row.serviceOwnerId === serviceOwnerId),
);
const sorted = [...filtered].sort(
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
return sortDirection === "asc" ? sorted : sorted.reverse();
}, [backups, destinationId, serviceOwnerId, sortDirection]);
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Backups{" "}
{isFetched && (
<span className="text-sm font-normal text-muted-foreground">
({rows.length})
</span>
)}
</h3>
<div className="flex flex-wrap items-center gap-2">
{isFetched && (
<>
<Select value={destinationId} onValueChange={setDestinationId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Destination" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All destinations</SelectItem>
{destinations.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={serviceOwnerId}
onValueChange={setServiceOwnerId}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Service" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All services</SelectItem>
{services.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() =>
setSortDirection((d) => (d === "desc" ? "asc" : "desc"))
}
>
<ArrowUpDown className="size-4 mr-2" />
{sortDirection === "desc" ? "Newest first" : "Oldest first"}
</Button>
</>
)}
<Button onClick={() => refetch()} disabled={isFetching} size="sm">
{isFetching ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<RefreshCw className="size-4 mr-2" />
)}
Refresh
</Button>
</div>
</div>
{isFetching && !isFetched && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading backups...
</div>
)}
{isFetched && isError && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>Failed to load backups.</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
</Button>
</div>
)}
{isFetched && !isError && rows.length === 0 && (
<div className="flex items-center justify-center py-16 text-muted-foreground">
No backups match the current filters.
</div>
)}
{isFetched && !isError && rows.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Service</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Kind</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const href = detailHref(row);
const name = href ? (
<Link href={href} className="hover:underline">
{row.serviceName}
</Link>
) : (
row.serviceName
);
return (
<TableRow key={row.deploymentId}>
<TableCell className="whitespace-nowrap text-sm">
{new Date(row.createdAt).toLocaleString()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{renderRowIcon(row)}
{name}
</div>
</TableCell>
<TableCell>{row.destinationName}</TableCell>
<TableCell>
<Badge variant="outline">
{row.kind === "backup" ? "Database" : "Volume"}
</Badge>
</TableCell>
<TableCell>
{row.status && (
<Badge
variant={STATUS_VARIANT[row.status] ?? "secondary"}
>
{row.status}
</Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</Card>
);
};
@@ -1,77 +0,0 @@
import { Rocket } from "lucide-react";
import { useRouter } from "next/router";
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const SUBTAB_VALUES = ["deployments", "queue"] as const;
type SubtabValue = (typeof SUBTAB_VALUES)[number];
const DEFAULT_SUBTAB: SubtabValue = "deployments";
function isValidSubtab(t: string): t is SubtabValue {
return SUBTAB_VALUES.includes(t as SubtabValue);
}
export const ShowOverviewDeployments = () => {
const router = useRouter();
const subtab =
typeof router.query.subtab === "string" &&
isValidSubtab(router.query.subtab)
? router.query.subtab
: DEFAULT_SUBTAB;
const setSubtab = (value: string) => {
if (!isValidSubtab(value)) return;
const { subtab: _current, ...query } = router.query;
router.replace(
{
pathname: router.pathname,
query: value === DEFAULT_SUBTAB ? query : { ...query, subtab: value },
},
undefined,
{ shallow: true },
);
};
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
<Rocket className="size-5" />
Deployments
</CardTitle>
<CardDescription>
All application and compose deployments in one place.
</CardDescription>
</div>
</div>
<Tabs
value={subtab}
onValueChange={setSubtab}
className="w-full min-w-0"
>
<TabsList className="mt-2">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
<ShowDeploymentsTable />
</TabsContent>
<TabsContent value="queue" className="mt-0 pt-4">
<ShowQueueTable />
</TabsContent>
</Tabs>
</CardHeader>
</div>
</Card>
);
};
@@ -1,383 +0,0 @@
import type { OverviewDomainSortBy } from "@dokploy/server/services/overview-shared";
import { sortOverviewDomains } from "@dokploy/server/services/overview-shared";
import { ExternalLink, Loader2 } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { COMPOSE_REDEPLOY_TOAST } from "@/components/dashboard/application/domains/redeploy-hint";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useSortPreference } from "@/hooks/use-sort-preference";
import { api } from "@/utils/api";
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
const SORT_OPTIONS: { value: OverviewDomainSortBy; label: string }[] = [
{ value: "createdAt-desc", label: "Newest first" },
{ value: "createdAt-asc", label: "Oldest first" },
{ value: "port-asc", label: "Port (low-high)" },
{ value: "port-desc", label: "Port (high-low)" },
];
const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value);
export const ShowOverviewDomains = () => {
const utils = api.useUtils();
const { data: permissions } = api.user.getPermissions.useQuery();
const canToggleDomain = permissions?.domain.create ?? false;
const { data: domains, isLoading } = api.overview.domains.useQuery();
const { data: allProjects } = api.project.all.useQuery();
const { mutateAsync: toggleEnable, isPending: isToggling } =
api.domain.toggleEnable.useMutation();
const [selectedProjectId, setSelectedProjectId] = useState("all");
const [selectedStatus, setSelectedStatus] = useState("all");
const [selectedSsl, setSelectedSsl] = useState("all");
const [selectedPort, setSelectedPort] = useState("all");
const [sortBy, setSort] = useSortPreference<OverviewDomainSortBy>(
"overviewDomainsSort",
"createdAt-desc",
SORT_VALUES,
);
const handleToggleEnable = async (
domain: NonNullable<typeof domains>[number],
) => {
try {
const result = await toggleEnable({ domainId: domain.domainId });
utils.overview.domains.invalidate();
toast.success(
result.enabled ? "Domain enabled" : "Domain disabled",
result.requiresRedeploy
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
} catch {
toast.error(`Error updating "${domain.host}"`);
}
};
const availablePorts = useMemo(() => {
if (!domains) return [];
const ports = new Set<number>();
for (const domain of domains) {
if (domain.port !== null) ports.add(domain.port);
}
return Array.from(ports).sort((a, b) => a - b);
}, [domains]);
const filteredDomains = useMemo(() => {
if (!domains) return [];
const filtered = domains.filter(
(domain) =>
(selectedProjectId === "all" ||
domain.projectId === selectedProjectId) &&
(selectedStatus === "all" ||
(selectedStatus === "enabled" ? domain.enabled : !domain.enabled)) &&
(selectedSsl === "all" ||
(selectedSsl === "https" ? domain.https : !domain.https)) &&
(selectedPort === "all" || domain.port === Number(selectedPort)),
);
return sortOverviewDomains(filtered, sortBy);
}, [
domains,
selectedProjectId,
selectedStatus,
selectedSsl,
selectedPort,
sortBy,
]);
const [pageSize, setPageSize] = useState(50);
const [pageIndex, setPageIndex] = useState(0);
const pageCount = Math.max(1, Math.ceil(filteredDomains.length / pageSize));
const currentPageIndex = Math.min(pageIndex, pageCount - 1);
const pagedDomains = filteredDomains.slice(
currentPageIndex * pageSize,
currentPageIndex * pageSize + pageSize,
);
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Domains{" "}
<span className="text-sm font-normal text-muted-foreground">
({filteredDomains.length})
</span>
</h3>
<div className="flex flex-wrap items-center gap-2">
<Select
value={selectedProjectId}
onValueChange={setSelectedProjectId}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{allProjects?.map((project) => (
<SelectItem key={project.projectId} value={project.projectId}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
<Select value={selectedSsl} onValueChange={setSelectedSsl}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="SSL" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">HTTP & HTTPS</SelectItem>
<SelectItem value="https">HTTPS only</SelectItem>
<SelectItem value="http">HTTP only</SelectItem>
</SelectContent>
</Select>
{availablePorts.length > 0 && (
<Select value={selectedPort} onValueChange={setSelectedPort}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Port" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All ports</SelectItem>
{availablePorts.map((port) => (
<SelectItem key={port} value={String(port)}>
{port}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select
value={sortBy}
onValueChange={(v) => setSort(v as OverviewDomainSortBy)}
>
<SelectTrigger className="w-[170px]">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading domains...
</div>
)}
{!isLoading && filteredDomains.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>No domains match the current filters.</span>
</div>
)}
{!isLoading && filteredDomains.length > 0 && (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Host</TableHead>
<TableHead>Path</TableHead>
<TableHead>Port</TableHead>
<TableHead>Entrypoint</TableHead>
<TableHead>Protocol</TableHead>
<TableHead>Certificate</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedDomains.map((domain) => {
const href = `/dashboard/project/${domain.projectId}/environment/${domain.environmentId}/services/${domain.serviceOwnerType}/${domain.serviceOwnerId}`;
return (
<TableRow key={domain.domainId}>
<TableCell>
<Link href={href} className="flex flex-col min-w-0">
<span className="font-medium truncate">
{domain.serviceName}
</span>
<span className="text-xs font-normal text-muted-foreground">
{domain.projectName} / {domain.environmentName}
</span>
</Link>
</TableCell>
<TableCell>
<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>
</TableCell>
<TableCell>
<span className="font-mono text-sm">
{domain.path || "/"}
</span>
</TableCell>
<TableCell>
<Badge variant="secondary">{domain.port}</Badge>
</TableCell>
<TableCell>
{domain.customEntrypoint ? (
<span className="font-mono text-sm">
{domain.customEntrypoint}
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>
<Badge variant={domain.https ? "outline" : "secondary"}>
{domain.https ? "HTTPS" : "HTTP"}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{domain.certificateType}
</Badge>
</TableCell>
<TableCell>
<DateTooltip date={domain.createdAt} />
</TableCell>
<TableCell className="text-right">
{canToggleDomain ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-end">
<Switch
checked={domain.enabled}
onCheckedChange={() =>
handleToggleEnable(domain)
}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{domain.enabled
? "Domain is active. Toggle to disable routing without deleting it."
: "Domain is disabled and not routed. Toggle to enable it again."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge
variant={domain.enabled ? "outline" : "secondary"}
>
{domain.enabled ? "Enabled" : "Disabled"}
</Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredDomains.length}{" "}
{filteredDomains.length === 1 ? "domain" : "domains"} total
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value));
setPageIndex(0);
}}
>
<SelectTrigger className="w-[80px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="whitespace-nowrap">
Page {currentPageIndex + 1} of {pageCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex - 1)}
disabled={currentPageIndex === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex + 1)}
disabled={currentPageIndex + 1 >= pageCount}
>
Next
</Button>
</div>
</div>
</div>
</>
)}
</div>
</Card>
);
};
@@ -1,550 +0,0 @@
import type {
OverviewServiceType,
OverviewSortBy,
} from "@dokploy/server/services/overview-shared";
import { sortOverviewServices } from "@dokploy/server/services/overview-shared";
import {
Ban,
CircuitBoard,
GlobeIcon,
Loader2,
MoreHorizontal,
RefreshCw,
Search,
ServerIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useSortPreference } from "@/hooks/use-sort-preference";
import { api } from "@/utils/api";
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
const TYPE_LABELS = {
application: "Application",
postgres: "PostgreSQL",
mariadb: "MariaDB",
mongo: "MongoDB",
mysql: "MySQL",
redis: "Redis",
compose: "Compose",
libsql: "Libsql",
} satisfies Record<OverviewServiceType, string>;
const STATUS_OPTIONS = ["running", "idle", "done", "error"];
// "done" is the steady live state (green); "running" only holds mid-deploy (yellow) — relabeled to match what users expect.
const STATUS_LABELS: Record<string, string> = {
running: "Deploying",
idle: "Idle",
done: "Running",
error: "Error",
};
const SORT_OPTIONS: { value: OverviewSortBy; label: string }[] = [
{ value: "lastDeploy-desc", label: "Recently deployed" },
{ value: "lastDeploy-asc", label: "Oldest deployed" },
{ value: "createdAt-desc", label: "Newest first" },
{ value: "createdAt-asc", label: "Oldest first" },
{ value: "name-asc", label: "Name (A-Z)" },
{ value: "name-desc", label: "Name (Z-A)" },
{ value: "type-asc", label: "Type (A-Z)" },
{ value: "type-desc", label: "Type (Z-A)" },
];
const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value);
// libsql has no deploy/stop actions, same as environment/[environmentId].tsx.
const idKeyByType: Record<string, string> = {
application: "applicationId",
compose: "composeId",
postgres: "postgresId",
mysql: "mysqlId",
mariadb: "mariadbId",
redis: "redisId",
mongo: "mongoId",
};
export const ShowOverviewServices = () => {
const utils = api.useUtils();
const {
data: services,
isLoading,
refetch,
} = api.overview.services.useQuery();
const { data: allProjects } = api.project.all.useQuery();
const [searchQuery, setSearchQuery] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState("all");
const [selectedType, setSelectedType] = useState("all");
const [selectedStatus, setSelectedStatus] = useState("all");
const [selectedServerId, setSelectedServerId] = useState("all");
const [sortBy, setSort] = useSortPreference<OverviewSortBy>(
"overviewServicesSort",
"lastDeploy-desc",
SORT_VALUES,
);
const applicationActions = {
deploy: api.application.deploy.useMutation(),
stop: api.application.stop.useMutation(),
};
const composeActions = {
deploy: api.compose.deploy.useMutation(),
stop: api.compose.stop.useMutation(),
};
const postgresActions = {
deploy: api.postgres.deploy.useMutation(),
stop: api.postgres.stop.useMutation(),
};
const mysqlActions = {
deploy: api.mysql.deploy.useMutation(),
stop: api.mysql.stop.useMutation(),
};
const mariadbActions = {
deploy: api.mariadb.deploy.useMutation(),
stop: api.mariadb.stop.useMutation(),
};
const redisActions = {
deploy: api.redis.deploy.useMutation(),
stop: api.redis.stop.useMutation(),
};
const mongoActions = {
deploy: api.mongo.deploy.useMutation(),
stop: api.mongo.stop.useMutation(),
};
const actionsByType: Record<
string,
{
deploy: { mutateAsync: (input: any) => Promise<unknown> };
stop: { mutateAsync: (input: any) => Promise<unknown> };
}
> = {
application: applicationActions,
compose: composeActions,
postgres: postgresActions,
mysql: mysqlActions,
mariadb: mariadbActions,
redis: redisActions,
mongo: mongoActions,
};
const handleAction = async (
service: NonNullable<typeof services>[number],
action: "deploy" | "stop",
) => {
const actions = actionsByType[service.type];
const idKey = idKeyByType[service.type];
if (!actions || !idKey) return;
const labels =
action === "deploy"
? {
loading: "Deploying",
success: "queued for deployment",
error: "deploying",
}
: { loading: "Stopping", success: "stopped", error: "stopping" };
toast.promise(
(async () => {
await actions[action].mutateAsync({ [idKey]: service.id });
})(),
{
loading: `${labels.loading} ${service.name}...`,
success: () => {
utils.overview.services.invalidate();
return `${service.name} ${labels.success} successfully`;
},
error: (error) =>
`Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
},
);
};
const availableServers = useMemo(() => {
if (!services) return [];
const map = new Map<string, string>();
for (const service of services) {
if (service.serverId && service.serverName) {
map.set(service.serverId, service.serverName);
}
}
return Array.from(map.entries()).map(([serverId, serverName]) => ({
serverId,
serverName,
}));
}, [services]);
const hasServicesWithoutServer = useMemo(
() => !!services?.some((service) => !service.serverId),
[services],
);
const filteredServices = useMemo(() => {
if (!services) return [];
const filtered = services.filter(
(service) =>
service.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
(selectedProjectId === "all" ||
service.projectId === selectedProjectId) &&
(selectedType === "all" || service.type === selectedType) &&
(selectedStatus === "all" || service.status === selectedStatus) &&
(selectedServerId === "all" ||
(selectedServerId === "dokploy-server" && !service.serverId) ||
service.serverId === selectedServerId),
);
return sortOverviewServices(filtered, sortBy);
}, [
services,
searchQuery,
selectedProjectId,
selectedType,
selectedStatus,
selectedServerId,
sortBy,
]);
// pageIndex is clamped against the filtered count each render instead of reset via an effect.
const [pageSize, setPageSize] = useState(50);
const [pageIndex, setPageIndex] = useState(0);
const pageCount = Math.max(1, Math.ceil(filteredServices.length / pageSize));
const currentPageIndex = Math.min(pageIndex, pageCount - 1);
const pagedServices = filteredServices.slice(
currentPageIndex * pageSize,
currentPageIndex * pageSize + pageSize,
);
const renderIcon = (service: NonNullable<typeof services>[number]) => {
if (service.type in DB_ENGINE_ICONS) {
const Icon =
DB_ENGINE_ICONS[service.type as keyof typeof DB_ENGINE_ICONS];
return <Icon className="h-5 w-5" />;
}
if (service.icon) {
return (
<img
src={service.icon}
alt={service.name}
className="size-5 object-contain rounded-sm"
/>
);
}
return service.type === "compose" ? (
<CircuitBoard className="h-5 w-5" />
) : (
<GlobeIcon className="h-5 w-5" />
);
};
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Services{" "}
<span className="text-sm font-normal text-muted-foreground">
({filteredServices.length})
</span>
</h3>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Input
placeholder="Filter services..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-9 w-[200px]"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<Select
value={selectedProjectId}
onValueChange={setSelectedProjectId}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{allProjects?.map((project) => (
<SelectItem key={project.projectId} value={project.projectId}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{Object.entries(TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{STATUS_OPTIONS.map((status) => (
<SelectItem key={status} value={status}>
{STATUS_LABELS[status] ?? status}
</SelectItem>
))}
</SelectContent>
</Select>
{(availableServers.length > 0 || hasServicesWithoutServer) && (
<Select
value={selectedServerId}
onValueChange={setSelectedServerId}
>
<SelectTrigger className="w-[170px]">
<SelectValue placeholder="Server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All servers</SelectItem>
{hasServicesWithoutServer && (
<SelectItem value="dokploy-server">
Dokploy server
</SelectItem>
)}
{availableServers.map((s) => (
<SelectItem key={s.serverId} value={s.serverId}>
{s.serverName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select
value={sortBy}
onValueChange={(v) => setSort(v as OverviewSortBy)}
>
<SelectTrigger className="w-[190px]">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading services...
</div>
)}
{!isLoading && filteredServices.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>No services match the current filters.</span>
</div>
)}
{!isLoading && filteredServices.length > 0 && (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Server</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last Deploy</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedServices.map((service) => {
const href = `/dashboard/project/${service.projectId}/environment/${service.environmentId}/services/${service.type}/${service.id}`;
const hasActions = service.type in actionsByType;
return (
<TableRow key={service.id}>
<TableCell>
<Link href={href} className="flex items-center gap-2">
{renderIcon(service)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">
{service.name}
</span>
<span className="text-xs font-normal text-muted-foreground">
{service.projectName} / {service.environmentName}
</span>
</div>
</Link>
</TableCell>
<TableCell>{TYPE_LABELS[service.type]}</TableCell>
<TableCell>
<StatusTooltip
status={service.status as any}
className="size-2.5"
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 text-muted-foreground">
<ServerIcon className="size-3.5" />
<span className="truncate">
{service.serverName ?? "Dokploy server"}
</span>
</div>
</TableCell>
<TableCell>
<DateTooltip date={service.createdAt} />
</TableCell>
<TableCell>
{service.lastDeployAt ? (
<DateTooltip date={service.lastDeployAt} />
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-right">
{hasActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel className="truncate">
{service.name}
</DropdownMenuLabel>
<DialogAction
title="Deploy Service"
description={`Are you sure you want to deploy "${service.name}"?`}
type="default"
onClick={() => handleAction(service, "deploy")}
>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={(e) => e.preventDefault()}
>
<RefreshCw className="size-4" />
Deploy
</DropdownMenuItem>
</DialogAction>
<DialogAction
title="Stop Service"
description={`Are you sure you want to stop "${service.name}"?`}
onClick={() => handleAction(service, "stop")}
>
<DropdownMenuItem
className="flex items-center gap-2 text-orange-500 focus:text-orange-500"
onSelect={(e) => e.preventDefault()}
>
<Ban className="size-4" />
Stop
</DropdownMenuItem>
</DialogAction>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredServices.length}{" "}
{filteredServices.length === 1 ? "service" : "services"} total
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value));
setPageIndex(0);
}}
>
<SelectTrigger className="w-[80px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="whitespace-nowrap">
Page {currentPageIndex + 1} of {pageCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex - 1)}
disabled={currentPageIndex === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex + 1)}
disabled={currentPageIndex + 1 >= pageCount}
>
Next
</Button>
</div>
</div>
</div>
</>
)}
</div>
</Card>
);
};
@@ -175,7 +175,7 @@ export const SearchCommand = () => {
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/overview?tab=deployments");
router.push("/dashboard/deployments");
setOpen(false);
}}
>
@@ -52,14 +52,13 @@ export const ShowNodes = ({ serverId }: Props) => {
serverId,
});
const { data: registry } = api.registry.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation();
const haveAtLeastOneRegistry = !!(registry && registry?.length > 0);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
<div className="flex flex-col gap-2">
@@ -69,7 +68,7 @@ export const ShowNodes = ({ serverId }: Props) => {
</CardTitle>
<CardDescription>Add nodes to your cluster</CardDescription>
</div>
{haveAtLeastOneRegistry && permissions?.server.create && (
{haveAtLeastOneRegistry && (
<div className="flex flex-row gap-2">
<AddNode serverId={serverId} />
</div>
@@ -145,35 +144,34 @@ export const ShowNodes = ({ serverId }: Props) => {
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<ShowNodeData data={node} />
{!node?.ManagerStatus?.Leader &&
permissions?.server.delete && (
<DialogAction
title="Delete Node"
description="Are you sure you want to delete this node from the cluster?"
type="destructive"
onClick={async () => {
await deleteNode({
nodeId: node.ID,
serverId,
{!node?.ManagerStatus?.Leader && (
<DialogAction
title="Delete Node"
description="Are you sure you want to delete this node from the cluster?"
type="destructive"
onClick={async () => {
await deleteNode({
nodeId: node.ID,
serverId,
})
.then(() => {
refetch();
toast.success(
"Node deleted successfully",
);
})
.then(() => {
refetch();
toast.success(
"Node deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting node");
});
}}
.catch(() => {
toast.error("Error deleting node");
});
}}
>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
Delete
</DropdownMenuItem>
</DialogAction>
)}
Delete
</DropdownMenuItem>
</DialogAction>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -1,340 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const providerLabels = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
} as const;
type ProviderType = keyof typeof providerLabels;
const DnsProviderSchema = z.object({
name: z
.string()
.min(1, { message: "Name is required" })
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum(["cloudflare", "route53"]),
apiToken: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
const defaultValues: DnsProviderForm = {
name: "",
providerType: "cloudflare",
apiToken: "",
accessKeyId: "",
secretAccessKey: "",
};
const buildConfig = (data: DnsProviderForm) => {
switch (data.providerType) {
case "cloudflare":
return {
providerType: "cloudflare" as const,
apiToken: data.apiToken,
};
case "route53":
return {
providerType: "route53" as const,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
};
}
};
const extractErrorMessage = (err: unknown) => {
if (!(err instanceof Error)) return undefined;
try {
const issues = JSON.parse(err.message) as { message?: string }[];
if (Array.isArray(issues)) {
return issues
.map((issue) => issue.message)
.filter(Boolean)
.join(", ");
}
} catch {}
return err.message;
};
interface Props {
dnsProviderId?: string;
}
export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: provider } = api.dnsProvider.one.useQuery(
{ dnsProviderId: dnsProviderId || "" },
{ enabled: !!dnsProviderId && isOpen },
);
const { mutateAsync, isPending, error, isError } = dnsProviderId
? api.dnsProvider.update.useMutation()
: api.dnsProvider.create.useMutation();
const { mutateAsync: testConnection, isPending: isTesting } =
api.dnsProvider.testConnection.useMutation();
const form = useForm<DnsProviderForm>({
defaultValues,
resolver: zodResolver(DnsProviderSchema),
});
const providerType = form.watch("providerType");
useEffect(() => {
if (provider) {
form.reset({
...defaultValues,
name: provider.name,
providerType: provider.config.providerType,
...(provider.config.providerType === "cloudflare" && {
apiToken: provider.config.apiToken,
}),
...(provider.config.providerType === "route53" && {
accessKeyId: provider.config.accessKeyId,
secretAccessKey: provider.config.secretAccessKey,
}),
});
} else if (!dnsProviderId) {
form.reset(defaultValues);
}
}, [provider, dnsProviderId, form, isOpen]);
const onSubmit = async (data: DnsProviderForm) => {
const payload: any = {
name: data.name,
config: buildConfig(data),
...(dnsProviderId && { dnsProviderId }),
};
await mutateAsync(payload)
.then(() => {
toast.success(
dnsProviderId ? "DNS provider updated" : "DNS provider created",
);
utils.dnsProvider.all.invalidate();
setIsOpen(false);
})
.catch(() => {});
};
const onTestConnection = async () => {
const isValid = await form.trigger();
if (!isValid) {
return;
}
const data = form.getValues();
await testConnection({
config: buildConfig(data),
...(dnsProviderId && { dnsProviderId }),
})
.then(() => {
toast.success("Connection successful");
})
.catch((err) => {
toast.error("Connection failed", {
description: extractErrorMessage(err),
});
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{dnsProviderId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-4 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add Provider
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{dnsProviderId ? "Update DNS Provider" : "Add DNS Provider"}
</DialogTitle>
<DialogDescription>
Connect a DNS provider to create records for your domains
automatically instead of setting them up by hand.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="prod-cloudflare" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="providerType"
render={({ field }) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={!!dnsProviderId}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(providerLabels).map(([value, label]) => {
const ProviderIcon =
dnsProviderIcons[value as ProviderType];
return (
<SelectItem key={value} value={value}>
<div className="flex flex-row items-center gap-2">
<ProviderIcon className="size-4 shrink-0" />
{label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{providerType === "cloudflare" && (
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create a token scoped to Zone → DNS → Edit for the zones
you want Dokploy to manage. Avoid the Global API Key.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{providerType === "route53" && (
<>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Use an IAM user/role scoped to{" "}
<code>route53:ListHostedZones</code>,{" "}
<code>route53:ListResourceRecordSets</code> and{" "}
<code>route53:ChangeResourceRecordSets</code> — avoid
root account credentials.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
variant="secondary"
isLoading={isTesting}
onClick={onTestConnection}
>
Test Connection
</Button>
<Button type="submit" isLoading={isPending}>
{dnsProviderId ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,271 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const DnsRecordSchema = z.object({
type: z.enum(["A", "CNAME"]),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
});
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
interface DnsRecordValue {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}
interface Props {
dnsProviderId: string;
zoneId: string;
zoneName: string;
record?: DnsRecordValue;
}
export const HandleDnsRecord = ({
dnsProviderId,
zoneId,
zoneName,
record,
}: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isPending, error, isError } = record
? api.dnsProvider.updateRecord.useMutation()
: api.dnsProvider.createRecord.useMutation();
const { data: servers } = api.server.all.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, {
enabled: isOpen,
});
const panelIp = panelPublicIp || panelStoredIp;
const ipSuggestions = [
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
...(servers ?? []).map((server) => ({
ip: server.ipAddress,
label: server.name,
})),
].filter(
(suggestion, index, all) =>
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
);
const form = useForm<DnsRecordForm>({
defaultValues: record
? {
type: record.type === "CNAME" ? "CNAME" : "A",
name: record.name,
content: record.content,
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
}
: { type: "A", name: "", content: "", ttl: "" },
resolver: zodResolver(DnsRecordSchema),
});
const type = form.watch("type");
const onSubmit = async (data: DnsRecordForm) => {
const name = data.name.trim() === "@" ? zoneName : data.name;
const payload = {
dnsProviderId,
zoneId,
type: data.type,
name,
content: data.content,
ttl: data.ttl ? Number(data.ttl) : undefined,
...(record && { recordId: record.id }),
};
await mutateAsync(payload as any)
.then(() => {
toast.success(record ? "Record updated" : "Record created");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
setIsOpen(false);
})
.catch(() => {});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{record ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 size-6"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button variant="outline" size="sm">
<PlusIcon className="size-3.5 mr-1.5" />
Add Record
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{record ? "Edit Record" : "Add Record"}</DialogTitle>
<DialogDescription>
{record
? "Update this DNS record."
: "Create a new A or CNAME record in this zone."}
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="A">A</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="app.example.com" {...field} />
</FormControl>
<FormDescription>
Use <code>@</code> for the root domain.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{type === "A" && ipSuggestions.length > 0 && (
<FormItem>
<FormLabel>Fill from server (optional)</FormLabel>
<Select
onValueChange={(ip) =>
form.setValue("content", ip, { shouldValidate: true })
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ipSuggestions.map((suggestion) => (
<SelectItem key={suggestion.ip} value={suggestion.ip}>
<div className="flex flex-row items-center gap-2">
<span>{suggestion.label}</span>
<span className="text-xs text-muted-foreground">
{suggestion.ip}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>
{type === "A" ? "IPv4 Address" : "Target"}
</FormLabel>
<FormControl>
<Input
placeholder={
type === "A" ? "203.0.113.10" : "app.example.com"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (optional)</FormLabel>
<FormControl>
<Input type="number" placeholder="Auto" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" isLoading={isPending}>
{record ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,218 +0,0 @@
import {
ChevronDown,
ChevronRight,
Globe,
Loader2,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { HandleDnsRecord } from "./handle-dns-record";
interface ZoneRecordsProps {
dnsProviderId: string;
zoneId: string;
zoneName: string;
}
const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: ZoneRecordsProps) => {
const utils = api.useUtils();
const { data, isLoading, isError, error } =
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteRecord, isPending: isDeleting } =
api.dnsProvider.deleteRecord.useMutation();
const canWrite = !!permissions?.dnsProvider.update;
const canDelete = !!permissions?.dnsProvider.delete;
return (
<div className="flex flex-col gap-1 pl-8 pb-2 pr-3">
{isLoading && (
<div className="flex flex-row gap-2 items-center text-sm text-muted-foreground py-3">
<span>Loading records...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No records found in this zone.
</p>
)}
{data?.map((record) => {
const isEditable = record.type === "A" || record.type === "CNAME";
return (
<div
key={record.id}
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs"
>
<Badge variant="outline" className="shrink-0">
{record.type}
</Badge>
<span className="font-medium truncate">{record.name}</span>
<span className="text-muted-foreground truncate flex-1">
→ {record.content}
</span>
{canWrite && isEditable && (
<HandleDnsRecord
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
record={record}
/>
)}
{canDelete && (
<DialogAction
title="Delete Record"
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
type="destructive"
onClick={async () => {
await deleteRecord({
dnsProviderId,
zoneId,
recordId: record.id,
})
.then(() => {
toast.success("Record deleted");
utils.dnsProvider.listRecords.invalidate({
dnsProviderId,
zoneId,
});
})
.catch(() => {
toast.error("Error deleting the record");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 size-6"
isLoading={isDeleting}
>
<Trash2 className="size-3.5 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
})}
{canWrite && (
<div className="pt-1">
<HandleDnsRecord
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
/>
</div>
)}
</div>
);
};
interface Props {
dnsProviderId: string;
providerName: string;
}
export const ShowDnsProviderZones = ({
dnsProviderId,
providerName,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [expandedZoneId, setExpandedZoneId] = useState<string | null>(null);
const { data, isLoading, isError, error } =
api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen });
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setExpandedZoneId(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Globe className="size-3.5 mr-1.5" />
View Domains
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Domains for {providerName}</DialogTitle>
<DialogDescription>
Zones this provider's API token can manage. Click a zone to see and
manage its records.
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground py-6">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No zones found for this token. Make sure it has access to at least
one zone.
</p>
)}
{data && data.length > 0 && (
<div className="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
{data.map((zone) => {
const isExpanded = expandedZoneId === zone.id;
return (
<div key={zone.id} className="rounded-md border">
<button
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left"
onClick={() =>
setExpandedZoneId(isExpanded ? null : zone.id)
}
>
{isExpanded ? (
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="size-3.5 text-muted-foreground shrink-0" />
)}
<Globe className="size-3.5 text-muted-foreground shrink-0" />
{zone.name}
</button>
{isExpanded && (
<ZoneRecords
dnsProviderId={dnsProviderId}
zoneId={zone.id}
zoneName={zone.name}
/>
)}
</div>
);
})}
</div>
)}
</DialogContent>
</Dialog>
);
};
@@ -1,145 +0,0 @@
import { Globe, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { HandleDnsProvider } from "./handle-dns-provider";
import { ShowDnsProviderZones } from "./show-dns-provider-zones";
const providerLabels: Record<string, string> = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
};
export const ShowDnsProviders = () => {
const { mutateAsync, isPending: isRemoving } =
api.dnsProvider.remove.useMutation();
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Globe className="size-6 text-muted-foreground self-center" />
DNS Providers
</CardTitle>
<CardDescription>
Connect a DNS provider so Dokploy can create the A/CNAME record
for a domain instead of you setting it up by hand.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Globe className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any DNS providers configured
</span>
{permissions?.dnsProvider.create && <HandleDnsProvider />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg">
{data?.map((provider) => {
const ProviderIcon =
dnsProviderIcons[provider.providerType];
return (
<div
key={provider.dnsProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
<ProviderIcon className="size-7 shrink-0" />
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
{provider.name}
</span>
<Badge variant="outline">
{providerLabels[provider.providerType] ??
provider.providerType}
</Badge>
</div>
</div>
<div className="flex flex-row gap-1">
<ShowDnsProviderZones
dnsProviderId={provider.dnsProviderId}
providerName={provider.name}
/>
{permissions?.dnsProvider.update && (
<HandleDnsProvider
dnsProviderId={provider.dnsProviderId}
/>
)}
{permissions?.dnsProvider.delete && (
<DialogAction
title="Delete DNS Provider"
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
type="destructive"
onClick={async () => {
await mutateAsync({
dnsProviderId: provider.dnsProviderId,
})
.then(() => {
toast.success("DNS provider deleted");
refetch();
})
.catch(() => {
toast.error(
"Error deleting the DNS provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
);
})}
</div>
{permissions?.dnsProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleDnsProvider />
</div>
)}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -70,9 +70,9 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
<TooltipContent side="top" className="max-w-sm">
<p>
Runs a full Docker cleanup daily, pruning stopped containers,
unused images, build cache, and system resources. This may remove
images built for Compose services that run on-demand (backup
runners, cron jobs, one-off tasks).
unused images, volumes, build cache, and system resources. This
may remove images built for Compose services that run on-demand
(backup runners, cron jobs, one-off tasks).
</p>
<p className="mt-1">
For custom cleanup strategies, use{" "}
@@ -1,28 +0,0 @@
import { Stethoscope } from "lucide-react";
import { useState } from "react";
import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
interface Props {
serverId: string;
}
export const ShowHealthModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Stethoscope className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-y-auto">
<div className="flex gap-4 py-4 w-full">
<ShowHealth serverId={serverId} />
</div>
</DialogContent>
</Dialog>
);
};
@@ -35,7 +35,6 @@ import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions";
import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server";
import { ShowHealthModal } from "./show-health-modal";
import { ShowMonitoringModal } from "./show-monitoring-modal";
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
@@ -325,24 +324,6 @@ export const ShowServers = () => {
</Tooltip>
)}
{permissions?.docker.read &&
permissions?.server.read &&
server.sshKeyId &&
!isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowHealthModal
serverId={server.serverId}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Health</p>
</TooltipContent>
</Tooltip>
)}
<div className="flex-1" />
{permissions?.server.delete && (
@@ -1,442 +0,0 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { format } from "date-fns";
import { ArrowUpDown, Loader2, LogOut, Smartphone } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
type SessionRow = {
id: string;
userId: string;
email: string;
firstName: string | null;
lastName: string | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: Date;
expiresAt: Date;
isCurrent: boolean;
};
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-3.5" />
</Button>
);
export const ShowSessions = () => {
const { data: auth } = api.user.get.useQuery();
const isOwner = auth?.role === "owner";
const {
data: sessions,
isPending,
refetch,
} = api.user.listSessions.useQuery();
const { mutateAsync: revoke, isPending: isRevoking } =
api.user.revokeSession.useMutation();
const { data: members } = api.user.all.useQuery(undefined, {
enabled: isOwner,
});
const [statusFilter, setStatusFilter] = useState<
"all" | "active" | "expired"
>("all");
const [userFilter, setUserFilter] = useState("all");
const [globalFilter, setGlobalFilter] = useState("");
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const handleRevoke = async (sessionId: string) => {
try {
await revoke({ sessionId });
toast.success("Session revoked successfully");
refetch();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to revoke session",
);
}
};
const filteredData = useMemo(() => {
let list = sessions ?? [];
if (statusFilter !== "all") {
list = list.filter((s) => {
const isExpired = new Date(s.expiresAt) <= new Date();
return statusFilter === "expired" ? isExpired : !isExpired;
});
}
if (isOwner && userFilter !== "all") {
list = list.filter((s) => s.userId === userFilter);
}
if (globalFilter.trim()) {
const query = globalFilter.toLowerCase();
list = list.filter((s) =>
[s.ipAddress, s.userAgent ? parseUserAgent(s.userAgent) : null]
.filter(Boolean)
.some((field) => field?.toLowerCase().includes(query)),
);
}
return list;
}, [sessions, statusFilter, userFilter, isOwner, globalFilter]);
const columns = useMemo<ColumnDef<SessionRow>[]>(
() => [
{
id: "user",
accessorFn: (row) => `${row.firstName} ${row.lastName} ${row.email}`,
header: ({ column }) => <SortableHeader column={column} title="User" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<span>
{row.original.firstName} {row.original.lastName}
</span>
<span className="text-muted-foreground">
({row.original.email})
</span>
{row.original.isCurrent && (
<Badge variant="secondary" className="ml-1 text-xs">
Current
</Badge>
)}
</div>
),
},
{
accessorKey: "ipAddress",
header: ({ column }) => (
<SortableHeader column={column} title="IP Address" />
),
cell: ({ row }) => (
<span className="font-mono text-sm">
{row.original.ipAddress || "-"}
</span>
),
meta: { className: "hidden md:table-cell" },
},
{
id: "device",
accessorFn: (row) =>
row.userAgent ? parseUserAgent(row.userAgent) : "",
header: ({ column }) => (
<SortableHeader column={column} title="Device" />
),
cell: ({ row }) => (
<span className="max-w-[200px] truncate text-sm text-muted-foreground">
{row.original.userAgent
? parseUserAgent(row.original.userAgent)
: "-"}
</span>
),
meta: { className: "hidden lg:table-cell" },
},
{
id: "status",
accessorFn: (row) => (new Date(row.expiresAt) <= new Date() ? 1 : 0),
header: ({ column }) => (
<SortableHeader column={column} title="Status" />
),
cell: ({ row }) => {
const isExpired = new Date(row.original.expiresAt) <= new Date();
return isExpired ? (
<Badge variant="outline" className="text-xs">
Expired
</Badge>
) : (
<Badge variant="green" className="text-xs">
Active
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<SortableHeader column={column} title="Active Since" />
),
cell: ({ row }) => (
<span className="text-sm text-muted-foreground whitespace-nowrap">
{format(new Date(row.original.createdAt), "MMM d, HH:mm")}
</span>
),
},
{
accessorKey: "expiresAt",
header: ({ column }) => (
<SortableHeader column={column} title="Expires" />
),
cell: ({ row }) => (
<span className="text-sm text-muted-foreground whitespace-nowrap">
{format(new Date(row.original.expiresAt), "MMM d, HH:mm")}
</span>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) =>
!row.original.isCurrent && (
<div className="flex justify-end">
<DialogAction
title="Revoke Session"
description={`Force logout for ${row.original.firstName} ${row.original.lastName} (${row.original.email})?`}
type="destructive"
onClick={async () => handleRevoke(row.original.id)}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={isRevoking}
>
<LogOut className="h-4 w-4 text-red-500" />
</Button>
</DialogAction>
</div>
),
},
],
[isRevoking, handleRevoke],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Smartphone className="size-6 text-muted-foreground self-center" />
Sessions
</CardTitle>
<CardDescription>
{isOwner
? "Manage active sessions across your organization. Revoke sessions to force logout."
: "Manage your active sessions. Revoke sessions to force logout."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !sessions || sessions.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Smartphone className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
No sessions found
</span>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by IP, device..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as "all" | "active" | "expired")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
</SelectContent>
</Select>
{isOwner && (
<Select value={userFilter} onValueChange={setUserFilter}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="User" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All users</SelectItem>
{members?.map((m) => (
<SelectItem key={m.user.id} value={m.user.id}>
{m.user.firstName} {m.user.lastName} ({m.user.email}
)
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={
(header.column.columnDef.meta as any)?.className
}
>
{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}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={
(cell.column.columnDef.meta as any)?.className
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No sessions match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<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>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
function parseUserAgent(ua: string): string {
if (ua.includes("Chrome/") && !ua.includes("Edg/")) return "Chrome";
if (ua.includes("Edg/")) return "Edge";
if (ua.includes("Firefox/")) return "Firefox";
if (ua.includes("Safari/") && !ua.includes("Chrome/")) return "Safari";
if (ua.includes("curl/") || ua.includes("wget/")) return "CLI";
return ua.slice(0, 40) + (ua.length > 40 ? "..." : "");
}
@@ -53,14 +53,9 @@ type Tag = z.infer<typeof TagSchema>;
interface HandleTagProps {
tagId?: string;
/**
* Called with the new tag's id after it is created, so callers embedding
* this dialog (e.g. the tag selector) can select the tag right away.
*/
onCreated?: (tagId: string) => void;
}
export const HandleTag = ({ tagId, onCreated }: HandleTagProps) => {
export const HandleTag = ({ tagId }: HandleTagProps) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const colorInputRef = useRef<HTMLInputElement>(null);
@@ -106,11 +101,8 @@ export const HandleTag = ({ tagId, onCreated }: HandleTagProps) => {
color: data.color,
tagId: tagId || "",
})
.then(async (result) => {
.then(async () => {
await utils.tag.all.invalidate();
if (!tagId && result?.tagId) {
onCreated?.(result.tagId);
}
toast.success(tagId ? "Tag Updated" : "Tag Created");
setIsOpen(false);
form.reset();
@@ -147,18 +139,9 @@ export const HandleTag = ({ tagId, onCreated }: HandleTagProps) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
{/*
* This dialog can be rendered inside another form (e.g. the tag
* selector in the project dialog). React propagates events through the
* React tree, not the DOM tree, so without stopPropagation submitting
* this form would also submit the outer one.
*/}
<form
id="hook-form-tag"
onSubmit={(e) => {
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
@@ -106,7 +106,6 @@ export const AddInvitation = () => {
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
api.user.createUserWithCredentials.useMutation();
const { data: customRoles } = api.customRole.all.useQuery();
const { data: activeOrganization } = api.organization.active.useQuery();
const [error, setError] = useState<string | null>(null);
const form = useForm<AddInvitation>({
@@ -133,16 +132,6 @@ export const AddInvitation = () => {
}
}, [form, isCloud]);
useEffect(() => {
if (
activeOrganization?.defaultRole &&
activeOrganization.defaultRole !== "owner" &&
!form.formState.dirtyFields.role
) {
form.setValue("role", activeOrganization.defaultRole);
}
}, [form, activeOrganization?.defaultRole]);
const onSubmit = async (data: AddInvitation) => {
setError(null);
@@ -278,7 +267,10 @@ export const AddInvitation = () => {
return (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
@@ -42,7 +42,6 @@ const providerLabels = {
aws: "AWS Secrets Manager",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
} as const;
type ProviderType = keyof typeof providerLabels;
@@ -56,14 +55,7 @@ const VaultProviderSchema = z
message:
"Only letters, numbers, dashes and underscores (used in ${{vault.<name>.<secret>}})",
}),
providerType: z.enum([
"hashicorp",
"infisical",
"aws",
"doppler",
"azure",
"scaleway",
]),
providerType: z.enum(["hashicorp", "infisical", "aws", "doppler", "azure"]),
url: z.string(),
token: z.string(),
namespace: z.string(),
@@ -85,10 +77,6 @@ const VaultProviderSchema = z
azureClientSecret: z.string(),
dopplerProject: z.string(),
dopplerConfig: z.string(),
scalewayRegion: z.string(),
scalewayProjectId: z.string(),
scalewaySecretKey: z.string(),
scalewayApiUrl: z.string(),
assignments: z.array(
z.object({
projectId: z.string(),
@@ -139,17 +127,6 @@ const VaultProviderSchema = z
path: ["awsEndpoint"],
});
}
if (
data.providerType === "scaleway" &&
data.scalewayApiUrl &&
!isValidUrl(data.scalewayApiUrl)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter a valid URL (e.g. https://api.scaleway.com)",
path: ["scalewayApiUrl"],
});
}
if (
data.providerType === "infisical" &&
data.siteUrl &&
@@ -189,11 +166,6 @@ const VaultProviderSchema = z
["azureClientId", "Client ID is required"],
["azureClientSecret", "Client Secret is required"],
],
scaleway: [
["scalewayRegion", "Region is required"],
["scalewayProjectId", "Project ID is required"],
["scalewaySecretKey", "Secret Key is required"],
],
};
for (const [field, message] of required[data.providerType] ?? []) {
@@ -250,10 +222,6 @@ const defaultValues: VaultProviderForm = {
azureClientSecret: "",
dopplerProject: "",
dopplerConfig: "",
scalewayRegion: "fr-par",
scalewayProjectId: "",
scalewaySecretKey: "",
scalewayApiUrl: "https://api.scaleway.com",
assignments: [],
};
@@ -300,14 +268,6 @@ const buildConfig = (data: VaultProviderForm) => {
clientId: data.azureClientId,
clientSecret: data.azureClientSecret,
};
case "scaleway":
return {
providerType: "scaleway" as const,
region: data.scalewayRegion || "fr-par",
projectId: data.scalewayProjectId,
secretKey: data.scalewaySecretKey,
apiUrl: data.scalewayApiUrl || "https://api.scaleway.com",
};
}
};
@@ -423,12 +383,6 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
azureClientId: provider.config.clientId,
azureClientSecret: provider.config.clientSecret,
}),
...(provider.config.providerType === "scaleway" && {
scalewayRegion: provider.config.region,
scalewayProjectId: provider.config.projectId,
scalewaySecretKey: provider.config.secretKey,
scalewayApiUrl: provider.config.apiUrl,
}),
});
} else if (!vaultProviderId) {
form.reset(defaultValues);
@@ -905,97 +859,6 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</>
)}
{providerType === "scaleway" && (
<>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="scalewayRegion"
render={({ field }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="fr-par" />
</SelectTrigger>
</FormControl>
<SelectContent>
{["fr-par", "nl-ams", "pl-waw"].map((region) => (
<SelectItem key={region} value={region}>
{region}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scalewayProjectId"
render={({ field }) => (
<FormItem>
<FormLabel>Project ID</FormLabel>
<FormControl>
<Input
placeholder="00000000-0000-0000-0000-000000000000"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="scalewaySecretKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
The secret key of an API key with the{" "}
<code>SecretManagerReadOnly</code> permission set
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scalewayApiUrl"
render={({ field }) => (
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input
placeholder="https://api.scaleway.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.secret-name}}"}</code>,{" "}
<code>{"${{vault.<name>.folder/secret-name}}"}</code> for
secrets in a path, or{" "}
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
secrets
</FormDescription>
</>
)}
<div className="flex flex-col gap-2 rounded-lg border p-3">
<FormLabel>Access</FormLabel>
<FormDescription>
@@ -20,7 +20,6 @@ const providerLabels: Record<string, string> = {
aws: "AWS Secrets Manager",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
};
export const ShowVaultProviders = () => {
@@ -41,22 +41,11 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
});
const addonFit = new FitAddon();
const clipboardAddon = new ClipboardAddon();
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
addonFit.fit();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const urlParams = new URLSearchParams();
urlParams.set("serverId", serverId);
urlParams.set("cols", term.cols.toString());
urlParams.set("rows", term.rows.toString());
if (serverId === "local") {
const { port, username } = getLocalServerData();
@@ -68,22 +57,17 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
const clipboardAddon = new ClipboardAddon();
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
term.loadAddon(addonAttach);
const sendResize = (cols: number, rows: number) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
};
term.onResize(({ cols, rows }) => sendResize(cols, rows));
const resizeObserver = new ResizeObserver(() => addonFit.fit());
if (termRef.current) {
resizeObserver.observe(termRef.current);
}
addonFit.fit();
return () => {
resizeObserver.disconnect();
ws.readyState === WebSocket.OPEN && ws.close();
};
}, [id, serverId]);
@@ -35,7 +35,7 @@ export const DocLinks = () => (
<ExternalLink className="h-3 w-3" />
</a>
<Link
href="/dashboard/docker?tab=swarm&subtab=nodes"
href="/dashboard/settings/cluster"
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
>
Cluster Settings
@@ -85,7 +85,7 @@ export const SwarmNotAvailable = ({
<li>
Check the{" "}
<Link
href="/dashboard/docker?tab=swarm&subtab=nodes"
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
Cluster Settings
@@ -130,7 +130,7 @@ export const ServicesError = ({
<li>
Network connectivity issues to a remote server &mdash; check{" "}
<Link
href="/dashboard/docker?tab=swarm&subtab=nodes"
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
Cluster Settings
@@ -183,7 +183,7 @@ export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => (
Worker nodes need to pull images from a shared registry. Configure one
in{" "}
<Link
href="/dashboard/docker?tab=swarm&subtab=nodes"
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
Cluster Settings
@@ -255,7 +255,7 @@ export const NoRunningContainers = ({
<li>
Images can&apos;t be pulled on worker nodes &mdash; verify your{" "}
<Link
href="/dashboard/docker?tab=swarm&subtab=nodes"
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
registry configuration
@@ -303,7 +303,7 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {
<p className="mt-2 text-xs">
Manage nodes in{" "}
<Link
href="/dashboard/docker?tab=swarm&subtab=nodes"
href="/dashboard/settings/cluster"
className="underline underline-offset-4"
>
Cluster Settings
@@ -51,9 +51,7 @@ export default function SwarmMonitorCard({ serverId }: Props) {
{!serverId && (
<Button
onClick={() =>
window.location.replace(
"/dashboard/docker?tab=swarm&subtab=nodes",
)
window.location.replace("/dashboard/settings/cluster")
}
>
<Settings className="mr-2 h-4 w-4" />
@@ -211,15 +211,6 @@ export const LibsqlIcon = ({ className }: Props) => {
);
};
export const DB_ENGINE_ICONS = {
postgres: PostgresqlIcon,
mariadb: MariadbIcon,
mysql: MysqlIcon,
mongo: MongodbIcon,
redis: RedisIcon,
libsql: LibsqlIcon,
} as const;
export const GitlabIcon = ({ className }: Props) => {
return (
<svg
@@ -1,40 +0,0 @@
interface Props {
className?: string;
}
export const CloudflareIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727" />
</svg>
);
export const Route53Icon = ({ className }: Props) => (
<svg
viewBox="0 0 304 182"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fill="currentColor"
d="m86 66 2 9c0 3 1 5 3 8v2l-1 3-7 4-2 1-3-1-4-5-3-6c-8 9-18 14-29 14-9 0-16-3-20-8-5-4-8-11-8-19s3-15 9-20c6-6 14-8 25-8a79 79 0 0 1 22 3v-7c0-8-2-13-5-16-3-4-8-5-16-5l-11 1a80 80 0 0 0-14 5h-2c-1 0-2-1-2-3v-5l1-3c0-1 1-2 3-2l12-5 16-2c12 0 20 3 26 8 5 6 8 14 8 25v32zM46 82l10-2c4-1 7-4 10-7l3-6 1-9v-4a84 84 0 0 0-19-2c-6 0-11 1-15 4-3 2-4 6-4 11s1 8 3 11c3 2 6 4 11 4zm80 10-4-1-2-3-23-78-1-4 2-2h10l4 1 2 4 17 66 15-66 2-4 4-1h8l4 1 2 4 16 67 17-67 2-4 4-1h9c2 0 3 1 3 2v2l-1 2-24 78-2 4-4 1h-9l-4-1-1-4-16-65-15 64-2 4-4 1h-9zm129 3a66 66 0 0 1-27-6l-3-3-1-2v-5c0-2 1-3 2-3h2l3 1a54 54 0 0 0 23 5c6 0 11-2 14-4 4-2 5-5 5-9l-2-7-10-5-15-5c-7-2-13-6-16-10a24 24 0 0 1 5-34l10-5a44 44 0 0 1 20-2 110 110 0 0 1 12 3l4 2 3 2 1 4v4c0 3-1 4-2 4l-4-2c-6-2-12-3-19-3-6 0-11 0-14 2s-4 5-4 9c0 3 1 5 3 7s5 4 11 6l14 4c7 3 12 6 15 10s5 9 5 14l-3 12-7 8c-3 3-7 5-11 6l-14 2z"
/>
<path
d="M274 144A220 220 0 0 1 4 124c-4-3-1-6 2-4a300 300 0 0 0 263 16c5-2 10 4 5 8z"
fill="#f90"
/>
<path
d="M287 128c-4-5-28-3-38-1-4 0-4-3-1-5 19-13 50-9 53-5 4 5-1 36-18 51-3 2-6 1-5-2 5-10 13-33 9-38z"
fill="#f90"
/>
</svg>
);
export const dnsProviderIcons = {
cloudflare: CloudflareIcon,
route53: Route53Icon,
} as const;
@@ -571,22 +571,10 @@ export const AzureIcon = ({ className }: Props) => (
</svg>
);
export const ScalewayIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M16.605 11.11v5.72a1.77 1.77 0 01-1.54 1.69h-4a1.43 1.43 0 01-1.31-1.22 1.09 1.09 0 010-.18 1.37 1.37 0 011.37-1.36h1.74a1 1 0 001-1v-3.62a1.4 1.4 0 011.18-1.39h.17a1.37 1.37 0 011.39 1.36zm-6.46 1.74V9.26a1 1 0 011-1h1.85a1.37 1.37 0 001.37-1.37 1 1 0 000-.17 1.45 1.45 0 00-1.41-1.2h-3.96a1.81 1.81 0 00-1.58 1.66v5.7a1.37 1.37 0 001.37 1.37h.21a1.4 1.4 0 001.15-1.4zm12-4.29V20a4.53 4.53 0 01-4.15 4h-7.58a8.57 8.57 0 01-8.56-8.57V4.54A4.54 4.54 0 016.395 0h7.18a8.56 8.56 0 018.56 8.56zm-2.74 0a5.83 5.83 0 00-5.82-5.82h-7.19a1.79 1.79 0 00-1.8 1.8v10.89a5.83 5.83 0 005.82 5.8h7.44a1.79 1.79 0 001.54-1.48z" />
</svg>
);
export const vaultProviderIcons = {
hashicorp: HashicorpVaultIcon,
infisical: InfisicalIcon,
aws: AwsIcon,
doppler: DopplerIcon,
azure: AzureIcon,
scaleway: ScalewayIcon,
} as const;
@@ -8,10 +8,8 @@ import { Button } from "../ui/button";
interface Props {
children: React.ReactNode;
/** Overrides the default quote in the left panel (desktop only). */
leftPanel?: React.ReactNode;
}
export const OnboardingLayout = ({ children, leftPanel }: Props) => {
export const OnboardingLayout = ({ children }: Props) => {
const { config: whitelabeling } = useWhitelabelingPublic();
const appName = whitelabeling?.appName || "Dokploy";
const appDescription =
@@ -32,11 +30,9 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
{appName}
</Link>
<div className="relative z-20 mt-auto">
{leftPanel ?? (
<blockquote className="space-y-2">
<p className="text-lg text-primary">{appDescription}</p>
</blockquote>
)}
<blockquote className="space-y-2">
<p className="text-lg text-primary">{appDescription}</p>
</blockquote>
</div>
</div>
<div className="flex min-h-svh w-full flex-col">
+138 -174
View File
@@ -14,24 +14,22 @@ import {
ClipboardList,
Clock,
CreditCard,
Database,
Folder,
Forward,
GalleryVerticalEnd,
GitBranch,
Globe,
HardDrive,
House,
Key,
KeyRound,
LayoutGrid,
Loader2,
LogIn,
type LucideIcon,
Package,
Palette,
Rocket,
Server,
ShieldCheck,
Smartphone,
Star,
Tags,
Trash2,
@@ -55,26 +53,14 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import {
SIDEBAR_COOKIE_NAME,
@@ -178,11 +164,10 @@ const MENU: Menu = {
},
{
isSingle: true,
title: "Overview",
url: "/dashboard/overview",
icon: LayoutGrid,
// Only enabled for users with access to services
isEnabled: ({ permissions }) => !!permissions?.service.read,
title: "Deployments",
url: "/dashboard/deployments",
icon: Rocket,
isEnabled: ({ permissions }) => !!permissions?.deployment.read,
},
{
isSingle: true,
@@ -299,12 +284,6 @@ const MENU: Menu = {
url: "/dashboard/settings/profile",
icon: User,
},
{
isSingle: true,
title: "Sessions",
icon: Smartphone,
url: "/dashboard/settings/sessions",
},
{
isSingle: true,
title: "Remote Servers",
@@ -379,18 +358,11 @@ const MENU: Menu = {
icon: Vault,
isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create,
},
{
isSingle: true,
title: "DNS Providers",
url: "/dashboard/settings/dns",
icon: Globe,
isEnabled: ({ permissions }) => !!permissions?.dnsProvider.read,
},
{
isSingle: true,
title: "S3 Destinations",
url: "/dashboard/settings/destinations",
icon: HardDrive,
icon: Database,
isEnabled: ({ permissions }) => !!permissions?.destination.read,
},
@@ -401,6 +373,14 @@ const MENU: Menu = {
icon: ShieldCheck,
isEnabled: ({ permissions }) => !!permissions?.certificate.read,
},
{
isSingle: true,
title: "Cluster",
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins
isEnabled: ({ permissions }) => !!permissions?.organization.update,
},
{
isSingle: true,
title: "Notifications",
@@ -593,8 +573,6 @@ function SidebarLogo() {
const [_activeTeam, setActiveTeam] = useState<
typeof activeOrganization | null
>(null);
const [organizationSelectorOpen, setOrganizationSelectorOpen] =
useState(false);
useEffect(() => {
if (activeOrganization) {
@@ -617,11 +595,8 @@ function SidebarLogo() {
>
{/* Organization Logo and Selector */}
<SidebarMenuItem className={"w-full"}>
<Popover
open={organizationSelectorOpen}
onOpenChange={setOrganizationSelectorOpen}
>
<PopoverTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size={isCollapsed ? "sm" : "lg"}
className={cn(
@@ -670,156 +645,145 @@ function SidebarLogo() {
className={cn("ml-auto", isCollapsed && "hidden")}
/>
</SidebarMenuButton>
</PopoverTrigger>
<PopoverContent
className="w-96 p-0"
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-64 rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<Command>
<CommandInput
placeholder="Search organizations..."
className="h-9"
/>
<CommandList className="max-h-[min(60vh,24rem)]">
<CommandEmpty>No organizations found.</CommandEmpty>
<CommandGroup heading="Organizations">
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<CommandItem
key={org.id}
value={org.name}
onSelect={async () => {
setOrganizationSelectorOpen(false);
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="flex items-center justify-between gap-1"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex size-6 shrink-0 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
<span className="truncate">{org.name}</span>
</div>
<DropdownMenuLabel className="text-xs text-muted-foreground shrink-0">
Organizations
</DropdownMenuLabel>
<div className="overflow-y-auto overflow-x-hidden min-h-0 -mx-1 px-1">
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<div
className="flex flex-row items-center justify-between gap-1"
key={org.name}
>
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="flex min-w-0 flex-1 gap-2 p-2"
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
<span className="truncate">{org.name}</span>
</DropdownMenuItem>
<div
className="flex shrink-0 items-center gap-2"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
<div className="flex shrink-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success("Default organization updated");
})
.catch((error) => {
toast.error(
error?.message ||
"Error setting default organization",
);
});
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
"Default organization updated",
"Organization deleted successfully",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error setting default organization",
"Error deleting organization",
);
});
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
{(user?.role === "owner" ||
user?.role === "admin" ||
isCloud) && (
<div className="border-t p-1">
<AddOrganization />
</div>
)}
</Command>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
</div>
</div>
);
})}
</div>
{(user?.role === "owner" ||
user?.role === "admin" ||
isCloud) && (
<>
<DropdownMenuSeparator />
<AddOrganization />
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
{/* Notification Bell */}
@@ -13,9 +13,6 @@ export function SignInWithGithub() {
try {
const { error } = await authClient.signIn.social({
provider: "github",
callbackURL: "/dashboard/home",
newUserCallbackURL: "/dashboard/home?signup=github",
errorCallbackURL: "/",
});
if (error) {
toast.error(error.message);
@@ -13,9 +13,6 @@ export function SignInWithGoogle() {
try {
const { error } = await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard/home",
newUserCallbackURL: "/dashboard/home?signup=google",
errorCallbackURL: "/",
});
if (error) {
toast.error(error.message);
@@ -1,204 +0,0 @@
"use client";
import {
Activity,
ArrowRight,
Bot,
Boxes,
Cloud,
Database,
Layers,
LayoutTemplate,
Rocket,
Shield,
Terminal,
Unlock,
Users,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface ShowcaseItem {
icon: typeof Users;
title: string;
description: string;
}
const SLIDES: ShowcaseItem[][] = [
[
{
icon: Users,
title: "Team access",
description:
"Anyone in your team can deploy simply and securely in a safe, governed environment.",
},
{
icon: Layers,
title: "Open source",
description:
"Deploy for free with the open-source alternative to Netlify, Vercel, and Heroku.",
},
{
icon: Bot,
title: "AI sandbox",
description:
"Unleash the power of AI and test AI-generated code in a sandbox before deploying to a live URL.",
},
{
icon: Shield,
title: "Enterprise ready",
description:
"Scale when you're ready with granular RBAC, SSO, audit logs, rollback and multi-tenancy.",
},
],
[
{
icon: Rocket,
title: "Any stack",
description:
"Deploy any application using Nixpacks, Heroku Buildpacks, or your own Dockerfile.",
},
{
icon: Boxes,
title: "Docker Compose",
description:
"Deploy complex applications natively with full Docker Compose integration.",
},
{
icon: Cloud,
title: "Multi-server",
description:
"Effortlessly deploy your applications on remote servers, with zero configuration hassle.",
},
{
icon: LayoutTemplate,
title: "Ready templates",
description:
"Get started quickly with pre-configured templates for Supabase, Cal.com, PocketBase, and more.",
},
],
[
{
icon: Database,
title: "Managed databases",
description:
"Manage and back up MySQL, PostgreSQL, MongoDB, MariaDB, and Redis directly from Dokploy.",
},
{
icon: Terminal,
title: "API & CLI",
description: "Full API and CLI access to fit any custom workflow.",
},
{
icon: Activity,
title: "Live monitoring",
description:
"Monitor CPU, memory, and network usage in real time across every deployment.",
},
{
icon: Unlock,
title: "No lock-in",
description:
"Modify, scale, and customize Dokploy however your project needs.",
},
],
];
const AUTO_ADVANCE_MS = 6000;
export const SignupShowcase = () => {
const [active, setActive] = useState(0);
const [paused, setPaused] = useState(false);
const [reduceMotion, setReduceMotion] = useState(false);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduceMotion(query.matches);
const listener = (e: MediaQueryListEvent) => setReduceMotion(e.matches);
query.addEventListener("change", listener);
return () => query.removeEventListener("change", listener);
}, []);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (paused || reduceMotion) {
return;
}
timerRef.current = setInterval(() => {
setActive((prev) => (prev + 1) % SLIDES.length);
}, AUTO_ADVANCE_MS);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [paused, reduceMotion]);
const goTo = (index: number) => {
setActive(((index % SLIDES.length) + SLIDES.length) % SLIDES.length);
};
return (
<div
className="relative"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
>
<div
key={active}
className={cn(
"grid grid-cols-2 gap-4",
!reduceMotion &&
"animate-in fade-in slide-in-from-bottom-2 duration-500 ease-out",
)}
>
{SLIDES[active]?.map((item) => (
<div
key={item.title}
className="group flex flex-col gap-3 rounded-lg border border-primary/10 bg-background/40 p-4 transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/25 hover:bg-background/60"
>
<div className="flex size-9 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors duration-300 group-hover:bg-primary/15">
<item.icon className="size-4.5" strokeWidth={1.75} />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-primary">{item.title}</p>
<p className="text-sm text-muted-foreground leading-snug">
{item.description}
</p>
</div>
</div>
))}
</div>
<div className="mt-6 flex items-center gap-2">
{SLIDES.map((slide, index) => (
<button
key={slide.map((item) => item.title).join("-")}
type="button"
aria-label={`Show showcase slide ${index + 1}`}
aria-current={index === active}
onClick={() => goTo(index)}
className={cn(
"h-1.5 rounded-full transition-all duration-300",
index === active
? "w-6 bg-primary"
: "w-1.5 bg-primary/20 hover:bg-primary/40",
)}
/>
))}
</div>
<button
type="button"
aria-label="Next showcase slide"
onClick={() => goTo(active + 1)}
className="absolute top-1/2 right-0 z-30 flex size-9 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full border border-primary/15 bg-background text-primary shadow-sm transition-all duration-300 hover:scale-105 hover:border-primary/30 hover:shadow-md"
>
<ArrowRight className="size-4" strokeWidth={1.75} />
</button>
</div>
);
};
@@ -45,13 +45,6 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
@@ -173,12 +166,7 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
vaultProvider: {
label: "Secrets Providers",
description:
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced",
},
dnsProvider: {
label: "DNS Providers",
description:
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler) and where their secrets can be referenced",
},
};
@@ -452,25 +440,6 @@ const ACTION_META: Record<
},
delete: { label: "Delete", description: "Remove secret providers" },
},
dnsProvider: {
read: {
label: "Read",
description: "View configured DNS providers and their zones/records",
},
create: {
label: "Create",
description:
"Connect new DNS providers, test their connection, and create records",
},
update: {
label: "Update",
description: "Edit provider credentials and update existing records",
},
delete: {
label: "Delete",
description: "Remove DNS providers and delete their records",
},
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
@@ -811,78 +780,6 @@ function HandleCustomRole({
);
}
const DefaultRoleSection = ({ customRoles }: { customRoles: string[] }) => {
const utils = api.useUtils();
const { data: auth } = api.user.get.useQuery();
const { data: activeOrganization } = api.organization.active.useQuery();
const { mutateAsync: updateOrganization, isPending: isUpdating } =
api.organization.update.useMutation();
const [selectedRole, setSelectedRole] = useState<string>();
if (auth?.role !== "owner" || !activeOrganization) {
return null;
}
const currentRole = activeOrganization.defaultRole ?? "member";
const value = selectedRole ?? currentRole;
const onSave = async () => {
await updateOrganization({
organizationId: activeOrganization.id,
name: activeOrganization.name,
logo: activeOrganization.logo ?? undefined,
defaultRole: value,
})
.then(() => {
toast.success("Default role updated");
utils.organization.active.invalidate();
})
.catch((error) => {
toast.error(
error instanceof Error
? error.message
: "Error updating default role",
);
});
};
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-lg border bg-muted/20 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Default role for new members</p>
<p className="text-xs text-muted-foreground">
Assigned automatically to users joining through SSO and preselected
when creating invitations.
</p>
</div>
<div className="flex items-center gap-2">
<Select value={value} onValueChange={setSelectedRole}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{customRoles.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
isLoading={isUpdating}
disabled={value === currentRole}
onClick={onSave}
>
Save
</Button>
</div>
</div>
);
};
const CustomRolesContent = () => {
const {
data: customRoles,
@@ -924,9 +821,6 @@ const CustomRolesContent = () => {
return (
<div className="space-y-4">
<DefaultRoleSection
customRoles={customRoles?.map((role) => role.role) ?? []}
/>
<div className="flex justify-end">
<HandleCustomRole onSuccess={refetch} />
</div>
@@ -50,7 +50,6 @@ export function SignInWithSSO({
const { data, error } = await authClient.signIn.sso({
email: values.email,
callbackURL: "/dashboard/home",
errorCallbackURL: "/",
});
if (error) {
toast.error(error.message ?? "Failed to sign in with SSO");
@@ -45,12 +45,10 @@ type ServiceItem = {
id: string;
name: string;
type: ServiceType;
icon: string | null;
};
type NamedService = {
name: string;
icon?: string | null;
};
type EnvironmentServiceCollections = {
@@ -118,20 +116,8 @@ const getStringQueryParam = (value: string | string[] | undefined) =>
const includesSearch = (value: string | null | undefined, search: string) =>
value?.toLowerCase().includes(search.toLowerCase()) ?? false;
const getServiceIcon = (
service: Pick<ServiceItem, "type" | "icon">,
className = "size-4",
) => {
if (service.icon) {
return (
<img
src={service.icon}
alt=""
className={`${className} object-contain shrink-0`}
/>
);
}
const Icon = SERVICE_ICONS[service.type];
const getServiceIcon = (type: ServiceType, className = "size-4") => {
const Icon = SERVICE_ICONS[type];
return <Icon className={className} />;
};
@@ -141,7 +127,7 @@ const countEnvironmentServices = (environment: ServiceCollections): number =>
0,
);
const mapServices = <T extends NamedService>(
const mapServices = <T extends { name: string }>(
items: readonly T[],
getId: (item: T) => string,
type: ServiceType,
@@ -150,7 +136,6 @@ const mapServices = <T extends NamedService>(
id: getId(item),
name: item.name,
type,
icon: item.icon ?? null,
}));
const extractServicesFromEnvironment = (
@@ -565,7 +550,7 @@ export const AdvanceBreadcrumb = () => {
aria-expanded={serviceOpen}
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
{getServiceIcon(currentService)}
{getServiceIcon(currentService.type)}
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
{currentService.name}
</span>
@@ -604,7 +589,7 @@ export const AdvanceBreadcrumb = () => {
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-8 rounded-md bg-muted">
{getServiceIcon(service)}
{getServiceIcon(service.type)}
</div>
<div className="flex flex-col">
<span className="font-medium">
@@ -147,13 +147,7 @@ export function TagSelector({
})}
</CommandGroup>
<div className="flex items-center justify-center p-2 border-t">
<HandleTag
onCreated={(tagId) => {
if (!selectedTags.includes(tagId)) {
onTagsChange([...selectedTags, tagId]);
}
}}
/>
<HandleTag />
</div>
</CommandList>
</Command>
+1 -1
View File
@@ -22,7 +22,7 @@ function Switch({
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[18px] group-data-[size=sm]/switch:data-checked:translate-x-[10px] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
);
@@ -1 +0,0 @@
ALTER TABLE "organization" ADD COLUMN "default_role" text;
@@ -1 +0,0 @@
ALTER TABLE "domain" ADD COLUMN "enabled" boolean DEFAULT true NOT NULL;
@@ -1 +0,0 @@
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'scaleway';
@@ -1,12 +0,0 @@
CREATE TYPE "public"."DnsProviderType" AS ENUM('cloudflare', 'route53');--> statement-breakpoint
CREATE TABLE "dns_provider" (
"dnsProviderId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"providerType" "DnsProviderType" NOT NULL,
"config" jsonb NOT NULL,
"organizationId" text NOT NULL,
"createdAt" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "dns_provider" ADD CONSTRAINT "dns_provider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "dns_provider_org_name_idx" ON "dns_provider" USING btree ("organizationId","name");
File diff suppressed because it is too large Load Diff

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