mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
Compare commits
296 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54cadc3b8d | |||
| 5624dff5c1 | |||
| d7ea9cf9bc | |||
| 68a05551d0 | |||
| 48e0b747b6 | |||
| 4e38925119 | |||
| 91bcf88df9 | |||
| a63157cd51 | |||
| 47308070a8 | |||
| a1c57011a4 | |||
| ec8e645758 | |||
| e8f4a1715c | |||
| 880bb55272 | |||
| a36739eb07 | |||
| 1ff9294009 | |||
| 142082fe89 | |||
| 5345c00ff3 | |||
| d181384833 | |||
| 1f5f5576f1 | |||
| d6ec4bd3d1 | |||
| 7e9b77e93f | |||
| 8db39a8d15 | |||
| 714bf03244 | |||
| dada3049a8 | |||
| 0971e1952a | |||
| e0a9b83a39 | |||
| ccfc44815e | |||
| 9e3d6fdf2c | |||
| cabfe2e81e | |||
| b0ab9c77fb | |||
| 2f7f50aec5 | |||
| 532de2c59e | |||
| 542eced075 | |||
| 4b2de31b1c | |||
| 4b7a6295fc | |||
| a4e29172f4 | |||
| 22450abc4b | |||
| 976161647f | |||
| bfc180dfef | |||
| 628b14d07a | |||
| 0efe7feaf5 | |||
| 474081a264 | |||
| 1053743a1c | |||
| 355b856185 | |||
| a1cc2778f3 | |||
| fee0071223 | |||
| 3334edef73 | |||
| cf0e8d236c | |||
| 9d9b41c1e5 | |||
| bdf33750a3 | |||
| a66684a784 | |||
| abc2439ec4 | |||
| 5b6a8bd1ef | |||
| d26f70642d | |||
| e2d2dbb00a | |||
| e4012d857d | |||
| ab62080e9d | |||
| 3a8fad57e6 | |||
| 203cddbeef | |||
| 032e804114 | |||
| ca3da4dc5d | |||
| 87b914996f | |||
| 8f3230e2ed | |||
| cebd380856 | |||
| a822db5967 | |||
| 71816d0853 | |||
| 85ed75a5c6 | |||
| f46cd4601c | |||
| 2a585933e9 | |||
| 62e6a4c017 | |||
| 4710c28db5 | |||
| 63efbffcfd | |||
| 523e8f4e4f | |||
| b67519ebd5 | |||
| 452b7aa2f2 | |||
| 3c6a96a30a | |||
| 546686ea35 | |||
| 217ca07842 | |||
| c6f81ce60d | |||
| bd8ba128cd | |||
| 72a554de5f | |||
| cda047feb2 | |||
| feccb51287 | |||
| 1ccd633153 | |||
| 8be1e68fe1 | |||
| 875baa139c | |||
| e2ab2eb6bc | |||
| d675db10bb | |||
| 903789bfe5 | |||
| d1273e2fe9 | |||
| ede4626396 | |||
| 772b768217 | |||
| d3d8d9ec83 | |||
| f4dfb6a903 | |||
| bbde5ebbc3 | |||
| 62f701425a | |||
| bee6918d9a | |||
| d7c152af0d | |||
| 5ff0c26954 | |||
| b4edb56132 | |||
| 9dab15ad47 | |||
| e3f4310755 | |||
| 0be822ef5b | |||
| a8e0477b70 | |||
| 5fa963fe87 | |||
| 3054cf53be | |||
| 8c2d4d5c19 | |||
| 64c3a93590 | |||
| fa3cab8bcc | |||
| 31ddd7fe1a | |||
| edc8fc1d7f | |||
| 8f42dced38 | |||
| fc6f4d9af8 | |||
| d183018201 | |||
| 7a0efc7aa4 | |||
| effa3ea502 | |||
| b10cad4f78 | |||
| 65c5ccc7b2 | |||
| cf6f5e39d2 | |||
| 33367fb86b | |||
| 5d1731c54f | |||
| e15b38be38 | |||
| 76c2416613 | |||
| 60612b0986 | |||
| 0ea7de7d31 | |||
| 49055a05b1 | |||
| 2221b8a99c | |||
| c77f6d889d | |||
| a5b8626345 | |||
| fa788b00a6 | |||
| 41d355c7dc | |||
| ded23bb05f | |||
| 717792f186 | |||
| c0265b49b4 | |||
| b53fface69 | |||
| ff275fe94e | |||
| ca578ed075 | |||
| a691b469a1 | |||
| 6d8aa9d8da | |||
| 3b8611771c | |||
| 2cb499fb64 | |||
| 479e8bb551 | |||
| e541308a4f | |||
| b976c7b4f7 | |||
| 7f3a85ac38 | |||
| ac9ad72875 | |||
| d8b7c1ea5e | |||
| d542967ae4 | |||
| aedb35b8df | |||
| c6ab76fe09 | |||
| 2598a62771 | |||
| 8bc1bd36d8 | |||
| 1b9504897a | |||
| 7b3c5355fa | |||
| 1828e7c276 | |||
| d770e3ce7d | |||
| d583ba8bd3 | |||
| b8e8704493 | |||
| 518ee44ea6 | |||
| f62e41ede6 | |||
| 046a608f8c | |||
| 171c70492d | |||
| a4425cfb1d | |||
| 8e18a8a683 | |||
| fc5b8d03e5 | |||
| 1183c7c4a9 | |||
| dec05aca22 | |||
| 98dbc59a02 | |||
| 95b1afc2c2 | |||
| 52aca4636d | |||
| c741ff36c0 | |||
| 795065810d | |||
| 4bf6dfff13 | |||
| 59ebf809aa | |||
| a1a672b672 | |||
| 92a1f0d0de | |||
| c61163d721 | |||
| a2329aed26 | |||
| f6c1e77279 | |||
| dbe2b38ea9 | |||
| eff6a1865e | |||
| 90e96e1806 | |||
| dc9c67129e | |||
| 561fb43d42 | |||
| 795f6f094e | |||
| 26c871946a | |||
| 65198f6082 | |||
| 07f95a034f | |||
| 2bda2ccc04 | |||
| 1c9a538457 | |||
| 4e18a64da2 | |||
| 3e10a8dd6d | |||
| 63edae4740 | |||
| c2071bbbfd | |||
| 1325a0fccc | |||
| 9412b52172 | |||
| 7825537964 | |||
| 3be9cdfe65 | |||
| fa7a54b4eb | |||
| daba2d7fbc | |||
| b07d7f2005 | |||
| 02edd6abe7 | |||
| a60b0af419 | |||
| 9687224152 | |||
| 86bce3335f | |||
| 37f0bba43f | |||
| 16f0f0eeb4 | |||
| 4d58f3987f | |||
| 3848fa9c0d | |||
| f12ecc3350 | |||
| 3cad115155 | |||
| 34c5b688ac | |||
| 3fbd67fe4d | |||
| 491054f1d9 | |||
| 5492ed12a0 | |||
| cba6ce9c57 | |||
| 8bf1167530 | |||
| 529f54e2a5 | |||
| 8144e1a2fe | |||
| d11735d4cd | |||
| 739fd08372 | |||
| 870d592a97 | |||
| 56fdb6151d | |||
| a089870e8b | |||
| 5bcaf9dafb | |||
| f238347ed7 | |||
| 9919e369d1 | |||
| aaf8d7e12f | |||
| 1ccf15f331 | |||
| 4732b38d5a | |||
| c76c5b2f90 | |||
| c6c259f926 | |||
| 1617d07fbd | |||
| e237e34e14 | |||
| 71521f2711 | |||
| 36db6f589a | |||
| 3cefade4a0 | |||
| 5ceb88bebe | |||
| 92b628c8fc | |||
| 790d7c1f36 | |||
| 00d9603209 | |||
| fe85f0f998 | |||
| cf22a0e058 | |||
| c05e6d5a6b | |||
| d39902d575 | |||
| 79b5ff96ca | |||
| 67fa8e4104 | |||
| c085ec3793 | |||
| f14f12c778 | |||
| 37c8d6477e | |||
| 2003886aa0 | |||
| 19958ee9d9 | |||
| 63144e6855 | |||
| c8b87c7105 | |||
| bd92dfb8c1 | |||
| 692c68f013 | |||
| 679dbf98cb | |||
| b1795e1d3c | |||
| 6204415dc5 | |||
| 79af8da827 | |||
| 6293a39187 | |||
| d3a873a6f5 | |||
| 54272b95b6 | |||
| 6ec530043e | |||
| 8545a1d9e7 | |||
| c9ca719d97 | |||
| b50b1d4c86 | |||
| 98af5a2f07 | |||
| 5ed94954e6 | |||
| 09ea766fb5 | |||
| 7728e5ea9e | |||
| 7ca96f6087 | |||
| 012d9596b2 | |||
| e2bbbc86d8 | |||
| 0e3c228c4e | |||
| 52eb38fa48 | |||
| ee802cd54c | |||
| 898af1f74c | |||
| e43d99e796 | |||
| e19dd43293 | |||
| 30c408b130 | |||
| a504449f1d | |||
| eaec68023e | |||
| 2643ae0a86 | |||
| f59f11474b | |||
| f29a1a1976 | |||
| ade7a79bf1 | |||
| 55704ff98f | |||
| 57bb9f27bd | |||
| b9a8064371 | |||
| cee674235f | |||
| 60ad78282e | |||
| 3cb0523681 | |||
| 4032895176 | |||
| bb0c59fc5c | |||
| 24cc455168 |
@@ -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\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
.env.local
|
||||
@@ -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.
|
||||
+9
-1
@@ -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
|
||||
|
||||
+4
-1
@@ -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"]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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/*"]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
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");
|
||||
});
|
||||
|
||||
it("resolves build.context against the compose file's own directory when there are no mounts", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composePath: "./backend/docker-compose.yml",
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
expect(cmd).toContain("-f ./backend/docker-compose.yml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compose createCommand --env-file", () => {
|
||||
it("points --env-file at the generated .env next to a nested compose file", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--env-file deploy/.env");
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits --env-file when createEnvFile is disabled", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--env-file");
|
||||
});
|
||||
|
||||
it("uses the code-root .env for raw sourceType", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
sourceType: "raw",
|
||||
composePath: "docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--env-file .env");
|
||||
});
|
||||
|
||||
it("does not add --env-file to stack deploy (unsupported flag)", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composeType: "stack",
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--env-file");
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ const makeDomain = (serviceName: string) =>
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
port: 3000,
|
||||
enabled: true,
|
||||
}) as any;
|
||||
|
||||
// If the returned shell fragment is safe, parse() yields only string tokens.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ describe("Host rule format regression tests", () => {
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ describe("createDomainLabels", () => {
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
+176
@@ -20,6 +20,7 @@ import {
|
||||
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
||||
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
||||
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
||||
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
@@ -440,3 +441,178 @@ describe("doppler client", () => {
|
||||
).rejects.toThrow("status 401");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleway client", () => {
|
||||
const config = {
|
||||
providerType: "scaleway" as const,
|
||||
region: "fr-par",
|
||||
projectId: "project-1",
|
||||
secretKey: "scw-secret-key",
|
||||
apiUrl: "https://api.scaleway.com",
|
||||
};
|
||||
|
||||
const accessResponse = (value: string) =>
|
||||
jsonResponse({
|
||||
secret_id: "secret-1",
|
||||
revision: 1,
|
||||
data: Buffer.from(value).toString("base64"),
|
||||
});
|
||||
|
||||
it("reads a secret by name from the root path and decodes the payload", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse("postgres://real"));
|
||||
|
||||
const result = await scalewayClient.getSecrets(config, ["db-url"]);
|
||||
|
||||
expect(result).toEqual({ "db-url": "postgres://real" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets-by-path/versions/latest_enabled/access?project_id=project-1&secret_name=db-url&secret_path=%2F",
|
||||
);
|
||||
expect((init.headers as Record<string, string>)["X-Auth-Token"]).toBe(
|
||||
"scw-secret-key",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the folder of a path-qualified ref as secret_path", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse("value"));
|
||||
|
||||
await scalewayClient.getSecrets(config, ["prod/db/password"]);
|
||||
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toContain(
|
||||
"secret_name=password&secret_path=%2Fprod%2Fdb",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts a field from a JSON secret and fetches the secret once", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
accessResponse(JSON.stringify({ user: "admin", port: 5432 })),
|
||||
);
|
||||
|
||||
const result = await scalewayClient.getSecrets(config, [
|
||||
"db-creds:user",
|
||||
"db-creds:port",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"db-creds:user": "admin",
|
||||
"db-creds:port": "5432",
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws when the secret is not JSON but a field is requested", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse("plain-value"));
|
||||
|
||||
await expect(
|
||||
scalewayClient.getSecrets(config, ["db-creds:user"]),
|
||||
).rejects.toThrow("is not JSON");
|
||||
});
|
||||
|
||||
it("throws when the requested field is missing", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse(JSON.stringify({ user: "a" })));
|
||||
|
||||
await expect(
|
||||
scalewayClient.getSecrets(config, ["db-creds:password"]),
|
||||
).rejects.toThrow('field "password" not found');
|
||||
});
|
||||
|
||||
it("throws a clear error for a missing secret", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ message: "not found" }, false, 404),
|
||||
);
|
||||
|
||||
await expect(scalewayClient.getSecrets(config, ["nope"])).rejects.toThrow(
|
||||
'secret "nope" not found in path "/"',
|
||||
);
|
||||
});
|
||||
|
||||
it("reports authentication failures with the API message", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ message: "denied authentication" }, false, 403),
|
||||
);
|
||||
|
||||
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
|
||||
"authentication failed (status 403: denied authentication)",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the secret has no enabled version", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ secret_id: "secret-1" }));
|
||||
|
||||
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
|
||||
"has no enabled version",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a ref without a secret name", async () => {
|
||||
await expect(scalewayClient.getSecrets(config, ["prod/"])).rejects.toThrow(
|
||||
"expected format <name> or <folder>/<name>[:field]",
|
||||
);
|
||||
});
|
||||
|
||||
it("tests the connection against the secrets listing", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ secrets: [], total_count: 0 }));
|
||||
|
||||
await scalewayClient.testConnection(config);
|
||||
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets?project_id=project-1&page_size=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists secret names with their folder prefix", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({
|
||||
secrets: [
|
||||
{ id: "1", name: "db-url", path: "/" },
|
||||
{ id: "2", name: "password", path: "/prod/db" },
|
||||
],
|
||||
total_count: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
const names = await scalewayClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toEqual(["db-url", "prod/db/password"]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("follows pagination until a short page is returned", async () => {
|
||||
const page = (start: number, size: number) =>
|
||||
jsonResponse({
|
||||
secrets: Array.from({ length: size }, (_, index) => ({
|
||||
id: String(start + index),
|
||||
name: `secret-${start + index}`,
|
||||
path: "/",
|
||||
})),
|
||||
});
|
||||
mockFetch.mockImplementation(async (url: string) =>
|
||||
url.includes("page=2") ? page(101, 2) : page(1, 100),
|
||||
);
|
||||
|
||||
const names = await scalewayClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toHaveLength(102);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("resolves env refs end to end through a scaleway provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "scw-prod",
|
||||
providerType: "scaleway",
|
||||
config,
|
||||
assignments: assignedEverywhere,
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(accessResponse("s3cret"));
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_PASSWORD=${{vault.scw-prod.prod/db-password}}",
|
||||
scope,
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -40,6 +40,7 @@ const ENTERPRISE_RESOURCES = [
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Swarm keeps converging a service for a bit after it's created (scheduling
|
||||
// tasks, resolving endpoints), which bumps Version.Index on its own. Calling
|
||||
// setupMonitoring again before that settles races that internal bump, so wait
|
||||
// for two consecutive reads to agree before treating the service as stable.
|
||||
const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastIndex: string | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const inspect = await docker.getService(name).inspect();
|
||||
if (inspect.Version.Index === lastIndex) return;
|
||||
lastIndex = inspect.Version.Index;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
};
|
||||
|
||||
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 waitForServiceConvergence(SERVICE_NAME);
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"deploys the service even when removing the legacy container fails",
|
||||
async () => {
|
||||
const failingDocker = {
|
||||
getContainer: () => ({
|
||||
remove: async () => {
|
||||
const error: any = new Error("device or resource busy");
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
getService: docker.getService.bind(docker),
|
||||
createService: docker.createService.bind(docker),
|
||||
};
|
||||
|
||||
const remoteDocker = await import(
|
||||
"@dokploy/server/utils/servers/remote-docker"
|
||||
);
|
||||
const spy = vi
|
||||
.spyOn(remoteDocker, "getRemoteDocker")
|
||||
.mockResolvedValue(failingDocker as any);
|
||||
|
||||
try {
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -35,6 +35,7 @@ const baseDomain: Domain = {
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("forwardAuthMiddlewareName", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,7 @@ const baseDomain: Domain = {
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
|
||||
@@ -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 /);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -234,7 +234,7 @@ export const HandleRedirect = ({
|
||||
<FormItem>
|
||||
<FormLabel>Replacement</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://mydomain/$${1}" {...field} />
|
||||
<Input placeholder="http://mydomain/$1" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -148,7 +148,7 @@ export const ShowDeployment = ({
|
||||
<DialogDescription className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
See all the details of this deployment |{" "}
|
||||
<Badge variant="blank" className="text-xs">
|
||||
<Badge variant="blank" className="text-xs tabular-nums">
|
||||
{filteredLogs.length} lines
|
||||
</Badge>
|
||||
</span>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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");
|
||||
}}
|
||||
/>
|
||||
|
||||
+13
-5
@@ -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>
|
||||
|
||||
@@ -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,6 +55,7 @@ export const ShowDockerLogsStack = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName && option === "swarm",
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -66,22 +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 =
|
||||
@@ -106,6 +104,7 @@ export const ShowDockerLogsStack = ({
|
||||
<Switch
|
||||
checked={option === "native"}
|
||||
onCheckedChange={(checked) => {
|
||||
setContainerId(undefined);
|
||||
setOption(checked ? "native" : "swarm");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 & 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -14,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,
|
||||
@@ -24,56 +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);
|
||||
fixMacOsAltKeys(term);
|
||||
// @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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 →
|
||||
|
||||
+6
-1
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -17,11 +17,13 @@ import {
|
||||
Download,
|
||||
Globe,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
Server,
|
||||
TrendingUpIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -100,7 +102,12 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data: statsLogs } = api.settings.readStatsLogs.useQuery(
|
||||
const {
|
||||
data: statsLogs,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = api.settings.readStatsLogs.useQuery(
|
||||
{
|
||||
sort: sorting[0],
|
||||
page: pagination,
|
||||
@@ -273,7 +280,18 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{statsLogs?.data.length === 0 && (
|
||||
{isLoading ? (
|
||||
<div className="w-full flex gap-4 items-center justify-center h-[55vh] text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading requests...</span>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="w-full flex items-center justify-center h-[55vh]">
|
||||
<AlertBlock type="error" className="w-full">
|
||||
{error?.message}
|
||||
</AlertBlock>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
|
||||
<span className="text-muted-foreground text-lg font-medium">
|
||||
No results.
|
||||
|
||||
@@ -175,7 +175,7 @@ export const SearchCommand = () => {
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/deployments");
|
||||
router.push("/dashboard/overview?tab=deployments");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -52,13 +52,14 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
serverId,
|
||||
});
|
||||
const { data: registry } = api.registry.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation();
|
||||
|
||||
const haveAtLeastOneRegistry = !!(registry && registry?.length > 0);
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -68,7 +69,7 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
</CardTitle>
|
||||
<CardDescription>Add nodes to your cluster</CardDescription>
|
||||
</div>
|
||||
{haveAtLeastOneRegistry && (
|
||||
{haveAtLeastOneRegistry && permissions?.server.create && (
|
||||
<div className="flex flex-row gap-2">
|
||||
<AddNode serverId={serverId} />
|
||||
</div>
|
||||
@@ -144,34 +145,35 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<ShowNodeData data={node} />
|
||||
{!node?.ManagerStatus?.Leader && (
|
||||
<DialogAction
|
||||
title="Delete Node"
|
||||
description="Are you sure you want to delete this node from the cluster?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteNode({
|
||||
nodeId: node.ID,
|
||||
serverId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Node deleted successfully",
|
||||
);
|
||||
{!node?.ManagerStatus?.Leader &&
|
||||
permissions?.server.delete && (
|
||||
<DialogAction
|
||||
title="Delete Node"
|
||||
description="Are you sure you want to delete this node from the cluster?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteNode({
|
||||
nodeId: node.ID,
|
||||
serverId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting node");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Node deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting node");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const providerLabels = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
|
||||
const DnsProviderSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: "Name is required" })
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: "Only letters, numbers, dashes and underscores",
|
||||
}),
|
||||
providerType: z.enum(["cloudflare", "route53"]),
|
||||
apiToken: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
});
|
||||
|
||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||
|
||||
const defaultValues: DnsProviderForm = {
|
||||
name: "",
|
||||
providerType: "cloudflare",
|
||||
apiToken: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
};
|
||||
|
||||
const buildConfig = (data: DnsProviderForm) => {
|
||||
switch (data.providerType) {
|
||||
case "cloudflare":
|
||||
return {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: data.apiToken,
|
||||
};
|
||||
case "route53":
|
||||
return {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const extractErrorMessage = (err: unknown) => {
|
||||
if (!(err instanceof Error)) return undefined;
|
||||
try {
|
||||
const issues = JSON.parse(err.message) as { message?: string }[];
|
||||
if (Array.isArray(issues)) {
|
||||
return issues
|
||||
.map((issue) => issue.message)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
} catch {}
|
||||
return err.message;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
dnsProviderId?: string;
|
||||
}
|
||||
|
||||
export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data: provider } = api.dnsProvider.one.useQuery(
|
||||
{ dnsProviderId: dnsProviderId || "" },
|
||||
{ enabled: !!dnsProviderId && isOpen },
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending, error, isError } = dnsProviderId
|
||||
? api.dnsProvider.update.useMutation()
|
||||
: api.dnsProvider.create.useMutation();
|
||||
|
||||
const { mutateAsync: testConnection, isPending: isTesting } =
|
||||
api.dnsProvider.testConnection.useMutation();
|
||||
|
||||
const form = useForm<DnsProviderForm>({
|
||||
defaultValues,
|
||||
resolver: zodResolver(DnsProviderSchema),
|
||||
});
|
||||
|
||||
const providerType = form.watch("providerType");
|
||||
|
||||
useEffect(() => {
|
||||
if (provider) {
|
||||
form.reset({
|
||||
...defaultValues,
|
||||
name: provider.name,
|
||||
providerType: provider.config.providerType,
|
||||
...(provider.config.providerType === "cloudflare" && {
|
||||
apiToken: provider.config.apiToken,
|
||||
}),
|
||||
...(provider.config.providerType === "route53" && {
|
||||
accessKeyId: provider.config.accessKeyId,
|
||||
secretAccessKey: provider.config.secretAccessKey,
|
||||
}),
|
||||
});
|
||||
} else if (!dnsProviderId) {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
}, [provider, dnsProviderId, form, isOpen]);
|
||||
|
||||
const onSubmit = async (data: DnsProviderForm) => {
|
||||
const payload: any = {
|
||||
name: data.name,
|
||||
config: buildConfig(data),
|
||||
...(dnsProviderId && { dnsProviderId }),
|
||||
};
|
||||
await mutateAsync(payload)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
dnsProviderId ? "DNS provider updated" : "DNS provider created",
|
||||
);
|
||||
utils.dnsProvider.all.invalidate();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const onTestConnection = async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
const data = form.getValues();
|
||||
await testConnection({
|
||||
config: buildConfig(data),
|
||||
...(dnsProviderId && { dnsProviderId }),
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Connection successful");
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Connection failed", {
|
||||
description: extractErrorMessage(err),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{dnsProviderId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<PenBoxIcon className="size-4 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="cursor-pointer space-x-3">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{dnsProviderId ? "Update DNS Provider" : "Add DNS Provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect a DNS provider to create records for your domains
|
||||
automatically instead of setting them up by hand.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="prod-cloudflare" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={!!dnsProviderId}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(providerLabels).map(([value, label]) => {
|
||||
const ProviderIcon =
|
||||
dnsProviderIcons[value as ProviderType];
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ProviderIcon className="size-4 shrink-0" />
|
||||
{label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{providerType === "cloudflare" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Create a token scoped to Zone → DNS → Edit for the zones
|
||||
you want Dokploy to manage. Avoid the Global API Key.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{providerType === "route53" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessKeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretAccessKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use an IAM user/role scoped to{" "}
|
||||
<code>route53:ListHostedZones</code>,{" "}
|
||||
<code>route53:ListResourceRecordSets</code> and{" "}
|
||||
<code>route53:ChangeResourceRecordSets</code> — avoid
|
||||
root account credentials.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
isLoading={isTesting}
|
||||
onClick={onTestConnection}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{dnsProviderId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DnsRecordSchema = z.object({
|
||||
type: z.enum(["A", "CNAME"]),
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
content: z.string().min(1, { message: "Content is required" }),
|
||||
ttl: z.string(),
|
||||
});
|
||||
|
||||
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
|
||||
|
||||
interface DnsRecordValue {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
zoneName: string;
|
||||
record?: DnsRecordValue;
|
||||
}
|
||||
|
||||
export const HandleDnsRecord = ({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
zoneName,
|
||||
record,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync, isPending, error, isError } = record
|
||||
? api.dnsProvider.updateRecord.useMutation()
|
||||
: api.dnsProvider.createRecord.useMutation();
|
||||
|
||||
const { data: servers } = api.server.all.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const panelIp = panelPublicIp || panelStoredIp;
|
||||
const ipSuggestions = [
|
||||
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
|
||||
...(servers ?? []).map((server) => ({
|
||||
ip: server.ipAddress,
|
||||
label: server.name,
|
||||
})),
|
||||
].filter(
|
||||
(suggestion, index, all) =>
|
||||
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
|
||||
);
|
||||
|
||||
const form = useForm<DnsRecordForm>({
|
||||
defaultValues: record
|
||||
? {
|
||||
type: record.type === "CNAME" ? "CNAME" : "A",
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
|
||||
}
|
||||
: { type: "A", name: "", content: "", ttl: "" },
|
||||
resolver: zodResolver(DnsRecordSchema),
|
||||
});
|
||||
|
||||
const type = form.watch("type");
|
||||
|
||||
const onSubmit = async (data: DnsRecordForm) => {
|
||||
const name = data.name.trim() === "@" ? zoneName : data.name;
|
||||
const payload = {
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
type: data.type,
|
||||
name,
|
||||
content: data.content,
|
||||
ttl: data.ttl ? Number(data.ttl) : undefined,
|
||||
...(record && { recordId: record.id }),
|
||||
};
|
||||
await mutateAsync(payload as any)
|
||||
.then(() => {
|
||||
toast.success(record ? "Record updated" : "Record created");
|
||||
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{record ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 size-6"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm">
|
||||
<PlusIcon className="size-3.5 mr-1.5" />
|
||||
Add Record
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{record ? "Edit Record" : "Add Record"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{record
|
||||
? "Update this DNS record."
|
||||
: "Create a new A or CNAME record in this zone."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="A">A</SelectItem>
|
||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="app.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use <code>@</code> for the root domain.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === "A" && ipSuggestions.length > 0 && (
|
||||
<FormItem>
|
||||
<FormLabel>Fill from server (optional)</FormLabel>
|
||||
<Select
|
||||
onValueChange={(ip) =>
|
||||
form.setValue("content", ip, { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ipSuggestions.map((suggestion) => (
|
||||
<SelectItem key={suggestion.ip} value={suggestion.ip}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>{suggestion.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{suggestion.ip}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{type === "A" ? "IPv4 Address" : "Target"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
type === "A" ? "203.0.113.10" : "app.example.com"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ttl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>TTL (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="Auto" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{record ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleDnsRecord } from "./handle-dns-record";
|
||||
|
||||
interface ZoneRecordsProps {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
zoneName: string;
|
||||
}
|
||||
|
||||
const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: ZoneRecordsProps) => {
|
||||
const utils = api.useUtils();
|
||||
const { data, isLoading, isError, error } =
|
||||
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { mutateAsync: deleteRecord, isPending: isDeleting } =
|
||||
api.dnsProvider.deleteRecord.useMutation();
|
||||
|
||||
const canWrite = !!permissions?.dnsProvider.update;
|
||||
const canDelete = !!permissions?.dnsProvider.delete;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pl-8 pb-2 pr-3">
|
||||
{isLoading && (
|
||||
<div className="flex flex-row gap-2 items-center text-sm text-muted-foreground py-3">
|
||||
<span>Loading records...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive py-2">{error?.message}</p>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No records found in this zone.
|
||||
</p>
|
||||
)}
|
||||
{data?.map((record) => {
|
||||
const isEditable = record.type === "A" || record.type === "CNAME";
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{record.type}
|
||||
</Badge>
|
||||
<span className="font-medium truncate">{record.name}</span>
|
||||
<span className="text-muted-foreground truncate flex-1">
|
||||
→ {record.content}
|
||||
</span>
|
||||
{canWrite && isEditable && (
|
||||
<HandleDnsRecord
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zoneId}
|
||||
zoneName={zoneName}
|
||||
record={record}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Record"
|
||||
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteRecord({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
recordId: record.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Record deleted");
|
||||
utils.dnsProvider.listRecords.invalidate({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the record");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 size-6"
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canWrite && (
|
||||
<div className="pt-1">
|
||||
<HandleDnsRecord
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zoneId}
|
||||
zoneName={zoneName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export const ShowDnsProviderZones = ({
|
||||
dnsProviderId,
|
||||
providerName,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [expandedZoneId, setExpandedZoneId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, error } =
|
||||
api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen });
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setExpandedZoneId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Globe className="size-3.5 mr-1.5" />
|
||||
View Domains
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domains for {providerName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Zones this provider's API token can manage. Click a zone to see and
|
||||
manage its records.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isLoading && (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground py-6">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive py-2">{error?.message}</p>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No zones found for this token. Make sure it has access to at least
|
||||
one zone.
|
||||
</p>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
|
||||
{data.map((zone) => {
|
||||
const isExpanded = expandedZoneId === zone.id;
|
||||
return (
|
||||
<div key={zone.id} className="rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left"
|
||||
onClick={() =>
|
||||
setExpandedZoneId(isExpanded ? null : zone.id)
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<Globe className="size-3.5 text-muted-foreground shrink-0" />
|
||||
{zone.name}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<ZoneRecords
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zone.id}
|
||||
zoneName={zone.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Globe, Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleDnsProvider } from "./handle-dns-provider";
|
||||
import { ShowDnsProviderZones } from "./show-dns-provider-zones";
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
};
|
||||
|
||||
export const ShowDnsProviders = () => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.dnsProvider.remove.useMutation();
|
||||
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Globe className="size-6 text-muted-foreground self-center" />
|
||||
DNS Providers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connect a DNS provider so Dokploy can create the A/CNAME record
|
||||
for a domain instead of you setting it up by hand.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isPending ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<Globe className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any DNS providers configured
|
||||
</span>
|
||||
{permissions?.dnsProvider.create && <HandleDnsProvider />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="flex flex-col gap-4 rounded-lg">
|
||||
{data?.map((provider) => {
|
||||
const ProviderIcon =
|
||||
dnsProviderIcons[provider.providerType];
|
||||
return (
|
||||
<div
|
||||
key={provider.dnsProviderId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<ProviderIcon className="size-7 shrink-0" />
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{provider.name}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{providerLabels[provider.providerType] ??
|
||||
provider.providerType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
<ShowDnsProviderZones
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
providerName={provider.name}
|
||||
/>
|
||||
{permissions?.dnsProvider.update && (
|
||||
<HandleDnsProvider
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.dnsProvider.delete && (
|
||||
<DialogAction
|
||||
title="Delete DNS Provider"
|
||||
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
dnsProviderId: provider.dnsProviderId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("DNS provider deleted");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting the DNS provider",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{permissions?.dnsProvider.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleDnsProvider />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -446,6 +446,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
serverUrl: notification.gotify?.serverUrl,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "ntfy") {
|
||||
form.reset({
|
||||
@@ -681,6 +682,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
decoration: data.decoration,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
gotifyId: notification?.gotifyId || "",
|
||||
});
|
||||
@@ -698,6 +700,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
priority: data.priority,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
ntfyId: notification?.ntfyId || "",
|
||||
});
|
||||
|
||||
+3
-3
@@ -70,9 +70,9 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<p>
|
||||
Runs a full Docker cleanup daily, pruning stopped containers,
|
||||
unused images, volumes, build cache, and system resources. This
|
||||
may remove images built for Compose services that run on-demand
|
||||
(backup runners, cron jobs, one-off tasks).
|
||||
unused images, build cache, and system resources. This may remove
|
||||
images built for Compose services that run on-demand (backup
|
||||
runners, cron jobs, one-off tasks).
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
For custom cleanup strategies, use{" "}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Stethoscope } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const ShowHealthModal = ({ serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex gap-4 py-4 w-full">
|
||||
<ShowHealth serverId={serverId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -35,6 +35,7 @@ import { TerminalModal } from "../web-server/terminal-modal";
|
||||
import { ShowServerActions } from "./actions/show-server-actions";
|
||||
import { HandleServers } from "./handle-servers";
|
||||
import { SetupServer } from "./setup-server";
|
||||
import { ShowHealthModal } from "./show-health-modal";
|
||||
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
||||
|
||||
@@ -249,29 +250,30 @@ export const ShowServers = () => {
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
{server.sshKeyId && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
{server.sshKeyId &&
|
||||
permissions?.server.terminal && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TerminalModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Terminal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TerminalModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Terminal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -324,6 +326,24 @@ export const ShowServers = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{permissions?.docker.read &&
|
||||
permissions?.server.read &&
|
||||
server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ShowHealthModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Health</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{permissions?.server.delete && (
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowUpDown, Loader2, LogOut, Smartphone } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
const SortableHeader = ({
|
||||
column,
|
||||
title,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
title: string;
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{title}
|
||||
<ArrowUpDown className="ml-2 size-3.5" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const ShowSessions = () => {
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const isOwner = auth?.role === "owner";
|
||||
const {
|
||||
data: sessions,
|
||||
isPending,
|
||||
refetch,
|
||||
} = api.user.listSessions.useQuery();
|
||||
const { mutateAsync: revoke, isPending: isRevoking } =
|
||||
api.user.revokeSession.useMutation();
|
||||
const { data: members } = api.user.all.useQuery(undefined, {
|
||||
enabled: isOwner,
|
||||
});
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<
|
||||
"all" | "active" | "expired"
|
||||
>("all");
|
||||
const [userFilter, setUserFilter] = useState("all");
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "createdAt", desc: true },
|
||||
]);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const handleRevoke = async (sessionId: string) => {
|
||||
try {
|
||||
await revoke({ sessionId });
|
||||
toast.success("Session revoked successfully");
|
||||
refetch();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to revoke session",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let list = sessions ?? [];
|
||||
|
||||
if (statusFilter !== "all") {
|
||||
list = list.filter((s) => {
|
||||
const isExpired = new Date(s.expiresAt) <= new Date();
|
||||
return statusFilter === "expired" ? isExpired : !isExpired;
|
||||
});
|
||||
}
|
||||
|
||||
if (isOwner && userFilter !== "all") {
|
||||
list = list.filter((s) => s.userId === userFilter);
|
||||
}
|
||||
|
||||
if (globalFilter.trim()) {
|
||||
const query = globalFilter.toLowerCase();
|
||||
list = list.filter((s) =>
|
||||
[s.ipAddress, s.userAgent ? parseUserAgent(s.userAgent) : null]
|
||||
.filter(Boolean)
|
||||
.some((field) => field?.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [sessions, statusFilter, userFilter, isOwner, globalFilter]);
|
||||
|
||||
const columns = useMemo<ColumnDef<SessionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "user",
|
||||
accessorFn: (row) => `${row.firstName} ${row.lastName} ${row.email}`,
|
||||
header: ({ column }) => <SortableHeader column={column} title="User" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>
|
||||
{row.original.firstName} {row.original.lastName}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
({row.original.email})
|
||||
</span>
|
||||
{row.original.isCurrent && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
Current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "ipAddress",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="IP Address" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">
|
||||
{row.original.ipAddress || "-"}
|
||||
</span>
|
||||
),
|
||||
meta: { className: "hidden md:table-cell" },
|
||||
},
|
||||
{
|
||||
id: "device",
|
||||
accessorFn: (row) =>
|
||||
row.userAgent ? parseUserAgent(row.userAgent) : "",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Device" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="max-w-[200px] truncate text-sm text-muted-foreground">
|
||||
{row.original.userAgent
|
||||
? parseUserAgent(row.original.userAgent)
|
||||
: "-"}
|
||||
</span>
|
||||
),
|
||||
meta: { className: "hidden lg:table-cell" },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorFn: (row) => (new Date(row.expiresAt) <= new Date() ? 1 : 0),
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isExpired = new Date(row.original.expiresAt) <= new Date();
|
||||
return isExpired ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Expired
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="green" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Active Since" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{format(new Date(row.original.createdAt), "MMM d, HH:mm")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "expiresAt",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Expires" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{format(new Date(row.original.expiresAt), "MMM d, HH:mm")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableSorting: false,
|
||||
header: () => <div className="text-right">Actions</div>,
|
||||
cell: ({ row }) =>
|
||||
!row.original.isCurrent && (
|
||||
<div className="flex justify-end">
|
||||
<DialogAction
|
||||
title="Revoke Session"
|
||||
description={`Force logout for ${row.original.firstName} ${row.original.lastName} (${row.original.email})?`}
|
||||
type="destructive"
|
||||
onClick={async () => handleRevoke(row.original.id)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={isRevoking}
|
||||
>
|
||||
<LogOut className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isRevoking, handleRevoke],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Smartphone className="size-6 text-muted-foreground self-center" />
|
||||
Sessions
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isOwner
|
||||
? "Manage active sessions across your organization. Revoke sessions to force logout."
|
||||
: "Manage your active sessions. Revoke sessions to force logout."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-8 border-t">
|
||||
{isPending ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : !sessions || sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<Smartphone className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No sessions found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by IP, device..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) =>
|
||||
setStatusFilter(v as "all" | "active" | "expired")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="expired">Expired</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isOwner && (
|
||||
<Select value={userFilter} onValueChange={setUserFilter}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="User" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All users</SelectItem>
|
||||
{members?.map((m) => (
|
||||
<SelectItem key={m.user.id} value={m.user.id}>
|
||||
{m.user.firstName} {m.user.lastName} ({m.user.email}
|
||||
)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={
|
||||
(header.column.columnDef.meta as any)?.className
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
(cell.column.columnDef.meta as any)?.className
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No sessions match your filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{table.getPageCount() > 1 && (
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function parseUserAgent(ua: string): string {
|
||||
if (ua.includes("Chrome/") && !ua.includes("Edg/")) return "Chrome";
|
||||
if (ua.includes("Edg/")) return "Edge";
|
||||
if (ua.includes("Firefox/")) return "Firefox";
|
||||
if (ua.includes("Safari/") && !ua.includes("Chrome/")) return "Safari";
|
||||
if (ua.includes("curl/") || ua.includes("wget/")) return "CLI";
|
||||
return ua.slice(0, 40) + (ua.length > 40 ? "..." : "");
|
||||
}
|
||||
@@ -53,9 +53,14 @@ type Tag = z.infer<typeof TagSchema>;
|
||||
|
||||
interface HandleTagProps {
|
||||
tagId?: string;
|
||||
/**
|
||||
* Called with the new tag's id after it is created, so callers embedding
|
||||
* this dialog (e.g. the tag selector) can select the tag right away.
|
||||
*/
|
||||
onCreated?: (tagId: string) => void;
|
||||
}
|
||||
|
||||
export const HandleTag = ({ tagId }: HandleTagProps) => {
|
||||
export const HandleTag = ({ tagId, onCreated }: HandleTagProps) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -101,8 +106,11 @@ export const HandleTag = ({ tagId }: HandleTagProps) => {
|
||||
color: data.color,
|
||||
tagId: tagId || "",
|
||||
})
|
||||
.then(async () => {
|
||||
.then(async (result) => {
|
||||
await utils.tag.all.invalidate();
|
||||
if (!tagId && result?.tagId) {
|
||||
onCreated?.(result.tagId);
|
||||
}
|
||||
toast.success(tagId ? "Tag Updated" : "Tag Created");
|
||||
setIsOpen(false);
|
||||
form.reset();
|
||||
@@ -139,9 +147,18 @@ export const HandleTag = ({ tagId }: HandleTagProps) => {
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
{/*
|
||||
* This dialog can be rendered inside another form (e.g. the tag
|
||||
* selector in the project dialog). React propagates events through the
|
||||
* React tree, not the DOM tree, so without stopPropagation submitting
|
||||
* this form would also submit the outer one.
|
||||
*/}
|
||||
<form
|
||||
id="hook-form-tag"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onSubmit={(e) => {
|
||||
e.stopPropagation();
|
||||
form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
|
||||
@@ -106,6 +106,7 @@ export const AddInvitation = () => {
|
||||
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
|
||||
api.user.createUserWithCredentials.useMutation();
|
||||
const { data: customRoles } = api.customRole.all.useQuery();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<AddInvitation>({
|
||||
@@ -132,6 +133,16 @@ export const AddInvitation = () => {
|
||||
}
|
||||
}, [form, isCloud]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeOrganization?.defaultRole &&
|
||||
activeOrganization.defaultRole !== "owner" &&
|
||||
!form.formState.dirtyFields.role
|
||||
) {
|
||||
form.setValue("role", activeOrganization.defaultRole);
|
||||
}
|
||||
}, [form, activeOrganization?.defaultRole]);
|
||||
|
||||
const onSubmit = async (data: AddInvitation) => {
|
||||
setError(null);
|
||||
|
||||
@@ -267,10 +278,7 @@ export const AddInvitation = () => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
|
||||
@@ -35,7 +35,7 @@ import { ChangeRole } from "./change-role";
|
||||
export const ShowUsers = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isPending, refetch } = api.user.all.useQuery();
|
||||
const { mutateAsync } = api.user.remove.useMutation();
|
||||
const { mutateAsync, isPending: isRemoving } = api.user.remove.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: hasValidLicense } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
@@ -237,6 +237,7 @@ export const ShowUsers = () => {
|
||||
title="Delete User"
|
||||
description="Are you sure you want to delete this user?"
|
||||
type="destructive"
|
||||
disabled={isRemoving}
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
@@ -269,6 +270,7 @@ export const ShowUsers = () => {
|
||||
title="Unlink User"
|
||||
description="Are you sure you want to unlink this user?"
|
||||
type="destructive"
|
||||
disabled={isRemoving}
|
||||
onClick={async () => {
|
||||
if (!isCloud) {
|
||||
const orgCount =
|
||||
|
||||
@@ -42,6 +42,7 @@ const providerLabels = {
|
||||
aws: "AWS Secrets Manager",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
@@ -55,7 +56,14 @@ const VaultProviderSchema = z
|
||||
message:
|
||||
"Only letters, numbers, dashes and underscores (used in ${{vault.<name>.<secret>}})",
|
||||
}),
|
||||
providerType: z.enum(["hashicorp", "infisical", "aws", "doppler", "azure"]),
|
||||
providerType: z.enum([
|
||||
"hashicorp",
|
||||
"infisical",
|
||||
"aws",
|
||||
"doppler",
|
||||
"azure",
|
||||
"scaleway",
|
||||
]),
|
||||
url: z.string(),
|
||||
token: z.string(),
|
||||
namespace: z.string(),
|
||||
@@ -77,6 +85,10 @@ const VaultProviderSchema = z
|
||||
azureClientSecret: z.string(),
|
||||
dopplerProject: z.string(),
|
||||
dopplerConfig: z.string(),
|
||||
scalewayRegion: z.string(),
|
||||
scalewayProjectId: z.string(),
|
||||
scalewaySecretKey: z.string(),
|
||||
scalewayApiUrl: z.string(),
|
||||
assignments: z.array(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -127,6 +139,17 @@ const VaultProviderSchema = z
|
||||
path: ["awsEndpoint"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "scaleway" &&
|
||||
data.scalewayApiUrl &&
|
||||
!isValidUrl(data.scalewayApiUrl)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Enter a valid URL (e.g. https://api.scaleway.com)",
|
||||
path: ["scalewayApiUrl"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "infisical" &&
|
||||
data.siteUrl &&
|
||||
@@ -166,6 +189,11 @@ const VaultProviderSchema = z
|
||||
["azureClientId", "Client ID is required"],
|
||||
["azureClientSecret", "Client Secret is required"],
|
||||
],
|
||||
scaleway: [
|
||||
["scalewayRegion", "Region is required"],
|
||||
["scalewayProjectId", "Project ID is required"],
|
||||
["scalewaySecretKey", "Secret Key is required"],
|
||||
],
|
||||
};
|
||||
|
||||
for (const [field, message] of required[data.providerType] ?? []) {
|
||||
@@ -222,6 +250,10 @@ const defaultValues: VaultProviderForm = {
|
||||
azureClientSecret: "",
|
||||
dopplerProject: "",
|
||||
dopplerConfig: "",
|
||||
scalewayRegion: "fr-par",
|
||||
scalewayProjectId: "",
|
||||
scalewaySecretKey: "",
|
||||
scalewayApiUrl: "https://api.scaleway.com",
|
||||
assignments: [],
|
||||
};
|
||||
|
||||
@@ -268,6 +300,14 @@ const buildConfig = (data: VaultProviderForm) => {
|
||||
clientId: data.azureClientId,
|
||||
clientSecret: data.azureClientSecret,
|
||||
};
|
||||
case "scaleway":
|
||||
return {
|
||||
providerType: "scaleway" as const,
|
||||
region: data.scalewayRegion || "fr-par",
|
||||
projectId: data.scalewayProjectId,
|
||||
secretKey: data.scalewaySecretKey,
|
||||
apiUrl: data.scalewayApiUrl || "https://api.scaleway.com",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -383,6 +423,12 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
azureClientId: provider.config.clientId,
|
||||
azureClientSecret: provider.config.clientSecret,
|
||||
}),
|
||||
...(provider.config.providerType === "scaleway" && {
|
||||
scalewayRegion: provider.config.region,
|
||||
scalewayProjectId: provider.config.projectId,
|
||||
scalewaySecretKey: provider.config.secretKey,
|
||||
scalewayApiUrl: provider.config.apiUrl,
|
||||
}),
|
||||
});
|
||||
} else if (!vaultProviderId) {
|
||||
form.reset(defaultValues);
|
||||
@@ -859,6 +905,97 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "scaleway" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scalewayRegion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="fr-par" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{["fr-par", "nl-ams", "pl-waw"].map((region) => (
|
||||
<SelectItem key={region} value={region}>
|
||||
{region}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scalewayProjectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scalewaySecretKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The secret key of an API key with the{" "}
|
||||
<code>SecretManagerReadOnly</code> permission set
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scalewayApiUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.scaleway.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.secret-name}}"}</code>,{" "}
|
||||
<code>{"${{vault.<name>.folder/secret-name}}"}</code> for
|
||||
secrets in a path, or{" "}
|
||||
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
|
||||
secrets
|
||||
</FormDescription>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<FormLabel>Access</FormLabel>
|
||||
<FormDescription>
|
||||
|
||||
@@ -20,6 +20,7 @@ const providerLabels: Record<string, string> = {
|
||||
aws: "AWS Secrets Manager",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
};
|
||||
|
||||
export const ShowVaultProviders = () => {
|
||||
|
||||
@@ -41,11 +41,22 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
});
|
||||
|
||||
const addonFit = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
term.loadAddon(clipboardAddon);
|
||||
fixMacOsAltKeys(term);
|
||||
|
||||
// @ts-ignore
|
||||
term.open(termRef.current);
|
||||
// @ts-ignore
|
||||
term.loadAddon(addonFit);
|
||||
addonFit.fit();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set("serverId", serverId);
|
||||
urlParams.set("cols", term.cols.toString());
|
||||
urlParams.set("rows", term.rows.toString());
|
||||
|
||||
if (serverId === "local") {
|
||||
const { port, username } = getLocalServerData();
|
||||
@@ -57,17 +68,22 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const addonAttach = new AttachAddon(ws);
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
term.loadAddon(clipboardAddon);
|
||||
fixMacOsAltKeys(term);
|
||||
|
||||
// @ts-ignore
|
||||
term.open(termRef.current);
|
||||
// @ts-ignore
|
||||
term.loadAddon(addonFit);
|
||||
term.loadAddon(addonAttach);
|
||||
addonFit.fit();
|
||||
|
||||
const sendResize = (cols: number, rows: number) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
};
|
||||
term.onResize(({ cols, rows }) => sendResize(cols, rows));
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => addonFit.fit());
|
||||
if (termRef.current) {
|
||||
resizeObserver.observe(termRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
ws.readyState === WebSocket.OPEN && ws.close();
|
||||
};
|
||||
}, [id, serverId]);
|
||||
|
||||
@@ -35,7 +35,7 @@ export const DocLinks = () => (
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -85,7 +85,7 @@ export const SwarmNotAvailable = ({
|
||||
<li>
|
||||
Check the{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -130,7 +130,7 @@ export const ServicesError = ({
|
||||
<li>
|
||||
Network connectivity issues to a remote server — check{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -183,7 +183,7 @@ export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => (
|
||||
Worker nodes need to pull images from a shared registry. Configure one
|
||||
in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -255,7 +255,7 @@ export const NoRunningContainers = ({
|
||||
<li>
|
||||
Images can't be pulled on worker nodes — verify your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
registry configuration
|
||||
|
||||
@@ -303,7 +303,7 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {
|
||||
<p className="mt-2 text-xs">
|
||||
Manage nodes in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
|
||||
@@ -51,7 +51,9 @@ export default function SwarmMonitorCard({ serverId }: Props) {
|
||||
{!serverId && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.location.replace("/dashboard/settings/cluster")
|
||||
window.location.replace(
|
||||
"/dashboard/docker?tab=swarm&subtab=nodes",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -76,7 +76,7 @@ export const MariadbIcon = ({ className }: Props) => {
|
||||
>
|
||||
<path
|
||||
d="M38.1637 8.19202C38.0639 8.10928 37.9372 8.0659 37.8075 8.07003C37.4539 8.07003 36.9964 8.314 36.7512 8.43598L36.6524 8.486C36.2404 8.68747 35.7917 8.80285 35.3337 8.8251C34.8629 8.83973 34.4567 8.8678 33.9309 8.92147C30.8009 9.2435 29.4115 11.6392 28.0685 13.9557C27.3365 15.2157 26.5827 16.5173 25.5472 17.5273C25.333 17.735 25.1052 17.9282 24.8652 18.1055C23.7942 18.9045 22.4487 19.468 21.3974 19.8705C20.3897 20.256 19.2894 20.6025 18.2257 20.9367C17.2499 21.2442 16.3338 21.5332 15.4885 21.8467C15.1067 21.9893 14.7822 22.0907 14.4968 22.1918C13.7271 22.4493 13.1708 22.6335 12.3609 23.1885C12.0449 23.4043 11.7278 23.6398 11.507 23.8155C10.8638 24.3293 10.2945 24.9295 9.81509 25.5988C9.40495 26.2163 8.93065 26.7888 8.4001 27.3067C8.22932 27.475 7.92559 27.5505 7.47059 27.5505C6.93754 27.5505 6.29102 27.4408 5.6067 27.3248C4.90287 27.2028 4.17342 27.081 3.54887 27.081C3.0402 27.081 2.6523 27.1627 2.36077 27.3322C2.36077 27.3322 1.87284 27.6177 1.66669 27.986L1.86917 28.0775C2.18275 28.2468 2.47374 28.455 2.73525 28.6972C3.00602 28.9482 3.3082 29.1632 3.63425 29.3363C3.7385 29.3782 3.83284 29.4413 3.91115 29.5218C3.82577 29.6438 3.70012 29.8072 3.5696 29.9792C2.84747 30.9233 2.42664 31.521 2.66815 31.8455C2.78092 31.9047 2.90685 31.934 3.0341 31.931C4.60767 31.931 5.45179 31.5223 6.52157 31.0038C6.83507 30.8623 7.15587 30.7075 7.52182 30.5488C8.14637 30.278 8.81972 29.845 9.53209 29.3877C10.4762 28.7777 11.4521 28.1532 12.3975 27.8507C13.1762 27.6123 13.9875 27.4978 14.8017 27.5115C15.8056 27.5115 16.8547 27.647 17.8695 27.7762C18.627 27.8738 19.4102 27.9738 20.1787 28.0202C20.4775 28.0373 20.7544 28.0458 21.0229 28.0458C21.3825 28.047 21.7422 28.0283 22.0999 27.9897L22.1854 27.9592C22.7245 27.6287 22.977 26.9175 23.2222 26.2295C23.3795 25.7867 23.5112 25.3903 23.7162 25.1317C23.729 25.1195 23.743 25.1085 23.7577 25.0987C23.7674 25.0933 23.7787 25.0913 23.7899 25.093C23.8009 25.0948 23.811 25.1003 23.8187 25.1085C23.8187 25.1085 23.8187 25.1085 23.8187 25.128C23.6894 27.8177 22.611 29.5193 21.5157 31.0417L20.7837 31.826C20.7837 31.826 21.8072 31.826 22.389 31.6015C24.5139 30.966 26.1192 29.5657 27.2865 27.3322C27.5745 26.7592 27.8319 26.1712 28.0575 25.5708C28.077 25.5208 28.2624 25.428 28.2442 25.6928C28.2442 25.7697 28.2332 25.855 28.227 25.9367C28.227 25.9892 28.2197 26.0428 28.2174 26.0965C28.1869 26.4685 28.0954 27.2652 28.0954 27.2652L28.7515 26.9127C30.3374 25.9098 31.5572 23.896 32.4794 20.7562C32.8649 19.4485 33.1465 18.1507 33.3954 17.004C33.693 15.6341 33.9505 14.4509 34.2494 13.9935C34.7104 13.2762 35.4142 12.7895 36.0949 12.3199L36.373 12.1259C37.2269 11.5258 38.0807 10.8305 38.2687 9.53748V9.50942C38.4102 8.55065 38.2942 8.30423 38.1637 8.19202Z"
|
||||
fill="#231F20"
|
||||
fill="#c2bfbf"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -211,6 +211,15 @@ export const LibsqlIcon = ({ className }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const DB_ENGINE_ICONS = {
|
||||
postgres: PostgresqlIcon,
|
||||
mariadb: MariadbIcon,
|
||||
mysql: MysqlIcon,
|
||||
mongo: MongodbIcon,
|
||||
redis: RedisIcon,
|
||||
libsql: LibsqlIcon,
|
||||
} as const;
|
||||
|
||||
export const GitlabIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CloudflareIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Route53Icon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 304 182"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m86 66 2 9c0 3 1 5 3 8v2l-1 3-7 4-2 1-3-1-4-5-3-6c-8 9-18 14-29 14-9 0-16-3-20-8-5-4-8-11-8-19s3-15 9-20c6-6 14-8 25-8a79 79 0 0 1 22 3v-7c0-8-2-13-5-16-3-4-8-5-16-5l-11 1a80 80 0 0 0-14 5h-2c-1 0-2-1-2-3v-5l1-3c0-1 1-2 3-2l12-5 16-2c12 0 20 3 26 8 5 6 8 14 8 25v32zM46 82l10-2c4-1 7-4 10-7l3-6 1-9v-4a84 84 0 0 0-19-2c-6 0-11 1-15 4-3 2-4 6-4 11s1 8 3 11c3 2 6 4 11 4zm80 10-4-1-2-3-23-78-1-4 2-2h10l4 1 2 4 17 66 15-66 2-4 4-1h8l4 1 2 4 16 67 17-67 2-4 4-1h9c2 0 3 1 3 2v2l-1 2-24 78-2 4-4 1h-9l-4-1-1-4-16-65-15 64-2 4-4 1h-9zm129 3a66 66 0 0 1-27-6l-3-3-1-2v-5c0-2 1-3 2-3h2l3 1a54 54 0 0 0 23 5c6 0 11-2 14-4 4-2 5-5 5-9l-2-7-10-5-15-5c-7-2-13-6-16-10a24 24 0 0 1 5-34l10-5a44 44 0 0 1 20-2 110 110 0 0 1 12 3l4 2 3 2 1 4v4c0 3-1 4-2 4l-4-2c-6-2-12-3-19-3-6 0-11 0-14 2s-4 5-4 9c0 3 1 5 3 7s5 4 11 6l14 4c7 3 12 6 15 10s5 9 5 14l-3 12-7 8c-3 3-7 5-11 6l-14 2z"
|
||||
/>
|
||||
<path
|
||||
d="M274 144A220 220 0 0 1 4 124c-4-3-1-6 2-4a300 300 0 0 0 263 16c5-2 10 4 5 8z"
|
||||
fill="#f90"
|
||||
/>
|
||||
<path
|
||||
d="M287 128c-4-5-28-3-38-1-4 0-4-3-1-5 19-13 50-9 53-5 4 5-1 36-18 51-3 2-6 1-5-2 5-10 13-33 9-38z"
|
||||
fill="#f90"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const dnsProviderIcons = {
|
||||
cloudflare: CloudflareIcon,
|
||||
route53: Route53Icon,
|
||||
} as const;
|
||||
@@ -571,10 +571,22 @@ export const AzureIcon = ({ className }: Props) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ScalewayIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M16.605 11.11v5.72a1.77 1.77 0 01-1.54 1.69h-4a1.43 1.43 0 01-1.31-1.22 1.09 1.09 0 010-.18 1.37 1.37 0 011.37-1.36h1.74a1 1 0 001-1v-3.62a1.4 1.4 0 011.18-1.39h.17a1.37 1.37 0 011.39 1.36zm-6.46 1.74V9.26a1 1 0 011-1h1.85a1.37 1.37 0 001.37-1.37 1 1 0 000-.17 1.45 1.45 0 00-1.41-1.2h-3.96a1.81 1.81 0 00-1.58 1.66v5.7a1.37 1.37 0 001.37 1.37h.21a1.4 1.4 0 001.15-1.4zm12-4.29V20a4.53 4.53 0 01-4.15 4h-7.58a8.57 8.57 0 01-8.56-8.57V4.54A4.54 4.54 0 016.395 0h7.18a8.56 8.56 0 018.56 8.56zm-2.74 0a5.83 5.83 0 00-5.82-5.82h-7.19a1.79 1.79 0 00-1.8 1.8v10.89a5.83 5.83 0 005.82 5.8h7.44a1.79 1.79 0 001.54-1.48z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const vaultProviderIcons = {
|
||||
hashicorp: HashicorpVaultIcon,
|
||||
infisical: InfisicalIcon,
|
||||
aws: AwsIcon,
|
||||
doppler: DopplerIcon,
|
||||
azure: AzureIcon,
|
||||
scaleway: ScalewayIcon,
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Head from "next/head";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
||||
import Page from "./side";
|
||||
@@ -8,9 +10,11 @@ interface Props {
|
||||
metaName?: string;
|
||||
}
|
||||
|
||||
export const DashboardLayout = ({ children }: Props) => {
|
||||
export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const { data: currentPlan } = api.stripe.getCurrentPlan.useQuery(undefined, {
|
||||
enabled: isCloud === true,
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -22,6 +26,13 @@ export const DashboardLayout = ({ children }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{metaName && (
|
||||
<Head>
|
||||
<title>
|
||||
{metaName} | {appName}
|
||||
</title>
|
||||
</Head>
|
||||
)}
|
||||
<Page>{children}</Page>
|
||||
{isChatEnabled && (
|
||||
<>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user