mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
379 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 165746ca46 | |||
| 5be17d3dd0 | |||
| 5aa28bc7d3 | |||
| 20575da9fe | |||
| 06b3bb282a | |||
| 5c19d1b704 | |||
| 2bc3741e41 | |||
| 35d26a806b | |||
| a4a13b90b8 | |||
| 48504fde4e | |||
| 84a1d7093c | |||
| 90d522c846 | |||
| c6a2b2437d | |||
| cec5b65cd6 | |||
| 52f61adfe8 | |||
| 169bac2ef8 | |||
| 916bb50e73 | |||
| 0d3a3966b0 | |||
| 560e790e73 | |||
| ff2e454dca | |||
| 82545bf500 | |||
| 302754bd3b | |||
| 6e7934fc55 | |||
| c18609a127 | |||
| 618a7df25c | |||
| 1c6d333739 | |||
| 72f2c4d5d1 | |||
| e8be2693e0 | |||
| 4396451095 | |||
| 7dc066a7bd | |||
| a0b6f233cb | |||
| d0ec74fbb8 | |||
| 382fdfc478 | |||
| c231c2979b | |||
| 3951247ebd | |||
| 7b89d1f948 | |||
| 6215c7f159 | |||
| 52ea2ce071 | |||
| a0f1a5119c | |||
| a0e0574bcd | |||
| c453ac3a2f | |||
| 143dfcafe3 | |||
| 3563f585f9 | |||
| f2ba262499 | |||
| f397c1a862 | |||
| 16ac4633f3 | |||
| d7d75f7c5c | |||
| a0e1001808 | |||
| 8236c31fcd | |||
| 037230496c | |||
| 14e059784c | |||
| f34151bc12 | |||
| d00ae31920 | |||
| 95b2712c26 | |||
| 318ad36967 | |||
| a87a5c06f0 | |||
| e120340e77 | |||
| 0d6e984b54 | |||
| 1e9873b3e4 | |||
| 26317e9126 | |||
| 853ca33659 | |||
| a90c2a662f | |||
| 45dcdfc5be | |||
| 29cdc815db | |||
| 9fa98de9c1 | |||
| a4a1d15656 | |||
| 8075a7ce95 | |||
| 326908869d | |||
| 18d07c7254 | |||
| 4cd4b83523 | |||
| f91b2e2ca6 | |||
| e70c880612 | |||
| 0c1dfe978c | |||
| 88118cdb3f | |||
| 38d157adc0 | |||
| 3b1fec5409 | |||
| 49b8214e5d | |||
| ec31739e0e | |||
| d5328703fa | |||
| f4cb26cbf4 | |||
| a6c1f3048f | |||
| 9cae609a0a | |||
| 2c234d6e20 | |||
| 9dcdb53aa1 | |||
| bda8124291 | |||
| 6dcd0e1859 | |||
| 85786a3196 | |||
| ea3f9f3b53 | |||
| e99a98e4fe | |||
| 5611662351 | |||
| 887a457f15 | |||
| df4e299545 | |||
| aaff71bbf6 | |||
| be128d5a4a | |||
| 467c80a73d | |||
| 2d5a6ef679 | |||
| 8291165259 | |||
| e701644fa5 | |||
| 94cd5035ef | |||
| ea1c6fc3fe | |||
| df0986b4e1 | |||
| caca3c19ae | |||
| 91db4e60c4 | |||
| 1e6ab0fab7 | |||
| 67f6890fc8 | |||
| 988f5b64d6 | |||
| e369541a16 | |||
| 7c1889205c | |||
| 0f28775a1e | |||
| 6ed5746983 | |||
| e8f94a94da | |||
| 150c34dc0a | |||
| 91eaab6a43 | |||
| f0f7843ece | |||
| 6e760da93b | |||
| 0c19973fbf | |||
| 600657ec62 | |||
| 383438e4fc | |||
| 57a5465d89 | |||
| ab87f2daad | |||
| 3b065db8a0 | |||
| 96778433db | |||
| f25810fa72 | |||
| 8409b8ce60 | |||
| 57d92b75de | |||
| 7e9fdbb5ff | |||
| 2311f346d4 | |||
| e9623119bd | |||
| d7884c7ce9 | |||
| 341f38bba6 | |||
| 28f71e727a | |||
| 29e5940e8d | |||
| 41251b4f1e | |||
| 15bf663aad | |||
| 13b043edd9 | |||
| 48dfb2b820 | |||
| 62e948a804 | |||
| b6c83e082e | |||
| b5c3d8660e | |||
| 5c0d3b84af | |||
| 7effae35cb | |||
| 7fb02abb3e | |||
| 631816f8d1 | |||
| 1a29694481 | |||
| 19589694ea | |||
| 1572008cdf | |||
| 5f10ed6884 | |||
| 399e20c1ed | |||
| 5faa363f7a | |||
| 5cbc7efc61 | |||
| 12ea7a8920 | |||
| 782bb4e249 | |||
| f4e1154d31 | |||
| bf547e2fd0 | |||
| 9568a4f9f8 | |||
| b839e6d6be | |||
| 7ae566f4de | |||
| ea9a466c01 | |||
| d1fda6368c | |||
| d389b3c8d0 | |||
| 0889d763ef | |||
| bbca400a6d | |||
| 03cdd00fa8 | |||
| ad00669f0f | |||
| f58ff1062e | |||
| c180f7502c | |||
| 4877c4ffa3 | |||
| dfa9c17d7d | |||
| e79ed7691f | |||
| e25e241cf5 | |||
| f617561d0b | |||
| f904a7bd55 | |||
| 261ebb2317 | |||
| c901260d81 | |||
| 8c9e473b4e | |||
| e2c9bd2833 | |||
| 8b38acdebd | |||
| 6dacab06cd | |||
| b2ed974422 | |||
| 62e43b164b | |||
| 4746cfc1ca | |||
| 18e617eb14 | |||
| f67fab0675 | |||
| 344febb4c1 | |||
| fff5612c2e | |||
| c5f12927b5 | |||
| 56eaa93b91 | |||
| e67d9efb38 | |||
| c32c8d01a9 | |||
| 1d26fdd027 | |||
| 0dcfcc19da | |||
| 2e2e0c8c29 | |||
| a424f64370 | |||
| 3893150f5c | |||
| 8806d4ef80 | |||
| 6d51c100ae | |||
| 13bb1ac5d1 | |||
| 5606a2bd1e | |||
| 58f3714ddc | |||
| 6282b6ef39 | |||
| f83097b80f | |||
| 59a74849a8 | |||
| 27dab3f4bf | |||
| d56ef890ae | |||
| b75785dc6a | |||
| 80a6baf58e | |||
| be4a5da405 | |||
| 2bc22c520d | |||
| e629248dea | |||
| ff3ebefc70 | |||
| c0f07f1427 | |||
| da3c0b1326 | |||
| c225188ed4 | |||
| 6239073e2a | |||
| a42614004f | |||
| e1a432d5c8 | |||
| 02760212f7 | |||
| 0478f34be5 | |||
| 8b5c14ca74 | |||
| 50acb791cd | |||
| 3a6e18be79 | |||
| 23b15801d5 | |||
| 78fe5e3a33 | |||
| 8cd2ba80f2 | |||
| 8639605b03 | |||
| 1443500b9d | |||
| 9c444ebca3 | |||
| e7eb20d92f | |||
| f1a4f4317a | |||
| 1ab4a8a70a | |||
| 53a6e926d6 | |||
| 43178b6442 | |||
| a194e1e27b | |||
| 50479f20ff | |||
| 4453c55086 | |||
| 3c95912001 | |||
| 5624dff5c1 | |||
| d7ea9cf9bc | |||
| 68a05551d0 | |||
| 57c221de6f | |||
| 48e0b747b6 | |||
| 4e38925119 | |||
| 91bcf88df9 | |||
| a63157cd51 | |||
| 47308070a8 | |||
| a1c57011a4 | |||
| ec8e645758 | |||
| e8f4a1715c | |||
| 880bb55272 | |||
| a36739eb07 | |||
| 1ff9294009 | |||
| 142082fe89 | |||
| 5345c00ff3 | |||
| d181384833 | |||
| 1f5f5576f1 | |||
| d6ec4bd3d1 | |||
| 7e9b77e93f | |||
| 8db39a8d15 | |||
| 714bf03244 | |||
| dada3049a8 | |||
| 0971e1952a | |||
| e0a9b83a39 | |||
| ccfc44815e | |||
| f1e2467bb9 | |||
| 3f6dfbaaa7 | |||
| e79239a100 | |||
| 9e3d6fdf2c | |||
| cabfe2e81e | |||
| b0ab9c77fb | |||
| 2f7f50aec5 | |||
| 532de2c59e | |||
| 542eced075 | |||
| 4b2de31b1c | |||
| 4b7a6295fc | |||
| a4e29172f4 | |||
| 22450abc4b | |||
| 976161647f | |||
| bfc180dfef | |||
| 628b14d07a | |||
| 0efe7feaf5 | |||
| 474081a264 | |||
| 1053743a1c | |||
| 355b856185 | |||
| a1cc2778f3 | |||
| fee0071223 | |||
| 3334edef73 | |||
| cf0e8d236c | |||
| 9d9b41c1e5 | |||
| bdf33750a3 | |||
| a66684a784 | |||
| abc2439ec4 | |||
| 5b6a8bd1ef | |||
| d26f70642d | |||
| e2d2dbb00a | |||
| e4012d857d | |||
| ab62080e9d | |||
| 3a8fad57e6 | |||
| 203cddbeef | |||
| 032e804114 | |||
| ca3da4dc5d | |||
| 87b914996f | |||
| 8f3230e2ed | |||
| cebd380856 | |||
| a822db5967 | |||
| 71816d0853 | |||
| 85ed75a5c6 | |||
| f46cd4601c | |||
| 2a585933e9 | |||
| 62e6a4c017 | |||
| 4710c28db5 | |||
| 63efbffcfd | |||
| 523e8f4e4f | |||
| b67519ebd5 | |||
| 452b7aa2f2 | |||
| 3c6a96a30a | |||
| 546686ea35 | |||
| 217ca07842 | |||
| c6f81ce60d | |||
| bd8ba128cd | |||
| 72a554de5f | |||
| cda047feb2 | |||
| feccb51287 | |||
| 1ccd633153 | |||
| 8be1e68fe1 | |||
| 875baa139c | |||
| e2ab2eb6bc | |||
| d675db10bb | |||
| 903789bfe5 | |||
| d1273e2fe9 | |||
| 74d2d41961 | |||
| ede4626396 | |||
| 772b768217 | |||
| d3d8d9ec83 | |||
| f4dfb6a903 | |||
| bbde5ebbc3 | |||
| 38e855bc5e | |||
| 62f701425a | |||
| 130be40b49 | |||
| db2eb4f60a | |||
| 9c4ad14c70 | |||
| bee6918d9a | |||
| d7c152af0d | |||
| 5ff0c26954 | |||
| b4edb56132 | |||
| 9dab15ad47 | |||
| e3f4310755 | |||
| 0be822ef5b | |||
| a8e0477b70 | |||
| 5fa963fe87 | |||
| 3054cf53be | |||
| 8c2d4d5c19 | |||
| 80859dc5b6 | |||
| 60612b0986 | |||
| 49055a05b1 | |||
| 2221b8a99c | |||
| c77f6d889d | |||
| a5b8626345 | |||
| 479e8bb551 | |||
| c6ab76fe09 | |||
| 2598a62771 | |||
| 529f54e2a5 | |||
| 8144e1a2fe | |||
| d11735d4cd | |||
| f5c21db1ef | |||
| 679dbf98cb | |||
| 6204415dc5 | |||
| 09ea766fb5 | |||
| 5344945f83 | |||
| f23b5b9532 | |||
| 79baa24763 | |||
| 6dc22bdeed | |||
| ac2e081134 | |||
| 26d716b34f | |||
| 2a7668662f | |||
| 49bf39dc18 | |||
| 0393c2635b | |||
| c668f6c7bd | |||
| 36ba9fcb3e | |||
| 24cc455168 |
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"worktree": {
|
||||||
|
"baseRef": "fresh"
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "EnterWorktree",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: fix-issue
|
||||||
|
description: Implement a GitHub issue with reproduction and verification
|
||||||
|
allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
|
||||||
|
---
|
||||||
|
|
||||||
|
The issue number is passed as $1.
|
||||||
|
|
||||||
|
## Instance
|
||||||
|
|
||||||
|
No instance is running yet — start your own, isolated to this worktree:
|
||||||
|
|
||||||
|
1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
|
||||||
|
2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
|
||||||
|
background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
|
||||||
|
until it answers (usually ~10-15s).
|
||||||
|
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
|
||||||
|
|
||||||
|
Note: `mcp__dokploy__*` resolves its URL from
|
||||||
|
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
||||||
|
discovered mid-session. If those tools are unavailable or point at the wrong
|
||||||
|
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
||||||
|
to relaunch with `DOKPLOY_BASE_URL` exported first.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up
|
||||||
|
state (create a project, an app, an env var) and to verify backend behavior.
|
||||||
|
Search for the tool you need; they are not all loaded upfront.
|
||||||
|
- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything
|
||||||
|
a user would see or click.
|
||||||
|
|
||||||
|
Pick by where the bug lives, not by convenience:
|
||||||
|
|
||||||
|
- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright.
|
||||||
|
The API returning correct data proves nothing here.
|
||||||
|
- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP.
|
||||||
|
A green screenshot proves nothing here.
|
||||||
|
- Unclear → do both.
|
||||||
|
|
||||||
|
Use the MCP to reach the state you need quickly, then verify in the UI. Do not
|
||||||
|
click through ten screens to create a project the API can create in one call.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Run `gh issue view $1` and read the full issue, including comments.
|
||||||
|
2. Reproduce the bug with the appropriate tool. If you cannot reproduce it,
|
||||||
|
comment on the issue explaining what you tried and STOP.
|
||||||
|
Do not implement anything.
|
||||||
|
3. Implement the fix. Keep the change minimal and scoped to the issue.
|
||||||
|
4. Run `pnpm test`, then re-run the same reproduction from step 2.
|
||||||
|
5. Only if both pass: commit and run `gh pr create`. The PR description must
|
||||||
|
include the before/after reproduction steps and reference the issue.
|
||||||
|
|
||||||
|
Never skip step 2. A fix you cannot reproduce and then verify is not a fix.
|
||||||
@@ -14,34 +14,34 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Get version from package.json
|
- name: Get version from package.json
|
||||||
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
|
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Get latest GitHub tag
|
- name: Get latest GitHub tag
|
||||||
run: |
|
run: |
|
||||||
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
|
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
|
||||||
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
|
||||||
echo $LATEST_TAG
|
echo "$LATEST_TAG"
|
||||||
- name: Compare versions
|
- name: Compare versions
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
|
if [ "$VERSION" != "$LATEST_TAG" ]; then
|
||||||
VERSION_CHANGED="true"
|
VERSION_CHANGED="true"
|
||||||
else
|
else
|
||||||
VERSION_CHANGED="false"
|
VERSION_CHANGED="false"
|
||||||
fi
|
fi
|
||||||
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
|
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
|
||||||
echo "Comparing versions:"
|
echo "Comparing versions:"
|
||||||
echo "Current version: ${{ env.VERSION }}"
|
echo "Current version: $VERSION"
|
||||||
echo "Latest tag: ${{ env.LATEST_TAG }}"
|
echo "Latest tag: $LATEST_TAG"
|
||||||
echo "Version changed: $VERSION_CHANGED"
|
echo "Version changed: $VERSION_CHANGED"
|
||||||
- name: Check if a PR already exists
|
- name: Check if a PR already exists
|
||||||
run: |
|
run: |
|
||||||
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
|
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
|
||||||
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
|
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GH_PAT }}
|
GH_TOKEN: ${{ secrets.GH_PAT }}
|
||||||
|
|
||||||
@@ -56,12 +56,12 @@ jobs:
|
|||||||
git push origin canary
|
git push origin canary
|
||||||
|
|
||||||
gh pr create \
|
gh pr create \
|
||||||
--title "🚀 Release ${{ env.VERSION }}" \
|
--title "🚀 Release $VERSION" \
|
||||||
--body '
|
--body "
|
||||||
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
|
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
|
||||||
|
|
||||||
### 🔍 Changes Include:
|
### 🔍 Changes Include:
|
||||||
- Version bump to ${{ env.VERSION }}
|
- Version bump to $VERSION
|
||||||
- All changes from canary branch
|
- All changes from canary branch
|
||||||
|
|
||||||
### ✅ Pre-merge Checklist:
|
### ✅ Pre-merge Checklist:
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
- [ ] Documentation updated
|
- [ ] Documentation updated
|
||||||
- [ ] Docker images built and tested
|
- [ ] Docker images built and tested
|
||||||
|
|
||||||
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
|
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
|
||||||
--base main \
|
--base main \
|
||||||
--head canary \
|
--head canary \
|
||||||
--label "release" --label "automated pr" || true \
|
--label "release" --label "automated pr" || true \
|
||||||
|
|||||||
@@ -5,33 +5,38 @@ on:
|
|||||||
branches: [main, canary]
|
branches: [main, canary]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-cloud-image:
|
build-and-push-cloud-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta-cloud
|
id: meta-cloud
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.cloud
|
file: ./Dockerfile.cloud
|
||||||
@@ -48,26 +53,28 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta-schedule
|
id: meta-schedule
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.schedule
|
file: ./Dockerfile.schedule
|
||||||
@@ -80,26 +87,28 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta-server
|
id: meta-server
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.server
|
file: ./Dockerfile.server
|
||||||
|
|||||||
@@ -8,18 +8,23 @@ on:
|
|||||||
env:
|
env:
|
||||||
IMAGE_NAME: dokploy/dokploy
|
IMAGE_NAME: dokploy/dokploy
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-amd:
|
docker-amd:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -27,15 +32,15 @@ jobs:
|
|||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Prepare env file
|
- name: Prepare env file
|
||||||
run: |
|
run: |
|
||||||
@@ -43,7 +48,7 @@ jobs:
|
|||||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
@@ -53,13 +58,15 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -68,15 +75,15 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Prepare env file
|
- name: Prepare env file
|
||||||
run: |
|
run: |
|
||||||
@@ -84,7 +91,7 @@ jobs:
|
|||||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
@@ -96,20 +103,22 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create and push manifests
|
- name: Create and push manifests
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
|
|
||||||
@@ -121,7 +130,7 @@ jobs:
|
|||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
${IMAGE_NAME}:${TAG}-arm64
|
${IMAGE_NAME}:${TAG}-arm64
|
||||||
|
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
@@ -138,68 +147,42 @@ jobs:
|
|||||||
needs: [combine-manifests]
|
needs: [combine-manifests]
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: get_version
|
id: get_version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
|
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Fetch install.sh
|
- name: Fetch install.sh
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
||||||
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
||||||
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
||||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_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@v3
|
||||||
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 }}
|
||||||
body_path: release-notes.md
|
generate_release_notes: true
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
files: install.sh
|
files: install.sh
|
||||||
@@ -210,15 +193,19 @@ jobs:
|
|||||||
needs: [generate-release]
|
needs: [generate-release]
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: 10.22.0
|
version: 10.22.0
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 24.4.0
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
@@ -233,63 +220,63 @@ jobs:
|
|||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||||
cd /tmp/mcp-repo
|
cd /tmp/mcp-repo
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
|
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
echo "✅ MCP repo synced to version $NPM_VERSION"
|
||||||
|
|
||||||
- name: Sync version to CLI repository
|
- name: Sync version to CLI repository
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||||
cd /tmp/cli-repo
|
cd /tmp/cli-repo
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
echo "✅ CLI repo synced to version $NPM_VERSION"
|
||||||
|
|
||||||
- name: Sync version to SDK repository
|
- name: Sync version to SDK repository
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
||||||
cd /tmp/sdk-repo
|
cd /tmp/sdk-repo
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
echo "✅ SDK repo synced to version $NPM_VERSION"
|
||||||
|
|||||||
@@ -6,15 +6,20 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [canary]
|
branches: [canary]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup biomeJs
|
- name: Setup biomeJs
|
||||||
uses: biomejs/setup-biome@v2
|
uses: biomejs/setup-biome@v2
|
||||||
|
|
||||||
- name: Run Biome formatter
|
- name: Run Biome formatter
|
||||||
run: biome format --write
|
run: biome format --write
|
||||||
|
|||||||
@@ -16,17 +16,19 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||||
|
|
||||||
- name: Cherry-pick fix to main
|
- name: Cherry-pick fix to main
|
||||||
|
env:
|
||||||
|
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||||
run: |
|
run: |
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
SHA="${{ github.event.pull_request.merge_commit_sha }}"
|
SHA="$MERGE_SHA"
|
||||||
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
||||||
git cherry-pick -x -m 1 "$SHA"
|
git cherry-pick -x -m 1 "$SHA"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ concurrency:
|
|||||||
group: hotfix-to-main
|
group: hotfix-to-main
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||||
|
|||||||
@@ -7,18 +7,23 @@ on:
|
|||||||
env:
|
env:
|
||||||
IMAGE_NAME: dokploy/monitoring
|
IMAGE_NAME: dokploy/monitoring
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-amd:
|
docker-amd:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -26,17 +31,17 @@ jobs:
|
|||||||
- name: Set tag
|
- name: Set tag
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.monitoring
|
file: ./Dockerfile.monitoring
|
||||||
@@ -47,13 +52,15 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -61,17 +68,17 @@ jobs:
|
|||||||
- name: Set
|
- name: Set
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.monitoring
|
file: ./Dockerfile.monitoring
|
||||||
@@ -84,27 +91,29 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create and push manifests
|
- name: Create and push manifests
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
|
|
||||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
${IMAGE_NAME}:${TAG}-arm64
|
${IMAGE_NAME}:${TAG}-arm64
|
||||||
|
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
job: [build, test, typecheck]
|
job: [build, test, typecheck]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v5
|
with:
|
||||||
- uses: actions/setup-node@v5
|
persist-credentials: false
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 24.4.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -12,17 +12,20 @@ on:
|
|||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate-and-commit:
|
generate-and-commit:
|
||||||
name: Generate OpenAPI and commit to Dokploy repo
|
name: Generate OpenAPI and commit to Dokploy repo
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Dokploy repository
|
- name: Checkout Dokploy repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
persist-credentials: false
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v6
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 24.4.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ env:
|
|||||||
DOKPLOY_SERVICE: dokploy
|
DOKPLOY_SERVICE: dokploy
|
||||||
DOKPLOY_PORT: 3000
|
DOKPLOY_PORT: 3000
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
@@ -421,8 +424,10 @@ jobs:
|
|||||||
# ── Job summary ────────────────────────────────────────────────────────
|
# ── Job summary ────────────────────────────────────────────────────────
|
||||||
- name: Write job summary
|
- name: Write job summary
|
||||||
if: always()
|
if: always()
|
||||||
|
env:
|
||||||
|
JOB_STATUS: ${{ job.status }}
|
||||||
run: |
|
run: |
|
||||||
STATUS="${{ job.status }}"
|
STATUS="$JOB_STATUS"
|
||||||
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
|
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
|
||||||
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
|
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
|
||||||
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
## Code style
|
||||||
|
- Don't write comments that restate what the code already says.
|
||||||
|
- Comment only the "why" when something isn't obvious: workarounds,
|
||||||
|
counterintuitive decisions, constraints from an external API.
|
||||||
|
- No section-divider comments like `// --- Helpers ---`.
|
||||||
|
- Don't leave comments describing the change you just made.
|
||||||
+10
-2
@@ -120,12 +120,20 @@ pnpm run docker:push
|
|||||||
|
|
||||||
## Password Reset
|
## Password Reset
|
||||||
|
|
||||||
In the case you lost your password, you can reset it using the following command
|
In the case you lost your password, you can reset the owner's password using the following command
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run reset-password
|
pnpm --filter=dokploy run reset-password
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To reset the password of a specific user instead, pass their email as an argument
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter=dokploy run reset-password user@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Both commands print the new randomly generated password to the console.
|
||||||
|
|
||||||
If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/)
|
If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -6,3 +6,13 @@ npm run dev
|
|||||||
```
|
```
|
||||||
open http://localhost:3000
|
open http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Inngest
|
||||||
|
|
||||||
|
This service uses Inngest to queue deployments. Run the local dev server in a separate terminal:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run dev:inngest
|
||||||
|
```
|
||||||
|
|
||||||
|
It syncs with the app at `http://localhost:4000/api/inngest` and serves its dashboard at `http://localhost:8288`.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=4000 tsx watch src/index.ts",
|
"dev": "PORT=4000 tsx watch src/index.ts",
|
||||||
|
"dev:inngest": "npx --yes inngest-cli@1.9.0 dev -u http://localhost:4000/api/inngest --no-discovery",
|
||||||
"build": "rimraf dist && tsc --project tsconfig.json",
|
"build": "rimraf dist && tsc --project tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockUpdateApplication = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/permission", () => {
|
||||||
|
return {
|
||||||
|
checkServicePermissionAndAccess: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/index", () => ({
|
||||||
|
IS_CLOUD: true,
|
||||||
|
hasValidLicense: vi.fn(async () => false),
|
||||||
|
updateApplication: mockUpdateApplication,
|
||||||
|
findApplicationById: vi.fn(async () => ({
|
||||||
|
applicationId: "app-1",
|
||||||
|
appName: "example-app",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||||
|
validateRequest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/api/utils/audit", () => ({
|
||||||
|
audit: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { applicationRouter } = await import("@/server/api/routers/application");
|
||||||
|
|
||||||
|
const caller = applicationRouter.createCaller({
|
||||||
|
session: { activeOrganizationId: "org-1" },
|
||||||
|
user: { id: "user-1", email: "user@example.com", role: "owner" },
|
||||||
|
} as Parameters<typeof applicationRouter.createCaller>[0]);
|
||||||
|
|
||||||
|
const providerChanges = [
|
||||||
|
{
|
||||||
|
name: "GitHub",
|
||||||
|
sourceType: "github",
|
||||||
|
save: () =>
|
||||||
|
caller.saveGithubProvider({
|
||||||
|
applicationId: "app-1",
|
||||||
|
repository: "example-repo",
|
||||||
|
owner: "example-owner",
|
||||||
|
branch: "main",
|
||||||
|
buildPath: "/",
|
||||||
|
githubId: "provider-1",
|
||||||
|
watchPaths: ["src/**"],
|
||||||
|
triggerType: "push",
|
||||||
|
enableSubmodules: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GitLab",
|
||||||
|
sourceType: "gitlab",
|
||||||
|
save: () =>
|
||||||
|
caller.saveGitlabProvider({
|
||||||
|
applicationId: "app-1",
|
||||||
|
gitlabRepository: "example-repo",
|
||||||
|
gitlabOwner: "example-owner",
|
||||||
|
gitlabBranch: "main",
|
||||||
|
gitlabBuildPath: "/",
|
||||||
|
gitlabId: "provider-1",
|
||||||
|
gitlabProjectId: 1,
|
||||||
|
gitlabPathNamespace: "example-owner/example-repo",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bitbucket",
|
||||||
|
sourceType: "bitbucket",
|
||||||
|
save: () =>
|
||||||
|
caller.saveBitbucketProvider({
|
||||||
|
applicationId: "app-1",
|
||||||
|
bitbucketRepository: "example-repo",
|
||||||
|
bitbucketRepositorySlug: "example-repo",
|
||||||
|
bitbucketOwner: "example-owner",
|
||||||
|
bitbucketBranch: "main",
|
||||||
|
bitbucketBuildPath: "/",
|
||||||
|
bitbucketId: "provider-1",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Gitea",
|
||||||
|
sourceType: "gitea",
|
||||||
|
save: () =>
|
||||||
|
caller.saveGiteaProvider({
|
||||||
|
applicationId: "app-1",
|
||||||
|
giteaRepository: "example-repo",
|
||||||
|
giteaOwner: "example-owner",
|
||||||
|
giteaBranch: "main",
|
||||||
|
giteaBuildPath: "/",
|
||||||
|
giteaId: "provider-1",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Docker",
|
||||||
|
sourceType: "docker",
|
||||||
|
save: () =>
|
||||||
|
caller.saveDockerProvider({
|
||||||
|
applicationId: "app-1",
|
||||||
|
dockerImage: "nginx:alpine",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
registryUrl: "",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom Git",
|
||||||
|
sourceType: "git",
|
||||||
|
save: () =>
|
||||||
|
caller.saveGitProvider({
|
||||||
|
applicationId: "app-1",
|
||||||
|
customGitUrl: "https://example.com/repo.git",
|
||||||
|
customGitBranch: "main",
|
||||||
|
customGitBuildPath: "/",
|
||||||
|
customGitSSHKeyId: "key-1",
|
||||||
|
watchPaths: ["src/**"],
|
||||||
|
enableSubmodules: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disconnect Git provider",
|
||||||
|
sourceType: "github",
|
||||||
|
save: () => caller.disconnectGitProvider({ applicationId: "app-1" }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("saving an application provider", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(providerChanges)(
|
||||||
|
"keeps the status when $name settings change",
|
||||||
|
async ({ save, sourceType }) => {
|
||||||
|
for (const status of ["done", "running"]) {
|
||||||
|
mockUpdateApplication.mockClear();
|
||||||
|
let savedStatus = status;
|
||||||
|
mockUpdateApplication.mockImplementation(async (_id, update) => {
|
||||||
|
if (update.applicationStatus !== undefined) {
|
||||||
|
savedStatus = update.applicationStatus;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await save();
|
||||||
|
|
||||||
|
expect(savedStatus).toBe(status);
|
||||||
|
expect(mockUpdateApplication).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockUpdateApplication).toHaveBeenCalledWith(
|
||||||
|
"app-1",
|
||||||
|
expect.objectContaining({ sourceType }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import type { Destination } from "@dokploy/server/services/destination";
|
||||||
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
||||||
|
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("redactRcloneCredentials (#4621)", () => {
|
describe("redactRcloneCredentials (#4621)", () => {
|
||||||
@@ -48,3 +50,48 @@ describe("redactRcloneCredentials (#4621)", () => {
|
|||||||
expect(redacted).toContain("[REDACTED]");
|
expect(redacted).toContain("[REDACTED]");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
|
||||||
|
const buildCommand = (accessKey: string, secretAccessKey: string) =>
|
||||||
|
`rclone rcat ${getS3Credentials({
|
||||||
|
accessKey,
|
||||||
|
secretAccessKey,
|
||||||
|
region: "us-west-001",
|
||||||
|
endpoint: "https://s3.us-west-001.backblazeb2.com",
|
||||||
|
provider: "Other",
|
||||||
|
} as Destination).join(" ")} :s3:bucket/file.gz`;
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"plain alphanumeric",
|
||||||
|
"001aaaabbbbccccdd0000000001",
|
||||||
|
"K001FAKEfakeFAKEfake",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"containing slashes",
|
||||||
|
"AKIAIOSFODNN7EXAMPLE",
|
||||||
|
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||||
|
],
|
||||||
|
["containing spaces", "key with space", "secret with space"],
|
||||||
|
["containing quotes", "it's-a-key", `say "hi" it's`],
|
||||||
|
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
|
||||||
|
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
|
||||||
|
const redacted = redactRcloneCredentials(
|
||||||
|
buildCommand(accessKey, secretAccessKey),
|
||||||
|
);
|
||||||
|
expect(redacted).toContain(
|
||||||
|
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
|
||||||
|
);
|
||||||
|
expect(redacted).not.toContain(accessKey);
|
||||||
|
expect(redacted).not.toContain(secretAccessKey);
|
||||||
|
expect(redacted).toContain(":s3:bucket/file.gz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts credentials embedded in an error string", () => {
|
||||||
|
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
|
||||||
|
const redacted = redactRcloneCredentials(errorStr);
|
||||||
|
expect(redacted).not.toContain("MYKEY");
|
||||||
|
expect(redacted).not.toContain("MY/SECRET");
|
||||||
|
expect(redacted).toContain("[REDACTED]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { createRestartSafeBackupCommand } from "@dokploy/server/utils/volume-backups/backup";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const runCommand = (command: string) =>
|
||||||
|
spawnSync("bash", ["-c", command], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputLines = (stdout: string) =>
|
||||||
|
stdout
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
|
describe("createRestartSafeBackupCommand", () => {
|
||||||
|
it("restarts the service and preserves the backup error status", () => {
|
||||||
|
const result = runCommand(
|
||||||
|
createRestartSafeBackupCommand({
|
||||||
|
stopCommand: 'echo "stop"',
|
||||||
|
backupCommand: 'echo "backup"; exit 23',
|
||||||
|
startCommand: 'echo "start"',
|
||||||
|
uploadCommand: 'echo "upload"',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe(23);
|
||||||
|
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the backup error status when the restart also fails", () => {
|
||||||
|
const result = runCommand(
|
||||||
|
createRestartSafeBackupCommand({
|
||||||
|
stopCommand: 'echo "stop"',
|
||||||
|
backupCommand: 'echo "backup"; exit 23',
|
||||||
|
startCommand: 'echo "start"; exit 17',
|
||||||
|
uploadCommand: 'echo "upload"',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe(23);
|
||||||
|
expect(outputLines(result.stdout)).toEqual([
|
||||||
|
"stop",
|
||||||
|
"backup",
|
||||||
|
"start",
|
||||||
|
"Service restart also failed with exit code 17",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the restart error status when the backup succeeds", () => {
|
||||||
|
const result = runCommand(
|
||||||
|
createRestartSafeBackupCommand({
|
||||||
|
stopCommand: 'echo "stop"',
|
||||||
|
backupCommand: 'echo "backup"',
|
||||||
|
startCommand: 'echo "start"; exit 17',
|
||||||
|
uploadCommand: 'echo "upload"',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe(17);
|
||||||
|
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads only after a successful backup and service restart", () => {
|
||||||
|
const result = runCommand(
|
||||||
|
createRestartSafeBackupCommand({
|
||||||
|
stopCommand: 'echo "stop"',
|
||||||
|
backupCommand: 'echo "backup"',
|
||||||
|
startCommand: 'echo "start"',
|
||||||
|
uploadCommand: 'echo "upload"',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(outputLines(result.stdout)).toEqual([
|
||||||
|
"stop",
|
||||||
|
"backup",
|
||||||
|
"start",
|
||||||
|
"upload",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("Remote Server Action Tooltips", () => {
|
||||||
|
it("should close the tooltip when the dialog trigger is clicked", () => {
|
||||||
|
// Note: The UI layer in this project is not currently tested with DOM simulation tools
|
||||||
|
// like React Testing Library or JSDOM.
|
||||||
|
//
|
||||||
|
// The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild)
|
||||||
|
// inherently guarantees that Radix UI's composeEventHandlers properly propagates the
|
||||||
|
// onPointerDown event to dismiss the tooltip simultaneously as the dialog opens.
|
||||||
|
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
composeType: "docker-compose" as const,
|
||||||
|
appName: "compose-app",
|
||||||
|
sourceType: "github" as const,
|
||||||
|
command: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("compose createCommand --project-directory", () => {
|
||||||
|
it("pins --project-directory to the code dir when composePath is nested", () => {
|
||||||
|
const cmd = createCommand(
|
||||||
|
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
|
||||||
|
"/etc/dokploy/compose/compose-app/code",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cmd).toContain(
|
||||||
|
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||||
|
);
|
||||||
|
expect(cmd).toContain("-f ./deploy/docker-compose.yml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits --project-directory when no projectPath is passed", () => {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
composePath: "./deploy/docker-compose.yml",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(cmd).not.toContain("--project-directory");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add --project-directory to stack deploy (unsupported flag)", () => {
|
||||||
|
const cmd = createCommand(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
composeType: "stack",
|
||||||
|
composePath: "./deploy/docker-compose.yml",
|
||||||
|
} as any,
|
||||||
|
"/etc/dokploy/compose/compose-app/code",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cmd).not.toContain("--project-directory");
|
||||||
|
expect(cmd.startsWith("stack deploy")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => {
|
||||||
|
const cmd = createCommand(
|
||||||
|
{ ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any,
|
||||||
|
"/etc/dokploy/compose/compose-app/code",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cmd).toContain(
|
||||||
|
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||||
|
);
|
||||||
|
expect(cmd).toContain("-f docker-compose.yml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves build.context against the compose file's own directory when there are no mounts", () => {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
composePath: "./backend/docker-compose.yml",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(cmd).not.toContain("--project-directory");
|
||||||
|
expect(cmd).toContain("-f ./backend/docker-compose.yml");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compose createCommand --env-file", () => {
|
||||||
|
it("points --env-file at the generated .env next to a nested compose file", () => {
|
||||||
|
const cmd = createCommand(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
composePath: "./deploy/docker-compose.yml",
|
||||||
|
createEnvFile: true,
|
||||||
|
} as any,
|
||||||
|
"/etc/dokploy/compose/compose-app/code",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cmd).toContain("--env-file deploy/.env");
|
||||||
|
expect(cmd).toContain(
|
||||||
|
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits --env-file when createEnvFile is disabled", () => {
|
||||||
|
const cmd = createCommand(
|
||||||
|
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
|
||||||
|
"/etc/dokploy/compose/compose-app/code",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cmd).not.toContain("--env-file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the code-root .env for raw sourceType", () => {
|
||||||
|
const cmd = createCommand(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
sourceType: "raw",
|
||||||
|
composePath: "docker-compose.yml",
|
||||||
|
createEnvFile: true,
|
||||||
|
} as any,
|
||||||
|
"/etc/dokploy/compose/compose-app/code",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cmd).toContain("--env-file .env");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add --env-file to stack deploy (unsupported flag)", () => {
|
||||||
|
const cmd = createCommand(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
composeType: "stack",
|
||||||
|
composePath: "./deploy/docker-compose.yml",
|
||||||
|
createEnvFile: true,
|
||||||
|
} as any,
|
||||||
|
"/etc/dokploy/compose/compose-app/code",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cmd).not.toContain("--env-file");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
composeType: "docker-compose" as const,
|
||||||
|
appName: "compose-app",
|
||||||
|
sourceType: "github" as const,
|
||||||
|
composePath: "./docker-compose.yml",
|
||||||
|
command: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("compose createCommand --pull always", () => {
|
||||||
|
it("adds --pull always when pullImages is enabled", () => {
|
||||||
|
const cmd = createCommand({ ...base, pullImages: true } as any);
|
||||||
|
|
||||||
|
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits --pull when pullImages is disabled", () => {
|
||||||
|
const cmd = createCommand({ ...base, pullImages: false } as any);
|
||||||
|
|
||||||
|
expect(cmd).not.toContain("--pull");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add --pull to stack deploy", () => {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
composeType: "stack",
|
||||||
|
pullImages: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(cmd).not.toContain("--pull");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a custom command untouched", () => {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
command: "compose -p compose-app up -d",
|
||||||
|
pullImages: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(cmd).toBe("compose -p compose-app up -d");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,6 +32,9 @@ const cases: Record<string, string> = {
|
|||||||
APOSTROPHE: "it's a test",
|
APOSTROPHE: "it's a test",
|
||||||
UNICODE: "héllo wörld 日本語 🚀",
|
UNICODE: "héllo wörld 日本語 🚀",
|
||||||
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
|
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
|
||||||
|
APP_URL: "https://example.com",
|
||||||
|
ASSET_URL: "https://example.com",
|
||||||
|
DB_HOST: "localhost",
|
||||||
};
|
};
|
||||||
|
|
||||||
// How each value must be typed in the UI so the (unchanged) dotenv input
|
// How each value must be typed in the UI so the (unchanged) dotenv input
|
||||||
@@ -46,9 +49,21 @@ const inputEncoding: Record<string, string> = {
|
|||||||
APOSTROPHE: "it's a test",
|
APOSTROPHE: "it's a test",
|
||||||
UNICODE: "héllo wörld 日本語 🚀",
|
UNICODE: "héllo wörld 日本語 🚀",
|
||||||
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
|
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
|
||||||
|
APP_URL: "https://example.com",
|
||||||
|
ASSET_URL: '"${APP_URL}"',
|
||||||
|
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("getCreateEnvFileCommand", () => {
|
const hasDocker = () => {
|
||||||
|
try {
|
||||||
|
execFileSync("docker", ["info"], { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
|
||||||
it("writes special environment values that Docker Compose reads back literally", () => {
|
it("writes special environment values that Docker Compose reads back literally", () => {
|
||||||
mkdirSync(codePath, { recursive: true });
|
mkdirSync(codePath, { recursive: true });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { Compose, ComposeSpecification } from "@dokploy/server";
|
||||||
|
import {
|
||||||
|
addAppNameToPreventCollision,
|
||||||
|
addSuffixToAllProperties,
|
||||||
|
} from "@dokploy/server";
|
||||||
|
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
||||||
|
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { stringify } from "yaml";
|
||||||
|
import composeSpec from "../../../components/shared/compose-spec.json";
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<
|
||||||
|
typeof import("@dokploy/server/utils/process/execAsync")
|
||||||
|
>()),
|
||||||
|
execAsyncRemote: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modelCompose = {
|
||||||
|
services: {
|
||||||
|
app: {
|
||||||
|
image: "my-chat-app",
|
||||||
|
models: ["llm"],
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
image: "my-worker",
|
||||||
|
models: {
|
||||||
|
llm: {
|
||||||
|
endpoint_var: "LLM_URL",
|
||||||
|
model_var: "LLM_MODEL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
llm: {
|
||||||
|
model: "ai/smollm2",
|
||||||
|
context_size: 2048,
|
||||||
|
runtime_flags: ["--verbose"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ComposeSpecification;
|
||||||
|
|
||||||
|
test("bundled compose schema declares models", () => {
|
||||||
|
expect(composeSpec.properties).toHaveProperty("models");
|
||||||
|
expect(composeSpec.definitions.model.required).toEqual(["model"]);
|
||||||
|
expect(composeSpec.definitions.service.properties).toHaveProperty("models");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("suffixing does not rename model identifiers or drop model config", () => {
|
||||||
|
const updated = addSuffixToAllProperties(
|
||||||
|
structuredClone(modelCompose),
|
||||||
|
"testhash",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
||||||
|
expect(updated.models?.llm?.context_size).toBe(2048);
|
||||||
|
expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]);
|
||||||
|
expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]);
|
||||||
|
expect(updated.services?.["worker-testhash"]?.models).toEqual({
|
||||||
|
llm: {
|
||||||
|
endpoint_var: "LLM_URL",
|
||||||
|
model_var: "LLM_MODEL",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updated.services).not.toHaveProperty("app");
|
||||||
|
expect(updated.models).not.toHaveProperty("llm-testhash");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isolated deployment preserves model identifiers", () => {
|
||||||
|
const updated = addAppNameToPreventCollision(
|
||||||
|
structuredClone(modelCompose),
|
||||||
|
"chat-app",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
||||||
|
expect(updated.services?.app?.models).toEqual(["llm"]);
|
||||||
|
expect(updated.services?.worker?.models).toEqual({
|
||||||
|
llm: {
|
||||||
|
endpoint_var: "LLM_URL",
|
||||||
|
model_var: "LLM_MODEL",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("raw remote compose conversion preserves models", async () => {
|
||||||
|
const converted = await addDomainToCompose(
|
||||||
|
{
|
||||||
|
appName: "chat-app",
|
||||||
|
composeFile: stringify(modelCompose, { lineWidth: 1000 }),
|
||||||
|
composePath: "./docker-compose.yml",
|
||||||
|
composeType: "docker-compose",
|
||||||
|
isolatedDeployment: false,
|
||||||
|
isolatedDeploymentsVolume: false,
|
||||||
|
randomize: false,
|
||||||
|
serverId: "remote-server",
|
||||||
|
sourceType: "raw",
|
||||||
|
suffix: "",
|
||||||
|
} as unknown as Compose,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||||
|
expect(converted?.models?.llm?.model).toBe("ai/smollm2");
|
||||||
|
expect(converted?.services?.app?.models).toEqual(["llm"]);
|
||||||
|
expect(converted?.services?.worker?.models).toEqual({
|
||||||
|
llm: {
|
||||||
|
endpoint_var: "LLM_URL",
|
||||||
|
model_var: "LLM_MODEL",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockUpdateCompose = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/permission", () => ({
|
||||||
|
checkServicePermissionAndAccess: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server", () => ({
|
||||||
|
IS_CLOUD: true,
|
||||||
|
findComposeById: vi.fn(async () => ({
|
||||||
|
composeId: "compose-1",
|
||||||
|
name: "example-compose",
|
||||||
|
})),
|
||||||
|
updateCompose: mockUpdateCompose,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||||
|
validateRequest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/api/utils/audit", () => ({
|
||||||
|
audit: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/queues/queueSetup", () => ({
|
||||||
|
cleanQueuesByCompose: vi.fn(),
|
||||||
|
killDockerBuild: vi.fn(),
|
||||||
|
myQueue: {
|
||||||
|
add: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { composeRouter } = await import("@/server/api/routers/compose");
|
||||||
|
|
||||||
|
const caller = composeRouter.createCaller({
|
||||||
|
session: { activeOrganizationId: "org-1" },
|
||||||
|
user: { id: "user-1", email: "user@example.com", role: "owner" },
|
||||||
|
} as Parameters<typeof composeRouter.createCaller>[0]);
|
||||||
|
|
||||||
|
describe("saving a compose provider", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["done", "running"])(
|
||||||
|
"keeps the %s status when disconnecting a Git provider",
|
||||||
|
async (status) => {
|
||||||
|
let savedStatus = status;
|
||||||
|
mockUpdateCompose.mockImplementation(async (_id, update) => {
|
||||||
|
if (update.composeStatus !== undefined) {
|
||||||
|
savedStatus = update.composeStatus;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await caller.disconnectGitProvider({ composeId: "compose-1" });
|
||||||
|
|
||||||
|
expect(savedStatus).toBe(status);
|
||||||
|
expect(mockUpdateCompose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockUpdateCompose).toHaveBeenCalledWith(
|
||||||
|
"compose-1",
|
||||||
|
expect.objectContaining({ sourceType: "github" }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -50,6 +50,15 @@ describe("getRailpackCommand", () => {
|
|||||||
expect(command).toContain("--build-arg cache-key=");
|
expect(command).toContain("--build-arg cache-key=");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("installs Railpack through sudo for non-root users", () => {
|
||||||
|
const command = getRailpackCommand(createApplication());
|
||||||
|
|
||||||
|
expect(command).toContain(
|
||||||
|
'$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"',
|
||||||
|
);
|
||||||
|
expect(command).toContain("sudo -n true 2>/dev/null");
|
||||||
|
});
|
||||||
|
|
||||||
it("changes secrets-hash when an environment value changes", () => {
|
it("changes secrets-hash when an environment value changes", () => {
|
||||||
const firstCommand = getRailpackCommand(
|
const firstCommand = getRailpackCommand(
|
||||||
createApplication({
|
createApplication({
|
||||||
|
|||||||
@@ -16,6 +16,30 @@ describe("shouldDeploy", () => {
|
|||||||
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should apply multiple negated watch paths as one pattern set", () => {
|
||||||
|
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||||
|
|
||||||
|
expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false);
|
||||||
|
expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false);
|
||||||
|
expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should deploy when a changed file remains after exclusions", () => {
|
||||||
|
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||||
|
|
||||||
|
expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine positive and negated watch paths", () => {
|
||||||
|
const watchPaths = ["src/**", "!src/**/*.test.ts"];
|
||||||
|
|
||||||
|
expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true);
|
||||||
|
expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false);
|
||||||
|
expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("should not throw when modified files contain non-string values", () => {
|
it("should not throw when modified files contain non-string values", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||||
|
|||||||
@@ -87,6 +87,224 @@ describe("cloudflareClient.listRecords", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cloudflareClient MX priority", () => {
|
||||||
|
it("inlines the priority into the content when listing", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
cfSuccess([
|
||||||
|
{
|
||||||
|
id: "mx-1",
|
||||||
|
type: "MX",
|
||||||
|
name: "example.com",
|
||||||
|
content: "mail.example.com",
|
||||||
|
ttl: 300,
|
||||||
|
priority: 20,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await cloudflareClient.listRecords(config, "zone-1");
|
||||||
|
|
||||||
|
expect(records[0]?.content).toBe("20 mail.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits the priority back out when writing", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(cfSuccess([]))
|
||||||
|
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
|
||||||
|
|
||||||
|
await cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "MX",
|
||||||
|
name: "example.com",
|
||||||
|
content: "20 mail.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||||
|
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||||
|
content: "mail.example.com",
|
||||||
|
priority: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to priority 10 when the content has no leading number", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(cfSuccess([]))
|
||||||
|
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
|
||||||
|
|
||||||
|
await cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "MX",
|
||||||
|
name: "example.com",
|
||||||
|
content: "mail.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||||
|
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||||
|
content: "mail.example.com",
|
||||||
|
priority: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves non-MX content untouched", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(cfSuccess([]))
|
||||||
|
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
|
||||||
|
|
||||||
|
await cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "TXT",
|
||||||
|
name: "example.com",
|
||||||
|
content: "10 not-a-priority",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||||
|
const body = JSON.parse(init.body as string);
|
||||||
|
expect(body.content).toBe("10 not-a-priority");
|
||||||
|
expect(body.priority).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cloudflareClient structured records", () => {
|
||||||
|
const stubCreate = () =>
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(cfSuccess([]))
|
||||||
|
.mockResolvedValueOnce(cfSuccess({ id: "rec-1" }));
|
||||||
|
|
||||||
|
it("sends SRV values as structured data", async () => {
|
||||||
|
stubCreate();
|
||||||
|
|
||||||
|
await cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "SRV",
|
||||||
|
name: "_sip._tcp.example.com",
|
||||||
|
content: "1 10 5269 talk.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||||
|
const body = JSON.parse(init.body as string);
|
||||||
|
expect(body.data).toEqual({
|
||||||
|
priority: 1,
|
||||||
|
weight: 10,
|
||||||
|
port: 5269,
|
||||||
|
target: "talk.example.com",
|
||||||
|
});
|
||||||
|
expect(body.content).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends CAA values as structured data and drops the quotes", async () => {
|
||||||
|
stubCreate();
|
||||||
|
|
||||||
|
await cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "CAA",
|
||||||
|
name: "example.com",
|
||||||
|
content: '0 issue "letsencrypt.org"',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||||
|
const body = JSON.parse(init.body as string);
|
||||||
|
expect(body.data).toEqual({
|
||||||
|
flags: 0,
|
||||||
|
tag: "issue",
|
||||||
|
value: "letsencrypt.org",
|
||||||
|
});
|
||||||
|
expect(body.content).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an SRV value that is missing a part", async () => {
|
||||||
|
await expect(
|
||||||
|
cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "SRV",
|
||||||
|
name: "_sip._tcp.example.com",
|
||||||
|
content: "1 10 5269",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/SRV value/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a CAA value that has no tag", async () => {
|
||||||
|
await expect(
|
||||||
|
cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "CAA",
|
||||||
|
name: "example.com",
|
||||||
|
content: "0",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/CAA value/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cloudflareClient proxy status", () => {
|
||||||
|
it("sends the proxy status for proxiable types", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(cfSuccess([]))
|
||||||
|
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
|
||||||
|
|
||||||
|
await cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
proxied: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||||
|
expect(JSON.parse(init.body as string).proxied).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the proxy status for types Cloudflare cannot proxy", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(cfSuccess([]))
|
||||||
|
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
|
||||||
|
|
||||||
|
await cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "TXT",
|
||||||
|
name: "example.com",
|
||||||
|
content: "hello",
|
||||||
|
proxied: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||||
|
expect(JSON.parse(init.body as string).proxied).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the proxy status untouched when the caller does not set it", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(cfSuccess([]))
|
||||||
|
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
|
||||||
|
|
||||||
|
await cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||||
|
expect("proxied" in JSON.parse(init.body as string)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the proxy status when listing records", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
cfSuccess([
|
||||||
|
{
|
||||||
|
id: "a-1",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await cloudflareClient.listRecords(config, "zone-1");
|
||||||
|
|
||||||
|
expect(records[0]?.proxied).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("cloudflareClient.upsertRecord", () => {
|
describe("cloudflareClient.upsertRecord", () => {
|
||||||
it("creates a record when none exists for the name/type", async () => {
|
it("creates a record when none exists for the name/type", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
@@ -105,9 +323,11 @@ describe("cloudflareClient.upsertRecord", () => {
|
|||||||
expect(createInit.method).toBe("POST");
|
expect(createInit.method).toBe("POST");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the existing record instead of creating a duplicate", async () => {
|
it("updates the existing record when content matches", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
|
.mockResolvedValueOnce(
|
||||||
|
cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
|
||||||
|
)
|
||||||
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
||||||
|
|
||||||
const result = await cloudflareClient.upsertRecord(config, {
|
const result = await cloudflareClient.upsertRecord(config, {
|
||||||
@@ -126,6 +346,25 @@ describe("cloudflareClient.upsertRecord", () => {
|
|||||||
expect(updateInit.method).toBe("PUT");
|
expect(updateInit.method).toBe("PUT");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a new record when content differs from existing", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(cfSuccess({ id: "new-2" }));
|
||||||
|
|
||||||
|
const result = await cloudflareClient.upsertRecord(config, {
|
||||||
|
zoneId: "zone-1",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "5.6.7.8",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "new-2" });
|
||||||
|
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||||
|
expect(createInit.method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(cfSuccess([]))
|
.mockResolvedValueOnce(cfSuccess([]))
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch as typeof fetch;
|
||||||
|
|
||||||
|
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||||
|
({
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
}) as Response;
|
||||||
|
|
||||||
|
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
|
||||||
|
|
||||||
|
const ikPage = (data: unknown, page: number, pages: number) =>
|
||||||
|
jsonResponse({ result: "success", data, page, pages });
|
||||||
|
|
||||||
|
const ikError = (description: string, status = 400) =>
|
||||||
|
jsonResponse(
|
||||||
|
{ result: "error", error: { code: "not_authorized", description } },
|
||||||
|
false,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
providerType: "infomaniak" as const,
|
||||||
|
apiToken: "ik_test_token",
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastCall = () =>
|
||||||
|
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
|
||||||
|
|
||||||
|
const lastBody = () => JSON.parse(lastCall()[1].body as string);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.listZones", () => {
|
||||||
|
it("exposes each domain product as a zone keyed by its name", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
ikPage(
|
||||||
|
[
|
||||||
|
{ id: 1, customer_name: "example.com" },
|
||||||
|
{ id: 2, customer_name: "example.ch" },
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const zones = await infomaniakClient.listZones(config);
|
||||||
|
|
||||||
|
expect(zones).toEqual([
|
||||||
|
{ id: "example.com", name: "example.com" },
|
||||||
|
{ id: "example.ch", name: "example.ch" },
|
||||||
|
]);
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
// The documented endpoint is the plural one; the singular is legacy and
|
||||||
|
// returns no pagination metadata at all.
|
||||||
|
expect(url).toContain("/1/products?service_name=domain");
|
||||||
|
expect(url).toContain("page=1");
|
||||||
|
expect(init.headers).toMatchObject({
|
||||||
|
Authorization: "Bearer ik_test_token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks every page so accounts with many domains keep all their zones", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
|
||||||
|
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
|
||||||
|
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
|
||||||
|
|
||||||
|
const zones = await infomaniakClient.listZones(config);
|
||||||
|
|
||||||
|
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||||
|
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after a single page when the response has no pagination", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
|
||||||
|
|
||||||
|
const zones = await infomaniakClient.listZones(config);
|
||||||
|
|
||||||
|
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates the API error description", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||||
|
|
||||||
|
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
|
||||||
|
"Authorization required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.listRecords", () => {
|
||||||
|
it("rebuilds the fqdn from the relative source", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
ikSuccess([
|
||||||
|
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||||
|
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||||
|
|
||||||
|
expect(records).toEqual([
|
||||||
|
{
|
||||||
|
id: "10",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "11",
|
||||||
|
type: "A",
|
||||||
|
name: "example.com",
|
||||||
|
content: "5.6.7.8",
|
||||||
|
ttl: 600,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(lastCall()[0]).toBe(
|
||||||
|
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||||
|
|
||||||
|
expect(records[0]?.name).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unquotes TXT targets", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
ikSuccess([
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
type: "TXT",
|
||||||
|
source: "_acme-challenge",
|
||||||
|
target: '"token-value"',
|
||||||
|
ttl: 300,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||||
|
|
||||||
|
expect(records[0]?.content).toBe("token-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a CAA target untouched", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
ikSuccess([
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
type: "CAA",
|
||||||
|
source: "",
|
||||||
|
target: '0 issue "letsencrypt.org"',
|
||||||
|
ttl: 300,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||||
|
|
||||||
|
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.upsertRecord", () => {
|
||||||
|
it("creates the record when no matching source and type exists", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikSuccess([]))
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "42" });
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect(lastBody()).toEqual({
|
||||||
|
type: "A",
|
||||||
|
source: "app",
|
||||||
|
target: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the existing record when content matches", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
ikSuccess([
|
||||||
|
{ id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "7" });
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||||
|
);
|
||||||
|
expect(init.method).toBe("PUT");
|
||||||
|
expect(lastBody().ttl).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new record when content differs from existing", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
ikSuccess([
|
||||||
|
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "50" });
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikSuccess([]))
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
|
||||||
|
|
||||||
|
await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "example.com.",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastBody().source).toBe(".");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([".", "", "@"])(
|
||||||
|
"matches an existing apex record stored with a %s source",
|
||||||
|
async (source) => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
ikSuccess([
|
||||||
|
{ id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "8" });
|
||||||
|
expect(lastCall()[1].method).toBe("PUT");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("matches the existing apex record instead of creating a duplicate", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
ikSuccess([
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
type: "TXT",
|
||||||
|
source: ".",
|
||||||
|
target: '"v=spf1 -all"',
|
||||||
|
ttl: 3600,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "TXT",
|
||||||
|
name: "example.com",
|
||||||
|
content: "v=spf1 -all",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "8" });
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(init.method).toBe("PUT");
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://api.infomaniak.com/2/zones/example.com/records/8",
|
||||||
|
);
|
||||||
|
expect(lastBody().target).toBe('"v=spf1 -all"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotes a TXT target on write", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikSuccess([]))
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
|
||||||
|
|
||||||
|
await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "TXT",
|
||||||
|
name: "_acme-challenge.example.com",
|
||||||
|
content: "token-value",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastBody().target).toBe('"token-value"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-quote a TXT target that is already quoted", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikSuccess([]))
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
|
||||||
|
|
||||||
|
await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "TXT",
|
||||||
|
name: "_acme-challenge.example.com",
|
||||||
|
content: '"token-value"',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastBody().target).toBe('"token-value"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queries the API with a source and type filter instead of the whole zone", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(ikSuccess([]))
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||||
|
|
||||||
|
await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url] = mockFetch.mock.calls[0] as [string];
|
||||||
|
expect(url).toContain("filter%5Bsource%5D=app");
|
||||||
|
expect(url).toContain("filter%5Btypes%5D%5B%5D=A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a partial filter hit rather than overwriting a different record", async () => {
|
||||||
|
// filter[source] matches substrings: asking for "auto" also returns
|
||||||
|
// "autoconfig" and "autodiscover". Trusting it would overwrite one of them.
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
ikSuccess([
|
||||||
|
{
|
||||||
|
id: 61,
|
||||||
|
type: "CNAME",
|
||||||
|
source: "autoconfig",
|
||||||
|
target: "a.example.net",
|
||||||
|
ttl: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 62,
|
||||||
|
type: "CNAME",
|
||||||
|
source: "autodiscover",
|
||||||
|
target: "b.example.net",
|
||||||
|
ttl: 300,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(ikSuccess({ id: 63 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "CNAME",
|
||||||
|
name: "auto.example.com",
|
||||||
|
content: "c.example.net",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "63" });
|
||||||
|
expect(lastCall()[1].method).toBe("POST");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.updateRecord", () => {
|
||||||
|
it("updates the record and keeps its id", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||||
|
|
||||||
|
const result = await infomaniakClient.updateRecord(
|
||||||
|
config,
|
||||||
|
"example.com",
|
||||||
|
"7",
|
||||||
|
{
|
||||||
|
type: "CNAME",
|
||||||
|
name: "www.example.com",
|
||||||
|
content: "example.com",
|
||||||
|
ttl: 900,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "7" });
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||||
|
);
|
||||||
|
expect(init.method).toBe("PUT");
|
||||||
|
expect(lastBody()).toEqual({
|
||||||
|
type: "CNAME",
|
||||||
|
source: "www",
|
||||||
|
target: "example.com",
|
||||||
|
ttl: 900,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default ttl when none is provided", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||||
|
|
||||||
|
await infomaniakClient.updateRecord(config, "example.com", "7", {
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastBody().ttl).toBe(300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.deleteRecord", () => {
|
||||||
|
it("deletes the record", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikSuccess(null));
|
||||||
|
|
||||||
|
await infomaniakClient.deleteRecord(config, "example.com", "7");
|
||||||
|
|
||||||
|
const [url, init] = lastCall();
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||||
|
);
|
||||||
|
expect(init.method).toBe("DELETE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a delete failure", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikError("Record not found", 404));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
infomaniakClient.deleteRecord(config, "example.com", "7"),
|
||||||
|
).rejects.toThrow("Record not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("infomaniakClient.testConnection", () => {
|
||||||
|
it("resolves when the domain listing succeeds", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikSuccess([]));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
infomaniakClient.testConnection(config),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects on an invalid token", async () => {
|
||||||
|
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||||
|
|
||||||
|
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
|
||||||
|
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch as typeof fetch;
|
||||||
|
|
||||||
|
import { ovhClient } from "@dokploy/server/utils/dns/ovh";
|
||||||
|
|
||||||
|
const textResponse = (body: string, ok = true, status = 200) =>
|
||||||
|
({
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
text: async () => body,
|
||||||
|
}) as Response;
|
||||||
|
|
||||||
|
const ovhSuccess = (data: unknown) =>
|
||||||
|
textResponse(data === undefined ? "" : JSON.stringify(data));
|
||||||
|
|
||||||
|
const ovhError = (message: string, status = 403) =>
|
||||||
|
textResponse(JSON.stringify({ message }), false, status);
|
||||||
|
|
||||||
|
const SERVER_TIME = 1788268225;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
providerType: "ovh" as const,
|
||||||
|
endpoint: "ovh-eu" as const,
|
||||||
|
applicationKey: "app-key",
|
||||||
|
applicationSecret: "app-secret",
|
||||||
|
consumerKey: "consumer-key",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each test uses a distinct endpoint so the module-level clock-skew cache, which
|
||||||
|
// is keyed by base url, never leaks a measurement between them.
|
||||||
|
let endpointCursor = 0;
|
||||||
|
const endpoints = [
|
||||||
|
"ovh-eu",
|
||||||
|
"ovh-ca",
|
||||||
|
"ovh-us",
|
||||||
|
"kimsufi-eu",
|
||||||
|
"kimsufi-ca",
|
||||||
|
"soyoustart-eu",
|
||||||
|
"soyoustart-ca",
|
||||||
|
] as const;
|
||||||
|
const baseUrls: Record<(typeof endpoints)[number], string> = {
|
||||||
|
"ovh-eu": "https://eu.api.ovh.com/1.0",
|
||||||
|
"ovh-ca": "https://ca.api.ovh.com/1.0",
|
||||||
|
"ovh-us": "https://api.us.ovhcloud.com/1.0",
|
||||||
|
"kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
|
||||||
|
"kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
|
||||||
|
"soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
|
||||||
|
"soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */
|
||||||
|
const freshConfig = () => {
|
||||||
|
const endpoint = endpoints[
|
||||||
|
endpointCursor % endpoints.length
|
||||||
|
] as (typeof endpoints)[number];
|
||||||
|
endpointCursor += 1;
|
||||||
|
return { ...config, endpoint, baseUrl: baseUrls[endpoint] };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Replies to /auth/time, then to each queued API response in order. */
|
||||||
|
const mockApi = (...responses: Response[]) => {
|
||||||
|
let call = 0;
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith("/auth/time")) {
|
||||||
|
return Promise.resolve(textResponse(String(SERVER_TIME)));
|
||||||
|
}
|
||||||
|
const response = responses[call];
|
||||||
|
call += 1;
|
||||||
|
return Promise.resolve(response ?? ovhSuccess(null));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiCalls = () =>
|
||||||
|
mockFetch.mock.calls.filter(
|
||||||
|
([url]) => !(url as string).endsWith("/auth/time"),
|
||||||
|
) as [string, RequestInit][];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient request signing", () => {
|
||||||
|
it("signs the request with the API server clock, not the local one", async () => {
|
||||||
|
const { baseUrl, ...cfg } = freshConfig();
|
||||||
|
mockApi(ovhSuccess(["example.com"]));
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000);
|
||||||
|
|
||||||
|
await ovhClient.listZones(cfg);
|
||||||
|
|
||||||
|
const [url, init] = apiCalls()[0] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect(url).toBe(`${baseUrl}/domain/zone`);
|
||||||
|
expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME));
|
||||||
|
expect(headers["X-Ovh-Application"]).toBe("app-key");
|
||||||
|
expect(headers["X-Ovh-Consumer"]).toBe("consumer-key");
|
||||||
|
|
||||||
|
const expected = createHash("sha1")
|
||||||
|
.update(
|
||||||
|
["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"),
|
||||||
|
)
|
||||||
|
.digest("hex");
|
||||||
|
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs a request body when one is sent", async () => {
|
||||||
|
const { baseUrl, ...cfg } = freshConfig();
|
||||||
|
mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null));
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000);
|
||||||
|
|
||||||
|
await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, init] = apiCalls()[1] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
const body = init.body as string;
|
||||||
|
expect(body).not.toBe("");
|
||||||
|
const expected = createHash("sha1")
|
||||||
|
.update(
|
||||||
|
["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join(
|
||||||
|
"+",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.digest("hex");
|
||||||
|
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.listZones", () => {
|
||||||
|
it("maps each zone name to a zone", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess(["example.com", "example.fr"]));
|
||||||
|
|
||||||
|
const zones = await ovhClient.listZones(cfg);
|
||||||
|
|
||||||
|
expect(zones).toEqual([
|
||||||
|
{ id: "example.com", name: "example.com" },
|
||||||
|
{ id: "example.fr", name: "example.fr" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the missing root right when OVH refuses the zone listing", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhError("This call has not been granted", 403));
|
||||||
|
|
||||||
|
// A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
|
||||||
|
// so the raw OVH message would send users looking in the wrong place.
|
||||||
|
await expect(ovhClient.listZones(cfg)).rejects.toThrow(
|
||||||
|
/missing the `GET \/domain\/zone` right/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates the API error message", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhError("Invalid signature", 403));
|
||||||
|
|
||||||
|
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.listRecords", () => {
|
||||||
|
it("resolves each id into a full record", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess([1, 2]),
|
||||||
|
ovhSuccess({
|
||||||
|
id: 1,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
}),
|
||||||
|
ovhSuccess({
|
||||||
|
id: 2,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: null,
|
||||||
|
target: "5.6.7.8",
|
||||||
|
ttl: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await ovhClient.listRecords(cfg, "example.com");
|
||||||
|
|
||||||
|
expect(records).toEqual([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "A",
|
||||||
|
name: "example.com",
|
||||||
|
content: "5.6.7.8",
|
||||||
|
ttl: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the records in the order of the returned ids", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
const record = (id: number, subDomain: string) => ({
|
||||||
|
id,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain,
|
||||||
|
target: `10.0.0.${id}`,
|
||||||
|
ttl: 60,
|
||||||
|
});
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess([1, 2, 3, 4, 5]),
|
||||||
|
...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await ovhClient.listRecords(cfg, "example.com");
|
||||||
|
|
||||||
|
expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.upsertRecord", () => {
|
||||||
|
it("creates the record then refreshes the zone", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||||
|
|
||||||
|
const result = await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "9" });
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[0]?.[0]).toContain(
|
||||||
|
"/domain/zone/example.com/record?fieldType=A&subDomain=app",
|
||||||
|
);
|
||||||
|
expect(calls[1]?.[1].method).toBe("POST");
|
||||||
|
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
});
|
||||||
|
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh");
|
||||||
|
expect(calls[2]?.[1].method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the existing record when content matches", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess([4]),
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.2.3.4",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhSuccess(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "4" });
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4");
|
||||||
|
expect(calls[2]?.[1].method).toBe("PUT");
|
||||||
|
expect(calls[3]?.[0]).toContain("/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new record when content differs from existing", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess([4]),
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess({ id: 10 }),
|
||||||
|
ovhSuccess(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "5.6.7.8",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "10" });
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[2]?.[1].method).toBe("POST");
|
||||||
|
expect(calls[3]?.[0]).toContain("/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the ttl so OVH applies the zone default", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||||
|
|
||||||
|
await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty(
|
||||||
|
"ttl",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes an empty subDomain for the apex and strips the trailing dot", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||||
|
|
||||||
|
await ovhClient.upsertRecord(cfg, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "example.com.",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiCalls()[0]?.[0]).toContain("subDomain=");
|
||||||
|
expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.updateRecord", () => {
|
||||||
|
it("updates in place when the type is unchanged", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhSuccess(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "4" });
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[1]?.[1].method).toBe("PUT");
|
||||||
|
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.2.3.4",
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
expect(calls[2]?.[0]).toContain("/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the record when the type changes, since PUT carries no fieldType", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhSuccess({ id: 11 }),
|
||||||
|
ovhSuccess(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "CNAME",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "11" });
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[1]?.[1].method).toBe("DELETE");
|
||||||
|
expect(calls[2]?.[1].method).toBe("POST");
|
||||||
|
expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME");
|
||||||
|
expect(calls[3]?.[0]).toContain("/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the original record when the replacement fails", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
const original = {
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
};
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess(original),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhError("Invalid target", 400),
|
||||||
|
ovhSuccess({ id: 12 }),
|
||||||
|
ovhSuccess(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "CNAME",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "not a valid target",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Invalid target");
|
||||||
|
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[1]?.[1].method).toBe("DELETE");
|
||||||
|
expect(calls[2]?.[1].method).toBe("POST");
|
||||||
|
// The original record is put back with its own type, target and ttl.
|
||||||
|
expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
});
|
||||||
|
expect(calls[4]?.[0]).toContain("/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the lost record when the restore also fails", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhError("Invalid target", 400),
|
||||||
|
ovhError("Service unavailable", 503),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "CNAME",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "not a valid target",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
/Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the change was applied when only the zone refresh fails", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null),
|
||||||
|
ovhSuccess({ id: 11 }),
|
||||||
|
ovhError("Service unavailable", 503),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The replacement succeeded, so the record exists at the provider — only
|
||||||
|
// publishing failed. Rolling back would destroy correct state.
|
||||||
|
await expect(
|
||||||
|
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "CNAME",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "example.com",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not tell the user to recreate a record that was restored but not published", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(
|
||||||
|
ovhSuccess({
|
||||||
|
id: 4,
|
||||||
|
zone: "example.com",
|
||||||
|
fieldType: "A",
|
||||||
|
subDomain: "app",
|
||||||
|
target: "1.1.1.1",
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
ovhSuccess(null), // DELETE de l'ancien
|
||||||
|
ovhError("Invalid target", 400), // POST de remplacement -> échec
|
||||||
|
ovhSuccess({ id: 12 }), // POST de restauration -> succès
|
||||||
|
ovhError("Service unavailable", 503), // refresh -> échec
|
||||||
|
);
|
||||||
|
|
||||||
|
const attempt = ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||||
|
type: "CNAME",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "not a valid target",
|
||||||
|
});
|
||||||
|
|
||||||
|
// L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait.
|
||||||
|
await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/);
|
||||||
|
await expect(attempt).rejects.not.toThrow(/Recreate it manually/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.deleteRecord", () => {
|
||||||
|
it("deletes the record then refreshes the zone", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess(null), ovhSuccess(null));
|
||||||
|
|
||||||
|
await ovhClient.deleteRecord(cfg, "example.com", "4");
|
||||||
|
|
||||||
|
const calls = apiCalls();
|
||||||
|
expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4");
|
||||||
|
expect(calls[0]?.[1].method).toBe("DELETE");
|
||||||
|
expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refresh the zone when the delete fails", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhError("This object does not exist", 404));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ovhClient.deleteRecord(cfg, "example.com", "4"),
|
||||||
|
).rejects.toThrow("This object does not exist");
|
||||||
|
expect(apiCalls()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ovhClient.testConnection", () => {
|
||||||
|
it("resolves when the zone listing succeeds", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhSuccess([]));
|
||||||
|
|
||||||
|
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects on invalid credentials", async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
mockApi(ovhError("Invalid signature", 403));
|
||||||
|
|
||||||
|
await expect(ovhClient.testConnection(cfg)).rejects.toThrow(
|
||||||
|
"Invalid signature",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch as typeof fetch;
|
||||||
|
|
||||||
|
import { porkbunClient } from "@dokploy/server/utils/dns/porkbun";
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||||
|
({
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
}) as Response;
|
||||||
|
|
||||||
|
const pbSuccess = (result: Record<string, unknown> = {}) =>
|
||||||
|
jsonResponse({ status: "SUCCESS", ...result });
|
||||||
|
|
||||||
|
const pbError = (message: string, status = 400) =>
|
||||||
|
jsonResponse({ status: "ERROR", message }, false, status);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
providerType: "porkbun" as const,
|
||||||
|
apiKey: "pk1_test",
|
||||||
|
secretApiKey: "sk1_test",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("porkbunClient.listZones", () => {
|
||||||
|
it("lists all domains as zones", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
pbSuccess({ domains: [{ domain: "example.com" }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const zones = await porkbunClient.listZones(config);
|
||||||
|
|
||||||
|
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
|
||||||
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toContain("/domain/listAll");
|
||||||
|
const body = JSON.parse(init.body as string);
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
apikey: "pk1_test",
|
||||||
|
secretapikey: "sk1_test",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("porkbunClient.listRecords", () => {
|
||||||
|
it("lists records for a domain", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
pbSuccess({
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: "600",
|
||||||
|
prio: "0",
|
||||||
|
notes: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await porkbunClient.listRecords(config, "example.com");
|
||||||
|
|
||||||
|
expect(records).toEqual([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
ttl: 600,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("porkbunClient.upsertRecord", () => {
|
||||||
|
it("creates a record when none exists for the name/type", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||||
|
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
|
||||||
|
|
||||||
|
const result = await porkbunClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "new-1" });
|
||||||
|
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
|
||||||
|
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app");
|
||||||
|
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
|
||||||
|
string,
|
||||||
|
RequestInit,
|
||||||
|
];
|
||||||
|
expect(createUrl).toContain("/dns/create/example.com");
|
||||||
|
const body = JSON.parse(createInit.body as string);
|
||||||
|
expect(body).toMatchObject({ name: "app", type: "A", content: "1.2.3.4" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the apex domain to an empty subdomain", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||||
|
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
|
||||||
|
|
||||||
|
await porkbunClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "example.com",
|
||||||
|
content: "1.2.3.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
|
||||||
|
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits the existing record when content matches", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(pbSuccess({}));
|
||||||
|
|
||||||
|
const result = await porkbunClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "5.6.7.8",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "existing-1" });
|
||||||
|
const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||||
|
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new record when content differs from existing", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
|
||||||
|
|
||||||
|
const result = await porkbunClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "5.6.7.8",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "new-2" });
|
||||||
|
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
|
||||||
|
string,
|
||||||
|
RequestInit,
|
||||||
|
];
|
||||||
|
expect(createUrl).toContain("/dns/create/example.com");
|
||||||
|
const body = JSON.parse(createInit.body as string);
|
||||||
|
expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults ttl to 600 when not provided", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||||
|
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
|
||||||
|
|
||||||
|
await porkbunClient.upsertRecord(config, {
|
||||||
|
zoneId: "example.com",
|
||||||
|
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(600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("porkbunClient.updateRecord", () => {
|
||||||
|
it("edits the given record id", async () => {
|
||||||
|
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||||
|
|
||||||
|
const result = await porkbunClient.updateRecord(
|
||||||
|
config,
|
||||||
|
"example.com",
|
||||||
|
"1",
|
||||||
|
{
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "9.9.9.9",
|
||||||
|
ttl: 300,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: "1" });
|
||||||
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toContain("/dns/edit/example.com/1");
|
||||||
|
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||||
|
name: "app",
|
||||||
|
type: "A",
|
||||||
|
content: "9.9.9.9",
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("porkbunClient.deleteRecord", () => {
|
||||||
|
it("posts to the delete endpoint for the given record id", async () => {
|
||||||
|
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||||
|
|
||||||
|
await porkbunClient.deleteRecord(config, "example.com", "1");
|
||||||
|
|
||||||
|
const [url] = mockFetch.mock.calls[0] as [string];
|
||||||
|
expect(url).toContain("/dns/delete/example.com/1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("porkbunClient.testConnection", () => {
|
||||||
|
it("succeeds when the credentials can ping the API", async () => {
|
||||||
|
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||||
|
await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces Porkbun's error message on invalid credentials", async () => {
|
||||||
|
mockFetch.mockResolvedValue(pbError("Invalid API key."));
|
||||||
|
|
||||||
|
await expect(porkbunClient.testConnection(config)).rejects.toThrow(
|
||||||
|
"Invalid API key.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -142,13 +142,15 @@ describe("route53Client.listRecords", () => {
|
|||||||
|
|
||||||
const records = await route53Client.listRecords(config, "Z123");
|
const records = await route53Client.listRecords(config, "Z123");
|
||||||
|
|
||||||
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
|
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("route53Client.upsertRecord", () => {
|
describe("route53Client.upsertRecord", () => {
|
||||||
it("sends a single UPSERT change", async () => {
|
it("sends a single UPSERT change when nothing exists yet", async () => {
|
||||||
send.mockResolvedValueOnce({});
|
send
|
||||||
|
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||||
|
.mockResolvedValueOnce({});
|
||||||
|
|
||||||
const result = await route53Client.upsertRecord(config, {
|
const result = await route53Client.upsertRecord(config, {
|
||||||
zoneId: "Z123",
|
zoneId: "Z123",
|
||||||
@@ -158,7 +160,7 @@ describe("route53Client.upsertRecord", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ id: "A:app.example.com" });
|
expect(result).toEqual({ id: "A:app.example.com" });
|
||||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||||
expect(command.input.HostedZoneId).toBe("Z123");
|
expect(command.input.HostedZoneId).toBe("Z123");
|
||||||
expect(command.input.ChangeBatch.Changes).toEqual([
|
expect(command.input.ChangeBatch.Changes).toEqual([
|
||||||
{
|
{
|
||||||
@@ -172,6 +174,82 @@ describe("route53Client.upsertRecord", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the values already in the record set", async () => {
|
||||||
|
send
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ResourceRecordSets: [
|
||||||
|
{
|
||||||
|
Name: "app.example.com.",
|
||||||
|
Type: "A",
|
||||||
|
TTL: 300,
|
||||||
|
ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await route53Client.upsertRecord(config, {
|
||||||
|
zoneId: "Z123",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "3.3.3.3",
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||||
|
expect(
|
||||||
|
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||||
|
).toEqual([
|
||||||
|
{ Value: "1.1.1.1" },
|
||||||
|
{ Value: "2.2.2.2" },
|
||||||
|
{ Value: "3.3.3.3" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate a value that is already in the record set", async () => {
|
||||||
|
send
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ResourceRecordSets: [
|
||||||
|
{
|
||||||
|
Name: "app.example.com.",
|
||||||
|
Type: "A",
|
||||||
|
TTL: 300,
|
||||||
|
ResourceRecords: [{ Value: "1.1.1.1" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await route53Client.upsertRecord(config, {
|
||||||
|
zoneId: "Z123",
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: "1.1.1.1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||||
|
expect(
|
||||||
|
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||||
|
).toEqual([{ Value: "1.1.1.1" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps unquoted TXT values in double quotes", async () => {
|
||||||
|
send
|
||||||
|
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||||
|
.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await route53Client.upsertRecord(config, {
|
||||||
|
zoneId: "Z123",
|
||||||
|
type: "TXT",
|
||||||
|
name: "example.com",
|
||||||
|
content: 'v=spf1 ~all\n"already quoted"',
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||||
|
expect(
|
||||||
|
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||||
|
).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("route53Client.updateRecord", () => {
|
describe("route53Client.updateRecord", () => {
|
||||||
@@ -222,6 +300,25 @@ describe("route53Client.updateRecord", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps every line of a multi-value record set", async () => {
|
||||||
|
send.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await route53Client.updateRecord(config, "Z123", "NS:example.com", {
|
||||||
|
type: "NS",
|
||||||
|
name: "example.com",
|
||||||
|
content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||||
|
expect(
|
||||||
|
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||||
|
).toEqual([
|
||||||
|
{ Value: "ns1.example.com" },
|
||||||
|
{ Value: "ns2.example.com" },
|
||||||
|
{ Value: "ns3.example.com" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips the DELETE when the old record no longer exists", async () => {
|
it("skips the DELETE when the old record no longer exists", async () => {
|
||||||
send
|
send
|
||||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import {
|
||||||
|
getServerIpCandidates,
|
||||||
|
validateDomain,
|
||||||
|
} from "@dokploy/server/services/domain";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
execAsyncRemote: vi.fn(),
|
||||||
|
findServerById: vi.fn(),
|
||||||
|
getPublicIpWithFallback: vi.fn(),
|
||||||
|
getWebServerSettings: vi.fn(),
|
||||||
|
resolve4: vi.fn(),
|
||||||
|
resolve6: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:dns", () => ({
|
||||||
|
default: {
|
||||||
|
resolve4: mocks.resolve4,
|
||||||
|
resolve6: mocks.resolve6,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||||
|
execAsyncRemote: mocks.execAsyncRemote,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/server", () => ({
|
||||||
|
findServerById: mocks.findServerById,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||||
|
getWebServerSettings: mocks.getWebServerSettings,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/wss/utils", () => ({
|
||||||
|
getPublicIpWithFallback: mocks.getPublicIpWithFallback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("getServerIpCandidates", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes every address reported by a multi-homed remote server", async () => {
|
||||||
|
mocks.findServerById.mockResolvedValue({
|
||||||
|
ipAddress: "10.0.0.10",
|
||||||
|
});
|
||||||
|
mocks.execAsyncRemote.mockResolvedValue({
|
||||||
|
stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(getServerIpCandidates("server-id")).resolves.toEqual([
|
||||||
|
"10.0.0.10",
|
||||||
|
"192.0.2.10",
|
||||||
|
"2001:db8::10",
|
||||||
|
]);
|
||||||
|
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
|
||||||
|
"server-id",
|
||||||
|
expect.stringContaining("ip -o addr show scope global"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes every address assigned to the local Dokploy host", async () => {
|
||||||
|
mocks.getWebServerSettings.mockResolvedValue({
|
||||||
|
serverIp: "10.0.0.10",
|
||||||
|
});
|
||||||
|
mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
|
||||||
|
vi.spyOn(os, "networkInterfaces").mockReturnValue({
|
||||||
|
eth0: [
|
||||||
|
{
|
||||||
|
address: "192.0.2.10",
|
||||||
|
netmask: "255.255.255.0",
|
||||||
|
family: "IPv4",
|
||||||
|
mac: "00:00:00:00:00:00",
|
||||||
|
internal: false,
|
||||||
|
cidr: "192.0.2.10/24",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(getServerIpCandidates()).resolves.toEqual([
|
||||||
|
"10.0.0.10",
|
||||||
|
"192.0.2.10",
|
||||||
|
"2001:db8::10",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains remote interface addresses when public IP detection times out", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mocks.findServerById.mockResolvedValue({
|
||||||
|
ipAddress: "10.0.0.10",
|
||||||
|
});
|
||||||
|
mocks.execAsyncRemote.mockImplementation(
|
||||||
|
(_serverId: string, command: string) => {
|
||||||
|
if (command.includes("curl")) {
|
||||||
|
return new Promise(() => undefined);
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
stdout: "192.0.2.10\n",
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidatesPromise = getServerIpCandidates("server-id");
|
||||||
|
await vi.advanceTimersByTimeAsync(7000);
|
||||||
|
|
||||||
|
await expect(candidatesPromise).resolves.toEqual([
|
||||||
|
"10.0.0.10",
|
||||||
|
"192.0.2.10",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateDomain", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates an IPv6-only domain against an IPv6 server address", async () => {
|
||||||
|
const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
|
||||||
|
code: "ENODATA",
|
||||||
|
});
|
||||||
|
mocks.resolve4.mockImplementation(
|
||||||
|
(_domain: string, callback: (error: Error | null) => void) =>
|
||||||
|
callback(noIpv4),
|
||||||
|
);
|
||||||
|
mocks.resolve6.mockImplementation(
|
||||||
|
(
|
||||||
|
_domain: string,
|
||||||
|
callback: (error: Error | null, addresses?: string[]) => void,
|
||||||
|
) => callback(null, ["2001:db8::10"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
validateDomain("example.com", ["2001:db8::10"]),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
isValid: true,
|
||||||
|
resolvedIp: "2001:db8::10",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type HasInput = { input: Record<string, unknown> };
|
||||||
|
|
||||||
|
const findMany = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
const {
|
||||||
|
send,
|
||||||
|
paginate,
|
||||||
|
SSMClient,
|
||||||
|
GetParametersCommand,
|
||||||
|
DescribeParametersCommand,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
class FakeCommand {
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
constructor(input: Record<string, unknown>) {
|
||||||
|
this.input = input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const send = vi.fn();
|
||||||
|
const paginate = vi.fn();
|
||||||
|
class SSMClient {
|
||||||
|
send(command: unknown) {
|
||||||
|
return send(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
send,
|
||||||
|
paginate,
|
||||||
|
SSMClient,
|
||||||
|
GetParametersCommand: class extends FakeCommand {},
|
||||||
|
DescribeParametersCommand: class extends FakeCommand {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@aws-sdk/client-ssm", () => ({
|
||||||
|
SSMClient,
|
||||||
|
GetParametersCommand,
|
||||||
|
DescribeParametersCommand,
|
||||||
|
paginateDescribeParameters: paginate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
vaultProvider: {
|
||||||
|
findMany: (...args: unknown[]) => findMany(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { resolveVaultReferences } from "@dokploy/server/utils/vault";
|
||||||
|
import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
providerType: "aws-parameter-store" as const,
|
||||||
|
region: "eu-central-1",
|
||||||
|
accessKeyId: "AKIA_TEST",
|
||||||
|
secretAccessKey: "secret",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
send.mockReset();
|
||||||
|
paginate.mockReset();
|
||||||
|
findMany.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("awsParameterStoreClient", () => {
|
||||||
|
it("decrypts parameters in batches of ten and preserves selectors", async () => {
|
||||||
|
const refs = [
|
||||||
|
"/prod/database:CURRENT",
|
||||||
|
...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
|
||||||
|
];
|
||||||
|
send.mockImplementation(async (command: HasInput) => ({
|
||||||
|
Parameters: (command.input.Names as string[]).map((ref) => {
|
||||||
|
if (ref === "/prod/database:CURRENT") {
|
||||||
|
return {
|
||||||
|
Name: "/prod/database",
|
||||||
|
Selector: ":CURRENT",
|
||||||
|
Value: "selected-value",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { Name: ref, Value: `value-for-${ref}` };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await awsParameterStoreClient.getSecrets(config, refs);
|
||||||
|
|
||||||
|
expect(send).toHaveBeenCalledTimes(2);
|
||||||
|
expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
|
||||||
|
WithDecryption: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
|
||||||
|
).toBe(10);
|
||||||
|
expect(
|
||||||
|
((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
|
||||||
|
).toBe(1);
|
||||||
|
expect(result["/prod/database:CURRENT"]).toBe("selected-value");
|
||||||
|
expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing parameter without exposing other values", async () => {
|
||||||
|
send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
awsParameterStoreClient.getSecrets(config, ["/missing"]),
|
||||||
|
).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tests the connection within the configured hierarchy", async () => {
|
||||||
|
send.mockResolvedValue({ Parameters: [] });
|
||||||
|
|
||||||
|
await awsParameterStoreClient.testConnection({
|
||||||
|
...config,
|
||||||
|
parameterPath: "/production/my-app/",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(send).toHaveBeenCalledTimes(1);
|
||||||
|
expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
|
||||||
|
ParameterFilters: [
|
||||||
|
{
|
||||||
|
Key: "Path",
|
||||||
|
Option: "Recursive",
|
||||||
|
Values: ["/production/my-app"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
MaxResults: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains the discovery permission when connection testing is denied", async () => {
|
||||||
|
const error = new Error("not authorized");
|
||||||
|
error.name = "AccessDeniedException";
|
||||||
|
send.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
awsParameterStoreClient.testConnection(config),
|
||||||
|
).rejects.toThrow("ssm:DescribeParameters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists parameter names across pages within the configured hierarchy", async () => {
|
||||||
|
paginate.mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield { Parameters: [{ Name: "/prod/db" }] };
|
||||||
|
yield { Parameters: [{ Name: "/prod/api" }] };
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const names = await awsParameterStoreClient.listSecretNames?.({
|
||||||
|
...config,
|
||||||
|
parameterPath: " /production/my-app/ ",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(names).toEqual(["/prod/db", "/prod/api"]);
|
||||||
|
expect(paginate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ pageSize: 50 }),
|
||||||
|
{
|
||||||
|
ParameterFilters: [
|
||||||
|
{
|
||||||
|
Key: "Path",
|
||||||
|
Option: "Recursive",
|
||||||
|
Values: ["/production/my-app"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a vault reference through the registered provider", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "ssm-prod",
|
||||||
|
providerType: "aws-parameter-store",
|
||||||
|
config,
|
||||||
|
assignments: [{ projectId: "project-1", environmentIds: [] }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
send.mockResolvedValue({
|
||||||
|
Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveVaultReferences(
|
||||||
|
"DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
|
||||||
|
{
|
||||||
|
organizationId: "organization-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
environmentId: "environment-1",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("DB_PASSWORD=resolved");
|
||||||
|
});
|
||||||
|
});
|
||||||
+345
@@ -20,6 +20,8 @@ import {
|
|||||||
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
||||||
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
||||||
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
||||||
|
import { infisicalClient } from "@dokploy/server/utils/vault/infisical";
|
||||||
|
import { phaseClient } from "@dokploy/server/utils/vault/phase";
|
||||||
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
@@ -430,6 +432,223 @@ describe("azure client", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("infisical client", () => {
|
||||||
|
const config = {
|
||||||
|
providerType: "infisical" as const,
|
||||||
|
siteUrl: "https://app.infisical.com",
|
||||||
|
clientId: "client-1",
|
||||||
|
clientSecret: "client-secret",
|
||||||
|
projectId: "workspace-1",
|
||||||
|
environmentSlug: "prod",
|
||||||
|
secretPath: "/frontend",
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
|
||||||
|
const list = (secrets: Record<string, string>) =>
|
||||||
|
jsonResponse({
|
||||||
|
secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
|
||||||
|
secretKey,
|
||||||
|
secretValue,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const listPathOf = (callIndex: number) => {
|
||||||
|
const [url] = mockFetch.mock.calls[callIndex] as [string];
|
||||||
|
return new URL(url).searchParams.get("secretPath");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("asks the list endpoint to expand secret references", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ DB_URL: "postgres://real" });
|
||||||
|
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||||
|
const params = new URL(listUrl).searchParams;
|
||||||
|
expect(params.get("expandSecretReferences")).toBe("true");
|
||||||
|
expect(params.get("workspaceId")).toBe("workspace-1");
|
||||||
|
expect(params.get("environment")).toBe("prod");
|
||||||
|
expect(params.get("secretPath")).toBe("/frontend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for imported secrets and merges them", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
secrets: [],
|
||||||
|
imports: [
|
||||||
|
{
|
||||||
|
secretPath: "/shared",
|
||||||
|
secrets: [
|
||||||
|
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ DB_URL: "postgres://imported" });
|
||||||
|
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||||
|
// snake_case on purpose: `includeImports` is silently ignored and the
|
||||||
|
// response comes back with an empty `imports` array.
|
||||||
|
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the later import win when two define the same key", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
secrets: [],
|
||||||
|
imports: [
|
||||||
|
{
|
||||||
|
secretPath: "/base",
|
||||||
|
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
secretPath: "/override",
|
||||||
|
secrets: [
|
||||||
|
{ secretKey: "DB_URL", secretValue: "postgres://override" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||||
|
|
||||||
|
// Infisical documents this order: "If two imports carry a secret with the
|
||||||
|
// same name, the value from the bottom-most import wins." The response
|
||||||
|
// lists imports in that order, so assigning them in sequence matches it.
|
||||||
|
expect(result).toEqual({ DB_URL: "postgres://override" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a folder's own secret win over an imported one of the same name", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
|
||||||
|
imports: [
|
||||||
|
{
|
||||||
|
secretPath: "/shared",
|
||||||
|
secrets: [
|
||||||
|
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ DB_URL: "postgres://local" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a clear error for a missing secret", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ secrets: [] }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
infisicalClient.getSecrets(config, ["ABSENT"]),
|
||||||
|
).rejects.toThrow('secret "ABSENT" not found in environment "prod"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates authentication failures with the status code", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
infisicalClient.getSecrets(config, ["DB_URL"]),
|
||||||
|
).rejects.toThrow("authentication failed (status 401)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a relative <path>:<KEY> ref against the provider path", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, [
|
||||||
|
"shared/sentry:SENTRY_DSN",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
"shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
|
||||||
|
});
|
||||||
|
expect(listPathOf(1)).toBe("/frontend/shared/sentry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a leading slash as an absolute path", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||||
|
|
||||||
|
await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
|
||||||
|
|
||||||
|
expect(listPathOf(1)).toBe("/external/sentry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the root path clean when the provider sits at /", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(list({ KEY: "value" }));
|
||||||
|
|
||||||
|
await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
|
||||||
|
"external/sentry:KEY",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(listPathOf(1)).toBe("/external/sentry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs in once and fetches each path once", async () => {
|
||||||
|
const byPath: Record<string, Record<string, string>> = {
|
||||||
|
"/frontend": { A: "a", B: "b" },
|
||||||
|
"/frontend/other": { C: "c" },
|
||||||
|
};
|
||||||
|
mockFetch.mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes("/auth/universal-auth/login")) return loginResponse();
|
||||||
|
const path = new URL(url).searchParams.get("secretPath") as string;
|
||||||
|
return list(byPath[path] ?? {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, [
|
||||||
|
"A",
|
||||||
|
"B",
|
||||||
|
"other:C",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
|
||||||
|
|
||||||
|
const urls = mockFetch.mock.calls.map(([url]) => url as string);
|
||||||
|
expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
urls
|
||||||
|
.filter((u) => u.includes("/secrets/raw?"))
|
||||||
|
.map((u) => new URL(u).searchParams.get("secretPath"))
|
||||||
|
.sort(),
|
||||||
|
).toEqual(["/frontend", "/frontend/other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the path when a secret is missing from an explicit one", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
.mockResolvedValueOnce(list({}));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
|
||||||
|
).rejects.toThrow(
|
||||||
|
'secret "ABSENT" not found at "/frontend/external/sentry"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a ref with an empty path or key", async () => {
|
||||||
|
await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
|
||||||
|
"expected format <path>:<KEY>",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
infisicalClient.getSecrets(config, ["external/sentry:"]),
|
||||||
|
).rejects.toThrow("expected format <path>:<KEY>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("doppler client", () => {
|
describe("doppler client", () => {
|
||||||
it("propagates auth errors with the status code", async () => {
|
it("propagates auth errors with the status code", async () => {
|
||||||
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
||||||
@@ -616,3 +835,129 @@ describe("scaleway client", () => {
|
|||||||
expect(result).toBe("DB_PASSWORD=s3cret");
|
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("phase client", () => {
|
||||||
|
const config = {
|
||||||
|
providerType: "phase" as const,
|
||||||
|
token: "phase-rest-token",
|
||||||
|
appId: "app-123",
|
||||||
|
env: "Production",
|
||||||
|
path: "/",
|
||||||
|
apiUrl: "https://api.phase.dev",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("fetches secrets by key and sends ServiceAccount auth", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
jsonResponse([
|
||||||
|
{ key: "DB_URL", value: "postgres://real", path: "/" },
|
||||||
|
{ key: "API_KEY", value: "key-123", path: "/" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" });
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
|
||||||
|
);
|
||||||
|
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||||
|
"Bearer ServiceAccount phase-rest-token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a requested secret is missing", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow(
|
||||||
|
'secret "MISSING" not found in environment "Production"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports authentication failures with the status code", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
jsonResponse({ error: "unauthorized" }, false, 401),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow(
|
||||||
|
"authentication failed (status 401: unauthorized)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects apps without SSE enabled during testConnection", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
id: "app-123",
|
||||||
|
name: "My App",
|
||||||
|
sseEnabled: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(phaseClient.testConnection(config)).rejects.toThrow(
|
||||||
|
"enable Server-side Encryption (SSE)",
|
||||||
|
);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||||
|
"https://api.phase.dev/v1/apps/app-123/",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tests connection against the app then secrets listing", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
id: "app-123",
|
||||||
|
name: "My App",
|
||||||
|
sseEnabled: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(jsonResponse([]));
|
||||||
|
|
||||||
|
await phaseClient.testConnection(config);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||||
|
"https://api.phase.dev/v1/apps/app-123/",
|
||||||
|
);
|
||||||
|
expect(mockFetch.mock.calls[1]?.[0]).toBe(
|
||||||
|
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists secret names from the configured path", async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
jsonResponse([
|
||||||
|
{ key: "DB_URL", value: "x", path: "/" },
|
||||||
|
{ key: "API_KEY", value: "y", path: "/" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const names = await phaseClient.listSecretNames?.(config);
|
||||||
|
|
||||||
|
expect(names).toEqual(["DB_URL", "API_KEY"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves env refs end to end through a phase provider", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "phase-prod",
|
||||||
|
providerType: "phase",
|
||||||
|
config,
|
||||||
|
assignments: assignedEverywhere,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await resolveVaultReferences(
|
||||||
|
"DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}",
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
|
||||||
|
|
||||||
|
describe("isHttpsRequest", () => {
|
||||||
|
it("returns true when X-Forwarded-Proto is https", () => {
|
||||||
|
expect(isHttpsRequest("https")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for http or a missing header", () => {
|
||||||
|
expect(isHttpsRequest("http")).toBe(false);
|
||||||
|
expect(isHttpsRequest(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the left-most value of a proxy chain", () => {
|
||||||
|
expect(isHttpsRequest("https, http")).toBe(true);
|
||||||
|
expect(isHttpsRequest("http, https")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an array header value and is case-insensitive", () => {
|
||||||
|
expect(isHttpsRequest(["HTTPS", "http"])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("secureSetCookie", () => {
|
||||||
|
it("adds Secure to a cookie that lacks it", () => {
|
||||||
|
expect(
|
||||||
|
secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"),
|
||||||
|
).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate Secure when already present", () => {
|
||||||
|
const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly";
|
||||||
|
expect(secureSetCookie(cookie)).toBe(cookie);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies to every cookie in an array", () => {
|
||||||
|
expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([
|
||||||
|
"a=1; Path=/; Secure",
|
||||||
|
"b=2; Path=/; Secure",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a numeric header value through untouched", () => {
|
||||||
|
expect(secureSetCookie(123)).toBe(123);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { apiCreateLibsql } from "@dokploy/server/db/schema";
|
||||||
|
import { APP_NAME_MESSAGE } from "@dokploy/server/db/schema/utils";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("apiCreateLibsql appName", () => {
|
||||||
|
const validBase = {
|
||||||
|
name: "My libSQL",
|
||||||
|
dockerImage: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
|
||||||
|
environmentId: "env-1",
|
||||||
|
description: null,
|
||||||
|
databaseUser: "admin",
|
||||||
|
databasePassword: "secretPassword1",
|
||||||
|
sqldNode: "primary" as const,
|
||||||
|
sqldPrimaryUrl: null,
|
||||||
|
enableNamespaces: false,
|
||||||
|
serverId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a space", "my libsql"],
|
||||||
|
["an at sign", "repro@lib"],
|
||||||
|
["a slash", "team/libsql"],
|
||||||
|
["a colon", "libsql:1"],
|
||||||
|
])("refuses an appName with %s", (_, appName) => {
|
||||||
|
const result = apiCreateLibsql.safeParse({ ...validBase, appName });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
const appNameIssues = result.error.issues.filter(
|
||||||
|
(issue) => issue.path[0] === "appName",
|
||||||
|
);
|
||||||
|
expect(appNameIssues.map((issue) => issue.message)).toEqual([
|
||||||
|
APP_NAME_MESSAGE,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an appName of letters, numbers, dots, underscores and hyphens", () => {
|
||||||
|
const result = apiCreateLibsql.safeParse({
|
||||||
|
...validBase,
|
||||||
|
appName: "my-libsql.db_1",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an appName longer than 63 characters", () => {
|
||||||
|
const result = apiCreateLibsql.safeParse({
|
||||||
|
...validBase,
|
||||||
|
appName: "a".repeat(64),
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
const appNameIssues = result.error.issues.filter(
|
||||||
|
(issue) => issue.path[0] === "appName",
|
||||||
|
);
|
||||||
|
expect(appNameIssues.map((issue) => issue.code)).toEqual(["too_big"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an appName of 63 characters", () => {
|
||||||
|
const result = apiCreateLibsql.safeParse({
|
||||||
|
...validBase,
|
||||||
|
appName: "a".repeat(63),
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||||
|
|
||||||
|
const containers = [
|
||||||
|
{ containerId: "first-container" },
|
||||||
|
{ containerId: "selected-container" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("resolveContainerSelection", () => {
|
||||||
|
it("selects the first container when no container is selected", () => {
|
||||||
|
expect(resolveContainerSelection(undefined, containers)).toBe(
|
||||||
|
"first-container",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a manual selection when refreshed data contains it", () => {
|
||||||
|
const refreshedContainers = containers.map((container) => ({
|
||||||
|
...container,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveContainerSelection("selected-container", refreshedContainers),
|
||||||
|
).toBe("selected-container");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first container when the selection disappears", () => {
|
||||||
|
expect(resolveContainerSelection("removed-container", containers)).toBe(
|
||||||
|
"first-container",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the current selection while container data is loading", () => {
|
||||||
|
expect(resolveContainerSelection("selected-container", undefined)).toBe(
|
||||||
|
"selected-container",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the selection when no containers are available", () => {
|
||||||
|
expect(resolveContainerSelection("selected-container", [])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
getHostSystemStats,
|
||||||
|
isPhysicalDisk,
|
||||||
|
} from "@dokploy/server/monitoring/utils";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const GiB = 1024 ** 3;
|
||||||
|
const diskStat = (device: string, readGiB: number, writeGiB: number) => ({
|
||||||
|
device,
|
||||||
|
readBytes: { toBytes: () => readGiB * GiB },
|
||||||
|
writeBytes: { toBytes: () => writeGiB * GiB },
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("node-os-utils", () => ({
|
||||||
|
OSUtils: class {
|
||||||
|
cpu = { usage: async () => ({ success: false }) };
|
||||||
|
memory = { info: async () => ({ success: false }) };
|
||||||
|
network = { overview: async () => ({ success: false }) };
|
||||||
|
disk = {
|
||||||
|
stats: async () => ({
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
diskStat("nvme0n1", 50, 100),
|
||||||
|
diskStat("nvme0n1p1", 50, 100),
|
||||||
|
diskStat("nvme1n1", 50, 100),
|
||||||
|
diskStat("nvme1n1p1", 50, 100),
|
||||||
|
diskStat("md0", 50, 100),
|
||||||
|
diskStat("md0p1", 50, 100),
|
||||||
|
diskStat("dm-0", 50, 100),
|
||||||
|
diskStat("zram0", 10, 20),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("isPhysicalDisk (#5513, #5385)", () => {
|
||||||
|
it.each([
|
||||||
|
"sda",
|
||||||
|
"sdab",
|
||||||
|
"hda",
|
||||||
|
"vda",
|
||||||
|
"xvda",
|
||||||
|
"nvme0n1",
|
||||||
|
"nvme1n1",
|
||||||
|
"mmcblk0",
|
||||||
|
])("counts whole disk %s", (device) => {
|
||||||
|
expect(isPhysicalDisk(device)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"sda1",
|
||||||
|
"sdab12",
|
||||||
|
"hda1",
|
||||||
|
"vda1",
|
||||||
|
"xvda1",
|
||||||
|
"nvme0n1p1",
|
||||||
|
"nvme1n1p4",
|
||||||
|
"mmcblk0p1",
|
||||||
|
])("skips partition %s", (device) => {
|
||||||
|
expect(isPhysicalDisk(device)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["md0", "md127", "md0p1", "md_d0p1", "dm-0", "dm-12"])(
|
||||||
|
"skips stacked device %s",
|
||||||
|
(device) => {
|
||||||
|
expect(isPhysicalDisk(device)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["loop0", "ram0", "zram0", "sr0", "fd0"])(
|
||||||
|
"skips virtual device %s",
|
||||||
|
(device) => {
|
||||||
|
expect(isPhysicalDisk(device)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("counts each write once on an mdadm RAID1 host", () => {
|
||||||
|
const devices = [
|
||||||
|
"nvme0n1",
|
||||||
|
"nvme0n1p1",
|
||||||
|
"nvme0n1p2",
|
||||||
|
"nvme0n1p3",
|
||||||
|
"nvme0n1p4",
|
||||||
|
"nvme1n1",
|
||||||
|
"nvme1n1p1",
|
||||||
|
"nvme1n1p2",
|
||||||
|
"nvme1n1p3",
|
||||||
|
"nvme1n1p4",
|
||||||
|
"md0",
|
||||||
|
"md1",
|
||||||
|
"md2",
|
||||||
|
"md3",
|
||||||
|
];
|
||||||
|
expect(devices.filter(isPhysicalDisk)).toEqual(["nvme0n1", "nvme1n1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getHostSystemStats Block I/O (#5513, #5385)", () => {
|
||||||
|
it("sums only whole physical disks", async () => {
|
||||||
|
const stats = await getHostSystemStats();
|
||||||
|
expect(stats.BlockIO).toBe("100.00GiB / 200.00GiB");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -126,6 +126,13 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
|
|||||||
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
|
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("member is denied server.terminal", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { server: ["terminal"] }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("member is denied registry.create", async () => {
|
it("member is denied registry.create", async () => {
|
||||||
memberToReturn = mockMemberData("member");
|
memberToReturn = mockMemberData("member");
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ describe("enterprise resources for static roles", () => {
|
|||||||
const perms = await resolvePermissions(ctx);
|
const perms = await resolvePermissions(ctx);
|
||||||
|
|
||||||
expect(perms.server.read).toBe(false);
|
expect(perms.server.read).toBe(false);
|
||||||
|
expect(perms.server.terminal).toBe(false);
|
||||||
expect(perms.registry.read).toBe(false);
|
expect(perms.registry.read).toBe(false);
|
||||||
expect(perms.certificate.read).toBe(false);
|
expect(perms.certificate.read).toBe(false);
|
||||||
expect(perms.destination.read).toBe(false);
|
expect(perms.destination.read).toBe(false);
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockMemberData = (role: string) => ({
|
||||||
|
id: "member-1",
|
||||||
|
role,
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
accessedProjects: [] as string[],
|
||||||
|
accessedServices: [] as string[],
|
||||||
|
accessedEnvironments: [] as string[],
|
||||||
|
accessedServers: [] as string[],
|
||||||
|
canCreateProjects: false,
|
||||||
|
canDeleteProjects: false,
|
||||||
|
canCreateServices: false,
|
||||||
|
canDeleteServices: false,
|
||||||
|
canCreateEnvironments: false,
|
||||||
|
canDeleteEnvironments: false,
|
||||||
|
canAccessToTraefikFiles: false,
|
||||||
|
canAccessToDocker: false,
|
||||||
|
canAccessToAPI: false,
|
||||||
|
canAccessToSSHKeys: false,
|
||||||
|
canAccessToGitProviders: false,
|
||||||
|
user: { id: "user-1", email: "test@test.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let memberToReturn = mockMemberData("deployer");
|
||||||
|
let rolesToReturn: { permission: string }[] = [];
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
member: {
|
||||||
|
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||||
|
findMany: vi.fn(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
organizationRole: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
findMany: vi.fn(() => Promise.resolve(rolesToReturn)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||||
|
hasValidLicense: vi.fn(() => Promise.resolve(true)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { checkPermission, resolvePermissions } = await import(
|
||||||
|
"@dokploy/server/services/permission"
|
||||||
|
);
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
user: { id: "user-1" },
|
||||||
|
session: { activeOrganizationId: "org-1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const withPermissions = (permissions: Record<string, string[]>) => {
|
||||||
|
rolesToReturn = [{ permission: JSON.stringify(permissions) }];
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
memberToReturn = mockMemberData("deployer");
|
||||||
|
rolesToReturn = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("server.terminal on custom roles", () => {
|
||||||
|
it("a role with server.read alone cannot open a terminal", async () => {
|
||||||
|
withPermissions({ server: ["read"] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { server: ["read"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { server: ["terminal"] }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
expect(perms.server.read).toBe(true);
|
||||||
|
expect(perms.server.terminal).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a role with server.terminal can open a terminal", async () => {
|
||||||
|
withPermissions({ server: ["read", "terminal"] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { server: ["terminal"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
expect(perms.server.terminal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("owner and admin keep terminal access", async () => {
|
||||||
|
for (const role of ["owner", "admin"]) {
|
||||||
|
memberToReturn = mockMemberData(role);
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { server: ["terminal"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
expect(perms.server.terminal).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { docker } from "@dokploy/server/constants";
|
||||||
|
import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup";
|
||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const REAL_TEST_TIMEOUT = 120000;
|
||||||
|
const SERVICE_NAME = "dokploy-monitoring";
|
||||||
|
const TEST_IMAGE = "busybox:latest";
|
||||||
|
|
||||||
|
// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to
|
||||||
|
// the local daemon, so the legacy-container cleanup is exercised for real.
|
||||||
|
vi.mock("@dokploy/server/services/server", () => ({
|
||||||
|
findServerById: vi.fn().mockResolvedValue({
|
||||||
|
serverId: "test-server",
|
||||||
|
serverType: "deploy",
|
||||||
|
sshKeyId: null, // -> getRemoteDocker returns the local docker instance
|
||||||
|
metricsConfig: {
|
||||||
|
server: {
|
||||||
|
type: "Remote",
|
||||||
|
port: 4500,
|
||||||
|
token: "test-token",
|
||||||
|
urlCallback: "http://localhost/callback",
|
||||||
|
cronJob: "0 0 * * *",
|
||||||
|
retentionDays: 2,
|
||||||
|
refreshRate: 60,
|
||||||
|
thresholds: { cpu: 0, memory: 0 },
|
||||||
|
},
|
||||||
|
containers: { refreshRate: 60, services: { include: [], exclude: [] } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/settings", () => ({
|
||||||
|
getDokployImageTag: vi.fn(() => "latest"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/docker/utils", () => ({
|
||||||
|
pullImage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
pullRemoteImage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||||
|
execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
|
||||||
|
execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const containerExists = async (name: string) => {
|
||||||
|
try {
|
||||||
|
await docker.getContainer(name).inspect();
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.statusCode === 404) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serviceExists = async (name: string) => {
|
||||||
|
try {
|
||||||
|
await docker.getService(name).inspect();
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.statusCode === 404) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Swarm keeps converging a service for a bit after it's created (scheduling
|
||||||
|
// tasks, resolving endpoints), which bumps Version.Index on its own. Calling
|
||||||
|
// setupMonitoring again before that settles races that internal bump, so wait
|
||||||
|
// for two consecutive reads to agree before treating the service as stable.
|
||||||
|
const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastIndex: string | null = null;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const inspect = await docker.getService(name).inspect();
|
||||||
|
if (inspect.Version.Index === lastIndex) return;
|
||||||
|
lastIndex = inspect.Version.Index;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const swarmTaskNames = async () => {
|
||||||
|
const list = await docker.listContainers({ all: true });
|
||||||
|
return list
|
||||||
|
.flatMap((c) => c.Names.map((n) => n.replace(/^\//, "")))
|
||||||
|
.filter((n) => n.startsWith(`${SERVICE_NAME}.`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
try {
|
||||||
|
await docker.getService(SERVICE_NAME).remove();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
await docker.getContainer(SERVICE_NAME).remove({ force: true });
|
||||||
|
} catch {}
|
||||||
|
for (const name of await swarmTaskNames()) {
|
||||||
|
try {
|
||||||
|
await docker.getContainer(name).remove({ force: true });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop.
|
||||||
|
const createLegacyZombie = async () => {
|
||||||
|
const container = await docker.createContainer({
|
||||||
|
name: SERVICE_NAME,
|
||||||
|
Image: TEST_IMAGE,
|
||||||
|
Cmd: [
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"echo 'Error starting metrics cleanup system: empty spec string'; exit 1",
|
||||||
|
],
|
||||||
|
HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" },
|
||||||
|
});
|
||||||
|
await container.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pullTestImage = async () => {
|
||||||
|
try {
|
||||||
|
await docker.getImage(TEST_IMAGE).inspect();
|
||||||
|
return;
|
||||||
|
} catch {}
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
docker.pull(TEST_IMAGE, (err: any, stream: any) =>
|
||||||
|
err
|
||||||
|
? reject(err)
|
||||||
|
: docker.modem.followProgress(stream, (e: any) =>
|
||||||
|
e ? reject(e) : resolve(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The code under test hardcodes the production name, so this suite cannot
|
||||||
|
// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host.
|
||||||
|
const hasRealMonitoring = () => {
|
||||||
|
const query = (cmd: string) => {
|
||||||
|
try {
|
||||||
|
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
query(
|
||||||
|
`docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`,
|
||||||
|
) !== "" ||
|
||||||
|
query(
|
||||||
|
`docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`,
|
||||||
|
) !== ""
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasDocker = () => {
|
||||||
|
try {
|
||||||
|
execSync("docker info", { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)(
|
||||||
|
"setupMonitoring - legacy container cleanup (real docker)",
|
||||||
|
() => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await pullTestImage();
|
||||||
|
await cleanup();
|
||||||
|
}, REAL_TEST_TIMEOUT);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
}, REAL_TEST_TIMEOUT);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"removes the legacy standalone container left behind by the swarm migration",
|
||||||
|
async () => {
|
||||||
|
await createLegacyZombie();
|
||||||
|
expect(await containerExists(SERVICE_NAME)).toBe(true);
|
||||||
|
|
||||||
|
await setupMonitoring("test-server");
|
||||||
|
|
||||||
|
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||||
|
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"removes the legacy container without touching swarm tasks, which are named dokploy-monitoring.<slot>.<id>",
|
||||||
|
async () => {
|
||||||
|
const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`;
|
||||||
|
const task = await docker.createContainer({
|
||||||
|
name: taskName,
|
||||||
|
Image: TEST_IMAGE,
|
||||||
|
Cmd: ["sleep", "3600"],
|
||||||
|
});
|
||||||
|
await task.start();
|
||||||
|
await createLegacyZombie();
|
||||||
|
|
||||||
|
await setupMonitoring("test-server");
|
||||||
|
|
||||||
|
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||||
|
expect(await containerExists(taskName)).toBe(true);
|
||||||
|
const inspect = await docker.getContainer(taskName).inspect();
|
||||||
|
expect(inspect.State.Running).toBe(true);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"is idempotent when no legacy container exists",
|
||||||
|
async () => {
|
||||||
|
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||||
|
|
||||||
|
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||||
|
await waitForServiceConvergence(SERVICE_NAME);
|
||||||
|
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"deploys the service even when removing the legacy container fails",
|
||||||
|
async () => {
|
||||||
|
const failingDocker = {
|
||||||
|
getContainer: () => ({
|
||||||
|
remove: async () => {
|
||||||
|
const error: any = new Error("device or resource busy");
|
||||||
|
error.statusCode = 500;
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getService: docker.getService.bind(docker),
|
||||||
|
createService: docker.createService.bind(docker),
|
||||||
|
};
|
||||||
|
|
||||||
|
const remoteDocker = await import(
|
||||||
|
"@dokploy/server/utils/servers/remote-docker"
|
||||||
|
);
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(remoteDocker, "getRemoteDocker")
|
||||||
|
.mockResolvedValue(failingDocker as any);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||||
|
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
|
||||||
|
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { reconnectServicesToTraefik } from "@dokploy/server/services/settings";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
findMany: vi.fn(),
|
||||||
|
execAsync: vi.fn(),
|
||||||
|
execAsyncRemote: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
compose: {
|
||||||
|
findMany: mocks.findMany,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||||
|
execAsync: mocks.execAsync,
|
||||||
|
execAsyncRemote: mocks.execAsyncRemote,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("reconnectServicesToTraefik", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.findMany.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not execute an empty local command when no isolated deployments exist", async () => {
|
||||||
|
await reconnectServicesToTraefik();
|
||||||
|
|
||||||
|
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not execute an empty remote command when no isolated deployments exist", async () => {
|
||||||
|
await reconnectServicesToTraefik("server-id");
|
||||||
|
|
||||||
|
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnects isolated deployments to the local Traefik network", async () => {
|
||||||
|
mocks.findMany.mockResolvedValue([
|
||||||
|
{ appName: "first-compose" },
|
||||||
|
{ appName: "second-compose" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await reconnectServicesToTraefik();
|
||||||
|
|
||||||
|
expect(mocks.execAsync).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.execAsync).toHaveBeenCalledWith(
|
||||||
|
'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' +
|
||||||
|
'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
|
||||||
|
);
|
||||||
|
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnects isolated deployments on a remote server", async () => {
|
||||||
|
mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]);
|
||||||
|
|
||||||
|
await reconnectServicesToTraefik("server-id");
|
||||||
|
|
||||||
|
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
|
||||||
|
"server-id",
|
||||||
|
'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
|
||||||
|
);
|
||||||
|
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = {
|
|||||||
docsUrl: null,
|
docsUrl: null,
|
||||||
errorPageTitle: null,
|
errorPageTitle: null,
|
||||||
errorPageDescription: null,
|
errorPageDescription: null,
|
||||||
metaTitle: null,
|
ogImageUrl: null,
|
||||||
footerText: null,
|
footerText: null,
|
||||||
},
|
},
|
||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
execAsyncRemote: vi.fn(),
|
||||||
|
writeFileRemote: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<
|
||||||
|
typeof import("@dokploy/server/utils/process/execAsync")
|
||||||
|
>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
execAsyncRemote: mocks.execAsyncRemote,
|
||||||
|
writeFileRemote: mocks.writeFileRemote,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writeAppTraefikConfig", () => {
|
||||||
|
let cwd: string;
|
||||||
|
let dynamicPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-"));
|
||||||
|
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
|
||||||
|
fs.mkdirSync(dynamicPath, { recursive: true });
|
||||||
|
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
fs.rmSync(cwd, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression test for #5189: Traefik's file provider rejects a standalone
|
||||||
|
// `routers: {}` / `services: {}` map and aborts its watcher for every
|
||||||
|
// dynamic config once it hits one, so an app with no domains must never
|
||||||
|
// get an on-disk config file at all.
|
||||||
|
it("removes the file instead of writing empty routers/services", async () => {
|
||||||
|
const appName = "no-domain-app";
|
||||||
|
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||||
|
fs.writeFileSync(configPath, "stale content", "utf8");
|
||||||
|
|
||||||
|
await writeAppTraefikConfig(
|
||||||
|
{ http: { routers: {}, services: {} } },
|
||||||
|
appName,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fs.existsSync(configPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the file when routers/services are present", async () => {
|
||||||
|
const appName = "with-domain-app";
|
||||||
|
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||||
|
|
||||||
|
await writeAppTraefikConfig(
|
||||||
|
{
|
||||||
|
http: {
|
||||||
|
routers: {
|
||||||
|
[`${appName}-router-1`]: {
|
||||||
|
rule: "Host(`x`)",
|
||||||
|
service: `${appName}-service`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
services: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appName,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fs.existsSync(configPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the remote file instead of writing empty routers/services", async () => {
|
||||||
|
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||||
|
|
||||||
|
await writeAppTraefikConfig(
|
||||||
|
{ http: { routers: {}, services: {} } },
|
||||||
|
"no-domain-app",
|
||||||
|
"server-id",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||||
|
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
|
||||||
|
expect(command).toMatch(/^rm -f /);
|
||||||
|
expect(command).toContain("no-domain-app.yml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the remote file when routers/services are present", async () => {
|
||||||
|
mocks.writeFileRemote.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await writeAppTraefikConfig(
|
||||||
|
{
|
||||||
|
http: {
|
||||||
|
routers: {
|
||||||
|
"with-domain-app-router-1": {
|
||||||
|
rule: "Host(`x`)",
|
||||||
|
service: "with-domain-app-service",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
services: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"with-domain-app",
|
||||||
|
"server-id",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.writeFileRemote).toHaveBeenCalledOnce();
|
||||||
|
const [serverId, remotePath, content] =
|
||||||
|
mocks.writeFileRemote.mock.calls[0] ?? [];
|
||||||
|
expect(serverId).toBe("server-id");
|
||||||
|
expect(remotePath).toContain("with-domain-app.yml");
|
||||||
|
expect(content).toContain("with-domain-app-router-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import {
|
||||||
|
checkDeletionCode,
|
||||||
|
generateDeletionCode,
|
||||||
|
serializeDeletionCode,
|
||||||
|
} from "@dokploy/server/services/account-deletion";
|
||||||
|
import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { cancelStripeSubscriptions } from "@/server/utils/billing";
|
||||||
|
|
||||||
|
const makeStripe = (
|
||||||
|
subscriptions: Array<{ id: string; status: string }>,
|
||||||
|
listError?: Error,
|
||||||
|
) => {
|
||||||
|
const stripe = {
|
||||||
|
subscriptions: {
|
||||||
|
list: vi.fn(() =>
|
||||||
|
listError
|
||||||
|
? Promise.reject(listError)
|
||||||
|
: Promise.resolve({ data: subscriptions }),
|
||||||
|
),
|
||||||
|
cancel: vi.fn(() => Promise.resolve({})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return stripe as unknown as Stripe & typeof stripe;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("cancelStripeSubscriptions", () => {
|
||||||
|
test("cancels every non-terminal subscription without invoicing", async () => {
|
||||||
|
const stripe = makeStripe([
|
||||||
|
{ id: "sub_active", status: "active" },
|
||||||
|
{ id: "sub_trial", status: "trialing" },
|
||||||
|
{ id: "sub_past_due", status: "past_due" },
|
||||||
|
{ id: "sub_canceled", status: "canceled" },
|
||||||
|
{ id: "sub_expired", status: "incomplete_expired" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await cancelStripeSubscriptions("cus_1", stripe);
|
||||||
|
|
||||||
|
expect(stripe.subscriptions.list).toHaveBeenCalledWith({
|
||||||
|
customer: "cus_1",
|
||||||
|
status: "all",
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3);
|
||||||
|
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", {
|
||||||
|
invoice_now: false,
|
||||||
|
prorate: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats a missing customer as having nothing to cancel", async () => {
|
||||||
|
const missing = new Stripe.errors.StripeInvalidRequestError({
|
||||||
|
type: "invalid_request_error",
|
||||||
|
code: "resource_missing",
|
||||||
|
message: "No such customer",
|
||||||
|
});
|
||||||
|
const stripe = makeStripe([], missing);
|
||||||
|
|
||||||
|
const result = await cancelStripeSubscriptions("cus_missing", stripe);
|
||||||
|
|
||||||
|
expect(stripe.subscriptions.cancel).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ cancelledSubscriptions: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("propagates unexpected Stripe errors", async () => {
|
||||||
|
const failure = new Stripe.errors.StripeAPIError({
|
||||||
|
type: "api_error",
|
||||||
|
message: "boom",
|
||||||
|
});
|
||||||
|
const stripe = makeStripe([], failure);
|
||||||
|
|
||||||
|
await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe(
|
||||||
|
failure,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteHubSpotContactByEmail", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is skipped when no access token is configured", async () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe(
|
||||||
|
"skipped",
|
||||||
|
);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calls the GDPR delete endpoint with the email as id", async () => {
|
||||||
|
const fetchMock = vi.fn(() =>
|
||||||
|
Promise.resolve(new Response(null, { status: 204 })),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
|
||||||
|
"deleted",
|
||||||
|
);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
RequestInit,
|
||||||
|
];
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete",
|
||||||
|
);
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||||
|
"Bearer token",
|
||||||
|
);
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
idProperty: "email",
|
||||||
|
objectId: "a@b.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps 404 to not_found and other errors to failed", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))),
|
||||||
|
);
|
||||||
|
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
|
||||||
|
"not_found",
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))),
|
||||||
|
);
|
||||||
|
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
|
||||||
|
"failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("account deletion codes", () => {
|
||||||
|
test("generates six-digit numeric codes", () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
expect(generateDeletionCode()).toMatch(/^\d{6}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts the right code, ignoring surrounding whitespace", () => {
|
||||||
|
const value = serializeDeletionCode("482913");
|
||||||
|
|
||||||
|
expect(checkDeletionCode(value, "482913")).toEqual({
|
||||||
|
valid: true,
|
||||||
|
attempts: 0,
|
||||||
|
});
|
||||||
|
expect(checkDeletionCode(value, " 482913 ").valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("counts failed attempts without revealing the hash", () => {
|
||||||
|
const value = serializeDeletionCode("482913");
|
||||||
|
|
||||||
|
const first = checkDeletionCode(value, "000000");
|
||||||
|
expect(first.valid).toBe(false);
|
||||||
|
if (first.valid) throw new Error("unreachable");
|
||||||
|
expect(first.attempts).toBe(1);
|
||||||
|
expect(JSON.parse(first.nextValue)).toEqual({
|
||||||
|
...JSON.parse(value),
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = checkDeletionCode(first.nextValue, "999999");
|
||||||
|
expect(second.valid).toBe(false);
|
||||||
|
if (second.valid) throw new Error("unreachable");
|
||||||
|
expect(second.attempts).toBe(2);
|
||||||
|
|
||||||
|
expect(checkDeletionCode(second.nextValue, "482913")).toEqual({
|
||||||
|
valid: true,
|
||||||
|
attempts: 2,
|
||||||
|
});
|
||||||
|
expect(value).not.toContain("482913");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/server", () => ({
|
||||||
|
findServerById: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
|
||||||
|
|
||||||
|
describe("pipeBetweenServers", () => {
|
||||||
|
it("delivers a short source stream that ends before the target is ready", async () => {
|
||||||
|
const bytes = await pipeBetweenServers({
|
||||||
|
source: { serverId: null, command: "printf 'hello world'" },
|
||||||
|
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
|
||||||
|
});
|
||||||
|
expect(bytes).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pipes data larger than the pipe buffer unchanged", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
||||||
|
const source = path.join(dir, "src.bin");
|
||||||
|
const target = path.join(dir, "dst.bin");
|
||||||
|
const size = 1024 * 1024;
|
||||||
|
const progress: number[] = [];
|
||||||
|
|
||||||
|
const bytes = await pipeBetweenServers({
|
||||||
|
source: {
|
||||||
|
serverId: null,
|
||||||
|
command: `head -c ${size} /dev/urandom | tee '${source}'`,
|
||||||
|
},
|
||||||
|
target: { serverId: null, command: `cat > '${target}'` },
|
||||||
|
onProgress: (transferred) => progress.push(transferred),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bytes).toBe(size);
|
||||||
|
expect(progress.at(-1)).toBe(size);
|
||||||
|
expect(await fs.readFile(target)).toEqual(await fs.readFile(source));
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("reports a target failure with its stderr", async () => {
|
||||||
|
await expect(
|
||||||
|
pipeBetweenServers({
|
||||||
|
source: { serverId: null, command: "printf x" },
|
||||||
|
target: { serverId: null, command: "echo boom >&2; exit 3" },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("target exited with code 3: boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a source failure", async () => {
|
||||||
|
await expect(
|
||||||
|
pipeBetweenServers({
|
||||||
|
source: { serverId: null, command: "exit 2" },
|
||||||
|
target: { serverId: null, command: "cat > /dev/null" },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("source exited with code 2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,10 +95,35 @@ describe("canAccessTerminalOverWss", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("gates a remote server terminal on server access", async () => {
|
it("gates a remote server terminal on server access", async () => {
|
||||||
|
mockHasPermission.mockResolvedValue(true);
|
||||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
||||||
// role lookup must not be needed for the remote path
|
// the remote path must never fall through to the owner/admin local branch
|
||||||
expect(mockFindMember).not.toHaveBeenCalled();
|
expect(mockFindMember).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("denies a remote server terminal without the server.terminal permission", async () => {
|
||||||
|
// Reaching a server (to deploy on it) must not imply a root shell on it.
|
||||||
|
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||||
|
mockHasPermission.mockResolvedValue(false);
|
||||||
|
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false);
|
||||||
|
expect(mockHasPermission).toHaveBeenCalledWith(
|
||||||
|
{ user: { id: USER.id }, session: { activeOrganizationId: "org-1" } },
|
||||||
|
{ server: ["terminal"] },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a remote server terminal with the server.terminal permission", async () => {
|
||||||
|
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||||
|
mockHasPermission.mockResolvedValue(true);
|
||||||
|
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not check permissions for a server the caller cannot access", async () => {
|
||||||
|
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||||
|
mockHasPermission.mockResolvedValue(true);
|
||||||
|
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
||||||
|
expect(mockHasPermission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import http from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
class FakeChild extends EventEmitter {
|
||||||
|
stdout = new EventEmitter();
|
||||||
|
stderr = new EventEmitter();
|
||||||
|
killed = false;
|
||||||
|
exitCode: number | null = null;
|
||||||
|
signalCode: NodeJS.Signals | null = null;
|
||||||
|
signals: string[] = [];
|
||||||
|
// Mirrors ChildProcess: `killed` flips as soon as a signal is delivered,
|
||||||
|
// even if the process ignores it and keeps running.
|
||||||
|
ignoreSigterm = false;
|
||||||
|
|
||||||
|
kill(signal: NodeJS.Signals = "SIGTERM") {
|
||||||
|
this.signals.push(signal);
|
||||||
|
this.killed = true;
|
||||||
|
if (signal === "SIGTERM" && this.ignoreSigterm) return true;
|
||||||
|
this.signalCode = signal;
|
||||||
|
this.emit("close", null, signal);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = vi.hoisted(() => [] as FakeChild[]);
|
||||||
|
const nextChild = vi.hoisted(() => ({ ignoreSigterm: false }));
|
||||||
|
vi.mock("node:child_process", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("node:child_process")>()),
|
||||||
|
spawn: vi.fn(() => {
|
||||||
|
const child = new FakeChild();
|
||||||
|
child.ignoreSigterm = nextChild.ignoreSigterm;
|
||||||
|
children.push(child);
|
||||||
|
return child;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sshClients = vi.hoisted(
|
||||||
|
() => [] as { connect: ReturnType<typeof vi.fn> }[],
|
||||||
|
);
|
||||||
|
vi.mock("ssh2", () => ({
|
||||||
|
Client: vi.fn(function (this: Record<string, unknown>) {
|
||||||
|
this.on = vi.fn(() => this);
|
||||||
|
this.connect = vi.fn(() => this);
|
||||||
|
this.end = vi.fn();
|
||||||
|
sshClients.push(this as never);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockValidateRequest = vi.hoisted(() => vi.fn());
|
||||||
|
const mockFindServerById = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@dokploy/server", () => ({
|
||||||
|
IS_CLOUD: false,
|
||||||
|
validateRequest: mockValidateRequest,
|
||||||
|
findServerById: mockFindServerById,
|
||||||
|
}));
|
||||||
|
vi.mock("@dokploy/server/wss/utils", () => ({
|
||||||
|
readValidDirectory: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployment";
|
||||||
|
|
||||||
|
const AUTH = {
|
||||||
|
user: { id: "user-1" },
|
||||||
|
session: { activeOrganizationId: "org-1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = () => new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
let server: http.Server;
|
||||||
|
let url: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
children.length = 0;
|
||||||
|
sshClients.length = 0;
|
||||||
|
nextChild.ignoreSigterm = false;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
server = http.createServer();
|
||||||
|
setupDeploymentLogsWebSocketServer(server);
|
||||||
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
url = `ws://127.0.0.1:${port}/listen-deployment?logPath=/etc/dokploy/logs/app/deploy.log`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await new Promise((r) => server.close(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
const connect = async (query = "") => {
|
||||||
|
const ws = new WebSocket(`${url}${query}`);
|
||||||
|
await new Promise((r) => ws.once("open", r));
|
||||||
|
return ws;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeClient = async (ws: WebSocket) => {
|
||||||
|
ws.close();
|
||||||
|
await new Promise((r) => ws.once("close", r));
|
||||||
|
await flush();
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("deployment logs websocket (#5518)", () => {
|
||||||
|
it("kills tail when the client disconnects", async () => {
|
||||||
|
mockValidateRequest.mockResolvedValue(AUTH);
|
||||||
|
const ws = await connect();
|
||||||
|
await flush();
|
||||||
|
expect(children).toHaveLength(1);
|
||||||
|
|
||||||
|
await closeClient(ws);
|
||||||
|
expect(children[0]!.signals).toContain("SIGTERM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not spawn tail when the client disconnects during auth", async () => {
|
||||||
|
const auth = deferred<typeof AUTH>();
|
||||||
|
mockValidateRequest.mockReturnValue(auth.promise);
|
||||||
|
const ws = await connect();
|
||||||
|
|
||||||
|
await closeClient(ws);
|
||||||
|
auth.resolve(AUTH);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const leaked = children.filter(
|
||||||
|
(c) => c.exitCode === null && c.signalCode === null,
|
||||||
|
);
|
||||||
|
expect(leaked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open an SSH session when the client disconnects while loading the server", async () => {
|
||||||
|
mockValidateRequest.mockResolvedValue(AUTH);
|
||||||
|
const serverLookup = deferred<unknown>();
|
||||||
|
mockFindServerById.mockReturnValue(serverLookup.promise);
|
||||||
|
const ws = await connect("&serverId=srv-1");
|
||||||
|
|
||||||
|
await closeClient(ws);
|
||||||
|
serverLookup.resolve({
|
||||||
|
organizationId: "org-1",
|
||||||
|
sshKeyId: "key-1",
|
||||||
|
sshKey: { privateKey: "key" },
|
||||||
|
ipAddress: "10.0.0.1",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const connected = sshClients.filter((c) => c.connect.mock.calls.length > 0);
|
||||||
|
expect(connected).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("force-kills tail with SIGKILL when SIGTERM does not stop it", async () => {
|
||||||
|
nextChild.ignoreSigterm = true;
|
||||||
|
mockValidateRequest.mockResolvedValue(AUTH);
|
||||||
|
const ws = await connect();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
await closeClient(ws);
|
||||||
|
await new Promise((r) => setTimeout(r, 1100));
|
||||||
|
expect(children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -94,4 +94,32 @@ describe("readValidDirectory (path traversal)", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns true for SvelteKit routes with + prefix and @ symbols", () => {
|
||||||
|
expect(
|
||||||
|
readValidDirectory(
|
||||||
|
`${BASE}/applications/myapp/code/src/routes/+page.svelte`,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
readValidDirectory(
|
||||||
|
`${BASE}/applications/myapp/code/src/routes/+layout.svelte`,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
readValidDirectory(
|
||||||
|
`${BASE}/applications/myapp/code/src/routes/+server.ts`,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
readValidDirectory(
|
||||||
|
`${BASE}/applications/myapp/code/src/routes/+error.svelte`,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
readValidDirectory(
|
||||||
|
`${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,4 +8,3 @@ export { RestartPolicyForm } from "./restart-policy-form";
|
|||||||
export { RollbackConfigForm } from "./rollback-config-form";
|
export { RollbackConfigForm } from "./rollback-config-form";
|
||||||
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||||
export { UpdateConfigForm } from "./update-config-form";
|
export { UpdateConfigForm } from "./update-config-form";
|
||||||
export { filterEmptyValues, hasValues } from "./utils";
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* Filters out undefined, null, and empty string values from form data
|
|
||||||
* Only returns fields that have actual values
|
|
||||||
*/
|
|
||||||
export const filterEmptyValues = (
|
|
||||||
formData: Record<string, any>,
|
|
||||||
): Record<string, any> => {
|
|
||||||
return Object.entries(formData).reduce(
|
|
||||||
(acc, [key, value]) => {
|
|
||||||
// Keep arrays even if empty (they might be intentionally cleared)
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
if (value.length > 0) {
|
|
||||||
acc[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For other values, filter out undefined, null, and empty strings
|
|
||||||
else if (value !== undefined && value !== null && value !== "") {
|
|
||||||
acc[key] = value;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, any>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if filtered data has any values to save
|
|
||||||
*/
|
|
||||||
export const hasValues = (data: Record<string, any>): boolean => {
|
|
||||||
return Object.keys(data).length > 0;
|
|
||||||
};
|
|
||||||
@@ -31,6 +31,45 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||||
export const RAILPACK_VERSIONS = [
|
export const RAILPACK_VERSIONS = [
|
||||||
|
"0.39.0",
|
||||||
|
"0.38.0",
|
||||||
|
"0.37.1",
|
||||||
|
"0.37.0",
|
||||||
|
"0.36.4",
|
||||||
|
"0.36.3",
|
||||||
|
"0.36.2",
|
||||||
|
"0.36.1",
|
||||||
|
"0.36.0",
|
||||||
|
"0.35.0",
|
||||||
|
"0.34.0",
|
||||||
|
"0.33.0",
|
||||||
|
"0.32.0",
|
||||||
|
"0.31.2",
|
||||||
|
"0.31.1",
|
||||||
|
"0.31.0",
|
||||||
|
"0.30.1",
|
||||||
|
"0.30.0",
|
||||||
|
"0.29.0",
|
||||||
|
"0.28.0",
|
||||||
|
"0.27.2",
|
||||||
|
"0.27.1",
|
||||||
|
"0.27.0",
|
||||||
|
"0.26.1",
|
||||||
|
"0.26.0",
|
||||||
|
"0.25.0",
|
||||||
|
"0.24.0",
|
||||||
|
"0.23.0",
|
||||||
|
"0.22.2",
|
||||||
|
"0.22.1",
|
||||||
|
"0.22.0",
|
||||||
|
"0.21.0",
|
||||||
|
"0.20.0",
|
||||||
|
"0.19.0",
|
||||||
|
"0.18.0",
|
||||||
|
"0.17.2",
|
||||||
|
"0.17.1",
|
||||||
|
"0.17.0",
|
||||||
|
"0.16.0",
|
||||||
"0.15.4",
|
"0.15.4",
|
||||||
"0.15.3",
|
"0.15.3",
|
||||||
"0.15.2",
|
"0.15.2",
|
||||||
@@ -45,12 +84,22 @@ export const RAILPACK_VERSIONS = [
|
|||||||
"0.9.1",
|
"0.9.1",
|
||||||
"0.9.0",
|
"0.9.0",
|
||||||
"0.8.0",
|
"0.8.0",
|
||||||
|
"0.7.2",
|
||||||
|
"0.7.1",
|
||||||
"0.7.0",
|
"0.7.0",
|
||||||
|
"0.6.1",
|
||||||
"0.6.0",
|
"0.6.0",
|
||||||
|
"0.5.1",
|
||||||
"0.5.0",
|
"0.5.0",
|
||||||
"0.4.0",
|
"0.4.0",
|
||||||
"0.3.0",
|
"0.3.0",
|
||||||
|
"0.2.3",
|
||||||
"0.2.2",
|
"0.2.2",
|
||||||
|
"0.2.1",
|
||||||
|
"0.2.0",
|
||||||
|
"0.1.2",
|
||||||
|
"0.1.1",
|
||||||
|
"0.1.0",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export enum BuildType {
|
export enum BuildType {
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export const ShowDeployments = ({
|
|||||||
)}
|
)}
|
||||||
{/* Hash (from description) - shown in compact form */}
|
{/* Hash (from description) - shown in compact form */}
|
||||||
{deployment.description?.trim() && (
|
{deployment.description?.trim() && (
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
<span className="wrap-anywhere text-xs text-muted-foreground font-mono">
|
||||||
{deployment.description}
|
{deployment.description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
domainId,
|
domainId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!domainId,
|
enabled: isOpen && !!domainId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
applicationId: id,
|
applicationId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!id,
|
enabled: isOpen && !!id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: api.compose.one.useQuery(
|
: api.compose.one.useQuery(
|
||||||
@@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
composeId: id,
|
composeId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!id,
|
enabled: isOpen && !!id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -187,9 +187,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
api.domain.generateDomain.useMutation();
|
api.domain.generateDomain.useMutation();
|
||||||
|
|
||||||
const { data: canGenerateTraefikMeDomains } =
|
const { data: canGenerateTraefikMeDomains } =
|
||||||
api.domain.canGenerateTraefikMeDomains.useQuery({
|
api.domain.canGenerateTraefikMeDomains.useQuery(
|
||||||
serverId: application?.serverId || "",
|
{
|
||||||
});
|
serverId: application?.serverId || "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isOpen,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: services,
|
data: services,
|
||||||
@@ -204,7 +209,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
{
|
{
|
||||||
retry: false,
|
retry: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
enabled: type === "compose" && !!id,
|
enabled: isOpen && type === "compose" && !!id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -191,8 +191,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
try {
|
try {
|
||||||
const result = await validateDomain({
|
const result = await validateDomain({
|
||||||
domain: host,
|
domain: host,
|
||||||
serverIp:
|
serverId: application?.serverId ?? undefined,
|
||||||
application?.server?.ipAddress?.toString() || ip?.toString() || "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setValidationStates((prev) => ({
|
setValidationStates((prev) => ({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||||
|
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -197,6 +198,18 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
name="environment"
|
name="environment"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<VaultImportDialog
|
||||||
|
projectId={data?.environment?.projectId}
|
||||||
|
environmentId={data?.environment?.environmentId}
|
||||||
|
currentEnv={field.value ?? ""}
|
||||||
|
onImport={(next) =>
|
||||||
|
form.setValue("environment", next, {
|
||||||
|
shouldDirty: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<FormControl className="">
|
<FormControl className="">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
style={
|
style={
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
}
|
}
|
||||||
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
|
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
|
||||||
completionSource={completionSource}
|
completionSource={completionSource}
|
||||||
|
projectId={data?.environment?.projectId}
|
||||||
|
environmentId={data?.environment?.environmentId}
|
||||||
/>
|
/>
|
||||||
{data?.buildType === "dockerfile" && (
|
{data?.buildType === "dockerfile" && (
|
||||||
<Secrets
|
<Secrets
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import DOMPurify from "dompurify";
|
|
||||||
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
|
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||||
|
|
||||||
interface ShowIconSettingsProps {
|
interface ShowIconSettingsProps {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
@@ -89,15 +89,6 @@ export const ShowIconSettings = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeSvg = (svgContent: string): string | null => {
|
|
||||||
const clean = DOMPurify.sanitize(svgContent, {
|
|
||||||
USE_PROFILES: { svg: true, svgFilters: true },
|
|
||||||
ADD_TAGS: ["use"],
|
|
||||||
});
|
|
||||||
if (!clean) return null;
|
|
||||||
return `data:image/svg+xml;base64,${btoa(clean)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async (files: FileList | null) => {
|
const handleFileUpload = async (files: FileList | null) => {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -79,17 +80,13 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const availableContainers = option === "native" ? containers : services;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (option === "native") {
|
setContainerId((currentContainerId) =>
|
||||||
if (containers && containers?.length > 0) {
|
resolveContainerSelection(currentContainerId, availableContainers),
|
||||||
setContainerId(containers[0]?.containerId);
|
);
|
||||||
}
|
}, [availableContainers]);
|
||||||
} else {
|
|
||||||
if (services && services?.length > 0) {
|
|
||||||
setContainerId(services[0]?.containerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [option, services, containers]);
|
|
||||||
|
|
||||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||||
const containersLength =
|
const containersLength =
|
||||||
@@ -105,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<div className="flex flex-row justify-between items-center gap-2">
|
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-2">
|
||||||
<Label>Select a container to view logs</Label>
|
<Label>Select a container to view logs</Label>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -114,6 +111,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={option === "native"}
|
checked={option === "native"}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
setContainerId(undefined);
|
||||||
setOption(checked ? "native" : "swarm");
|
setOption(checked ? "native" : "swarm");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||||
<div>
|
<div className="flex flex-col gap-1">
|
||||||
<CardTitle>Patches</CardTitle>
|
<CardTitle className="text-xl">Patches</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Apply code patches to your repository during build. Patches are
|
Apply code patches to your repository during build. Patches are
|
||||||
applied after cloning the repository and before building.
|
applied after cloning the repository and before building.
|
||||||
|
|||||||
@@ -77,23 +77,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
<Card className="bg-background h-full min-h-[50vh]">
|
||||||
<CardHeader className="px-0">
|
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||||
<div className="flex justify-between items-center gap-y-2 flex-wrap">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-2">
|
<CardTitle className="text-xl">Scheduled Tasks</CardTitle>
|
||||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
<CardDescription>
|
||||||
Scheduled Tasks
|
Schedule tasks to run automatically at specified intervals.
|
||||||
</CardTitle>
|
</CardDescription>
|
||||||
<CardDescription>
|
|
||||||
Schedule tasks to run automatically at specified intervals.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
{schedules && schedules.length > 0 && (
|
|
||||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{schedules && schedules.length > 0 && (
|
||||||
|
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0">
|
<CardContent>
|
||||||
{isLoadingSchedules ? (
|
{isLoadingSchedules ? (
|
||||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
|
|||||||
+18
-27
@@ -77,35 +77,26 @@ export const ShowVolumeBackups = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
<Card className="bg-background h-full min-h-[50vh]">
|
||||||
<CardHeader className="px-0">
|
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||||
<div className="flex justify-between items-center flex-wrap gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-2">
|
<CardTitle className="text-xl">Volume Backups</CardTitle>
|
||||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
<CardDescription>
|
||||||
Volume Backups
|
Schedule volume backups to run automatically at specified intervals
|
||||||
</CardTitle>
|
</CardDescription>
|
||||||
<CardDescription>
|
</div>
|
||||||
Schedule volume backups to run automatically at specified
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
intervals
|
{volumeBackups && volumeBackups.length > 0 && (
|
||||||
</CardDescription>
|
<>
|
||||||
</div>
|
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2">
|
||||||
{volumeBackups && volumeBackups.length > 0 && (
|
<RestoreVolumeBackups id={id} type={type} serverId={serverId} />
|
||||||
<>
|
</div>
|
||||||
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
</>
|
||||||
<div className="flex items-center gap-2">
|
)}
|
||||||
<RestoreVolumeBackups
|
|
||||||
id={id}
|
|
||||||
type={type}
|
|
||||||
serverId={serverId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0">
|
<CardContent>
|
||||||
{isLoadingVolumeBackups ? (
|
{isLoadingVolumeBackups ? (
|
||||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Rocket } from "lucide-react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const TrialBanner = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||||
|
const { mutateAsync: createCustomerPortalSession, isPending } =
|
||||||
|
api.stripe.createCustomerPortalSession.useMutation();
|
||||||
|
|
||||||
|
const handleAddPaymentMethod = async () => {
|
||||||
|
const session = await createCustomerPortalSession().catch(() => null);
|
||||||
|
if (session?.url) {
|
||||||
|
window.open(session.url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/dashboard/settings/billing");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
|
||||||
|
const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-20 mx-4 mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Rocket className="h-4 w-4 text-primary shrink-0" />
|
||||||
|
{daysRemaining > 0
|
||||||
|
? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.`
|
||||||
|
: "Your free trial ends today. Add a payment method to keep your servers."}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="h-7"
|
||||||
|
isLoading={isPending}
|
||||||
|
onClick={handleAddPaymentMethod}
|
||||||
|
>
|
||||||
|
Add payment method
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,6 +31,7 @@ interface Props {
|
|||||||
|
|
||||||
const AddRedirectSchema = z.object({
|
const AddRedirectSchema = z.object({
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
|
pullImages: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||||
@@ -57,22 +59,25 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
|||||||
const form = useForm<AddCommand>({
|
const form = useForm<AddCommand>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
command: "",
|
command: "",
|
||||||
|
pullImages: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRedirectSchema),
|
resolver: zodResolver(AddRedirectSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.command) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
command: data?.command || "",
|
command: data.command || "",
|
||||||
|
pullImages: data.pullImages ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddCommand) => {
|
const onSubmit = async (formData: AddCommand) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
composeId,
|
composeId,
|
||||||
command: data?.command,
|
command: formData.command,
|
||||||
|
pullImages: formData.pullImages,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Command Updated");
|
toast.success("Command Updated");
|
||||||
@@ -109,6 +114,30 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
|||||||
<strong>docker</strong>.
|
<strong>docker</strong>.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{data?.composeType === "docker-compose" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="pullImages"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Pull latest images on deploy</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Adds <strong>--pull always</strong> to the default
|
||||||
|
command so every deploy fetches the newest image for
|
||||||
|
each tag. Has no effect when a custom command is set.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="command"
|
name="command"
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { AlertTriangle, Copy, HardDriveDownload } from "lucide-react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
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 { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const freshVolumesSchema = z.object({
|
||||||
|
confirmName: z.string().min(1, {
|
||||||
|
message: "Compose name is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FreshVolumesForm = z.infer<typeof freshVolumesSchema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
composeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FreshVolumes = ({ composeId }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canDeploy = permissions?.deployment.create ?? false;
|
||||||
|
const { data, refetch } = api.compose.one.useQuery(
|
||||||
|
{ composeId },
|
||||||
|
{ enabled: !!composeId },
|
||||||
|
);
|
||||||
|
const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<FreshVolumesForm>({
|
||||||
|
defaultValues: { confirmName: "" },
|
||||||
|
resolver: zodResolver(freshVolumesSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedName = `${data?.name}/${data?.appName}`;
|
||||||
|
const isRunning = data?.composeStatus === "running";
|
||||||
|
|
||||||
|
const onSubmit = async (formData: FreshVolumesForm) => {
|
||||||
|
if (formData.confirmName !== expectedName) {
|
||||||
|
form.setError("confirmName", {
|
||||||
|
message: `Compose name must match "${expectedName}"`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deploy({ composeId, freshVolumes: true })
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Compose deployed with fresh volumes");
|
||||||
|
setIsOpen(false);
|
||||||
|
form.reset();
|
||||||
|
refetch();
|
||||||
|
router.push(
|
||||||
|
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deploying compose");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canDeploy || data?.composeType !== "docker-compose") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-background border-destructive/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
Danger Zone
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
Deploy with Fresh Volumes
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Permanently wipes every volume of this compose and redeploys it
|
||||||
|
from a clean state. All persistent data (databases, uploads,
|
||||||
|
caches) will be lost.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsOpen(open);
|
||||||
|
if (!open) form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
isLoading={isPending}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
|
||||||
|
>
|
||||||
|
<HardDriveDownload className="mr-2 h-4 w-4" />
|
||||||
|
Deploy with Fresh Volumes
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
Are you absolutely sure?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>This action will:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>Stop the current compose</li>
|
||||||
|
<li>Delete all volumes and their data</li>
|
||||||
|
<li>Redeploy the compose with empty volumes</li>
|
||||||
|
</ul>
|
||||||
|
<p className="font-medium text-destructive mt-4">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
id="hook-form-fresh-volumes"
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirmName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
To confirm, type{" "}
|
||||||
|
<Badge
|
||||||
|
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (data?.name && data?.appName) {
|
||||||
|
copy(expectedName);
|
||||||
|
toast.success(
|
||||||
|
"Copied to clipboard. Be careful!",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expectedName}
|
||||||
|
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||||
|
</Badge>{" "}
|
||||||
|
in the box below:
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter compose name to confirm"
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
{isRunning && (
|
||||||
|
<AlertBlock type="warning" className="w-full">
|
||||||
|
Cannot deploy with fresh volumes while a deployment is
|
||||||
|
running. Please wait for it to finish and then try again.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => setIsOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isPending}
|
||||||
|
disabled={isRunning}
|
||||||
|
form="hook-form-fresh-volumes"
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Wipe volumes and deploy
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
-1
@@ -158,7 +158,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
composeId,
|
composeId,
|
||||||
sourceType: "bitbucket",
|
sourceType: "bitbucket",
|
||||||
composeStatus: "idle",
|
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
|
|||||||
-1
@@ -97,7 +97,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
composeId,
|
composeId,
|
||||||
sourceType: "git",
|
sourceType: "git",
|
||||||
composePath: values.composePath,
|
composePath: values.composePath,
|
||||||
composeStatus: "idle",
|
|
||||||
watchPaths: values.watchPaths || [],
|
watchPaths: values.watchPaths || [],
|
||||||
enableSubmodules: values.enableSubmodules,
|
enableSubmodules: values.enableSubmodules,
|
||||||
})
|
})
|
||||||
|
|||||||
-1
@@ -157,7 +157,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
giteaId: data.giteaId,
|
giteaId: data.giteaId,
|
||||||
composeId,
|
composeId,
|
||||||
sourceType: "gitea",
|
sourceType: "gitea",
|
||||||
composeStatus: "idle",
|
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
} as any)
|
} as any)
|
||||||
|
|||||||
-1
@@ -155,7 +155,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
githubId: data.githubId,
|
githubId: data.githubId,
|
||||||
sourceType: "github",
|
sourceType: "github",
|
||||||
composeStatus: "idle",
|
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
triggerType: data.triggerType,
|
triggerType: data.triggerType,
|
||||||
|
|||||||
-1
@@ -169,7 +169,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
gitlabProjectId: data.repository.id,
|
gitlabProjectId: data.repository.id,
|
||||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||||
sourceType: "gitlab",
|
sourceType: "gitlab",
|
||||||
composeStatus: "idle",
|
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||||
|
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -54,6 +55,7 @@ export const ShowDockerLogsStack = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!appName && option === "swarm",
|
enabled: !!appName && option === "swarm",
|
||||||
|
refetchInterval: 5000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -66,22 +68,18 @@ export const ShowDockerLogsStack = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!appName && option === "native",
|
enabled: !!appName && option === "native",
|
||||||
|
refetchInterval: 5000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const containers = data?.filter((container) => container.containerId);
|
const containers = data?.filter((container) => container.containerId);
|
||||||
|
const availableContainers = option === "native" ? containers : services;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (option === "native") {
|
setContainerId((currentContainerId) =>
|
||||||
if (containers && containers?.length > 0) {
|
resolveContainerSelection(currentContainerId, availableContainers),
|
||||||
setContainerId(containers[0]?.containerId);
|
);
|
||||||
}
|
}, [availableContainers]);
|
||||||
} else {
|
|
||||||
if (services && services?.length > 0) {
|
|
||||||
setContainerId(services[0]?.containerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [option, services, containers]);
|
|
||||||
|
|
||||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||||
const containersLength =
|
const containersLength =
|
||||||
@@ -106,6 +104,7 @@ export const ShowDockerLogsStack = ({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={option === "native"}
|
checked={option === "native"}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
setContainerId(undefined);
|
||||||
setOption(checked ? "native" : "swarm");
|
setOption(checked ? "native" : "swarm");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||||
|
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -52,14 +53,15 @@ export const ShowDockerLogsCompose = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!appName,
|
enabled: !!appName,
|
||||||
|
refetchInterval: 5000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const [containerId, setContainerId] = useState<string | undefined>();
|
const [containerId, setContainerId] = useState<string | undefined>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && data?.length > 0) {
|
setContainerId((currentContainerId) =>
|
||||||
setContainerId(data[0]?.containerId);
|
resolveContainerSelection(currentContainerId, data),
|
||||||
}
|
);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
|
||||||
ClipboardList,
|
|
||||||
Database,
|
|
||||||
DatabaseBackup,
|
|
||||||
Play,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -101,12 +95,9 @@ export const ShowBackups = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
|
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-1">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl">Backups</CardTitle>
|
||||||
<Database className="size-6 text-muted-foreground" />
|
|
||||||
Backups
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Add backups to your database to save the data to a different
|
Add backups to your database to save the data to a different
|
||||||
provider.
|
provider.
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ColumnFiltersState,
|
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
@@ -27,7 +25,6 @@ import Link from "next/link";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
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 { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -97,14 +94,21 @@ function getServiceInfo(d: DeploymentRow) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShowDeploymentsTable() {
|
interface ShowDeploymentsTableProps {
|
||||||
|
globalFilter: string;
|
||||||
|
statusFilter: string;
|
||||||
|
typeFilter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShowDeploymentsTable({
|
||||||
|
globalFilter,
|
||||||
|
statusFilter,
|
||||||
|
typeFilter,
|
||||||
|
}: ShowDeploymentsTableProps) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: "createdAt", desc: true },
|
{ id: "createdAt", desc: true },
|
||||||
]);
|
]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
||||||
const [globalFilter, setGlobalFilter] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
@@ -446,177 +450,140 @@ export function ShowDeploymentsTable() {
|
|||||||
columns,
|
columns,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
|
||||||
globalFilter,
|
|
||||||
pagination,
|
pagination,
|
||||||
},
|
},
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="px-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{isLoading ? (
|
||||||
<Input
|
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
||||||
placeholder="Search by name, project, environment, server..."
|
<Loader2 className="size-4 animate-spin" />
|
||||||
value={globalFilter}
|
<span>Loading deployments...</span>
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
</div>
|
||||||
className="max-w-xs"
|
) : (
|
||||||
/>
|
<>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<div className="rounded-md border overflow-x-auto">
|
||||||
<SelectTrigger className="w-[140px]">
|
<Table>
|
||||||
<SelectValue placeholder="Status" />
|
<TableHeader>
|
||||||
</SelectTrigger>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<SelectContent>
|
<TableRow key={headerGroup.id}>
|
||||||
<SelectItem value="all">All statuses</SelectItem>
|
{headerGroup.headers.map((header) => (
|
||||||
<SelectItem value="running">Running</SelectItem>
|
<TableHead key={header.id}>
|
||||||
<SelectItem value="done">Done</SelectItem>
|
{header.isPlaceholder
|
||||||
<SelectItem value="error">Error</SelectItem>
|
? null
|
||||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
: flexRender(
|
||||||
</SelectContent>
|
header.column.columnDef.header,
|
||||||
</Select>
|
header.getContext(),
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
)}
|
||||||
<SelectTrigger className="w-[140px]">
|
</TableHead>
|
||||||
<SelectValue placeholder="Type" />
|
))}
|
||||||
</SelectTrigger>
|
</TableRow>
|
||||||
<SelectContent>
|
))}
|
||||||
<SelectItem value="all">All types</SelectItem>
|
</TableHeader>
|
||||||
<SelectItem value="application">Application</SelectItem>
|
<TableBody>
|
||||||
<SelectItem value="compose">Compose</SelectItem>
|
{table.getRowModel().rows?.length ? (
|
||||||
</SelectContent>
|
table.getRowModel().rows.map((row) => (
|
||||||
</Select>
|
<TableRow key={row.id}>
|
||||||
</div>
|
{row.getVisibleCells().map((cell) => (
|
||||||
<div className="px-0">
|
<TableCell key={cell.id}>
|
||||||
{isLoading ? (
|
{flexRender(
|
||||||
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
cell.column.columnDef.cell,
|
||||||
<Loader2 className="size-4 animate-spin" />
|
cell.getContext(),
|
||||||
<span>Loading deployments...</span>
|
)}
|
||||||
</div>
|
</TableCell>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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>
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className=" text-center"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Rocket className="size-8" />
|
||||||
|
<p className="font-medium">No deployments found</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Deployments from applications and compose will appear
|
||||||
|
here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
Rows per page
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={String(pagination.pageSize)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPagination((p) => ({
|
||||||
|
...p,
|
||||||
|
pageSize: Number(value),
|
||||||
|
pageIndex: 0,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 25, 50, 100].map((size) => (
|
||||||
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</SelectContent>
|
||||||
<TableBody>
|
</Select>
|
||||||
{table.getRowModel().rows?.length ? (
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
table.getRowModel().rows.map((row) => (
|
Showing{" "}
|
||||||
<TableRow key={row.id}>
|
{filteredData.length === 0
|
||||||
{row.getVisibleCells().map((cell) => (
|
? 0
|
||||||
<TableCell key={cell.id}>
|
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
||||||
{flexRender(
|
to{" "}
|
||||||
cell.column.columnDef.cell,
|
{Math.min(
|
||||||
cell.getContext(),
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
)}
|
filteredData.length,
|
||||||
</TableCell>
|
)}{" "}
|
||||||
))}
|
of {filteredData.length} entries
|
||||||
</TableRow>
|
</span>
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className=" text-center"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
|
||||||
<Rocket className="size-8" />
|
|
||||||
<p className="font-medium">No deployments found</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
Deployments from applications and compose will
|
|
||||||
appear here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<Button
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
variant="outline"
|
||||||
Rows per page
|
size="sm"
|
||||||
</span>
|
className="h-8"
|
||||||
<Select
|
onClick={() => table.previousPage()}
|
||||||
value={String(pagination.pageSize)}
|
disabled={!table.getCanPreviousPage()}
|
||||||
onValueChange={(value) => {
|
>
|
||||||
setPagination((p) => ({
|
<ChevronLeft className="size-4" />
|
||||||
...p,
|
Previous
|
||||||
pageSize: Number(value),
|
</Button>
|
||||||
pageIndex: 0,
|
<Button
|
||||||
}));
|
variant="outline"
|
||||||
}}
|
size="sm"
|
||||||
>
|
className="h-8"
|
||||||
<SelectTrigger className="h-8 w-[70px]">
|
onClick={() => table.nextPage()}
|
||||||
<SelectValue />
|
disabled={!table.getCanNextPage()}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent side="top">
|
Next
|
||||||
{[10, 25, 50, 100].map((size) => (
|
<ChevronRight className="size-4" />
|
||||||
<SelectItem key={size} value={String(size)}>
|
</Button>
|
||||||
{size}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
|
||||||
Showing{" "}
|
|
||||||
{filteredData.length === 0
|
|
||||||
? 0
|
|
||||||
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
|
||||||
to{" "}
|
|
||||||
{Math.min(
|
|
||||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
|
||||||
filteredData.length,
|
|
||||||
)}{" "}
|
|
||||||
of {filteredData.length} entries
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,11 +352,11 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-9"
|
className="h-9 w-full sm:w-auto"
|
||||||
onClick={handlePauseResume}
|
onClick={handlePauseResume}
|
||||||
title={isPaused ? "Resume logs" : "Pause logs"}
|
title={isPaused ? "Resume logs" : "Pause logs"}
|
||||||
>
|
>
|
||||||
@@ -372,7 +372,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-9"
|
className="h-9 w-full sm:w-auto"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
disabled={filteredLogs.length === 0}
|
disabled={filteredLogs.length === 0}
|
||||||
title="Copy logs to clipboard"
|
title="Copy logs to clipboard"
|
||||||
@@ -389,7 +389,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-9 sm:w-auto w-full"
|
className="h-9 w-full sm:w-auto"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={filteredLogs.length === 0 || !data?.Name}
|
disabled={filteredLogs.length === 0 || !data?.Name}
|
||||||
title="Download logs as text file"
|
title="Download logs as text file"
|
||||||
@@ -418,7 +418,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
className="h-[50vh] sm:h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||||
>
|
>
|
||||||
{filteredLogs.length > 0 ? (
|
{filteredLogs.length > 0 ? (
|
||||||
filteredLogs.map((filteredLog: LogLine, index: number) => (
|
filteredLogs.map((filteredLog: LogLine, index: number) => (
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
|||||||
|
|
||||||
const tooltip = (color: string, timestamp: string | null) => {
|
const tooltip = (color: string, timestamp: string | null) => {
|
||||||
const square = (
|
const square = (
|
||||||
<div className={cn("w-2 h-full shrink-0 rounded-[3px]", color)} />
|
<div
|
||||||
|
className={cn("w-2 min-h-4 h-full flex-shrink-0 rounded-[3px]", color)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
return timestamp ? (
|
return timestamp ? (
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||||
@@ -88,7 +90,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
|
"font-mono text-xs flex flex-col sm:flex-row gap-1 sm:gap-3 py-2 sm:py-0.5 group",
|
||||||
type === "error"
|
type === "error"
|
||||||
? "bg-red-500/10 hover:bg-red-500/15"
|
? "bg-red-500/10 hover:bg-red-500/15"
|
||||||
: type === "warning"
|
: type === "warning"
|
||||||
@@ -104,7 +106,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
|||||||
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
|
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
|
||||||
{tooltip(color, rawTimestamp)}
|
{tooltip(color, rawTimestamp)}
|
||||||
{!noTimestamp && (
|
{!noTimestamp && (
|
||||||
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 shrink-0">
|
<span className="select-none pl-2 text-muted-foreground w-auto sm:w-40 flex-shrink-0">
|
||||||
{formattedTime}
|
{formattedTime}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ export interface LogLine {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ContainerOption {
|
||||||
|
containerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveContainerSelection = (
|
||||||
|
currentContainerId: string | undefined,
|
||||||
|
containers: readonly ContainerOption[] | undefined,
|
||||||
|
) => {
|
||||||
|
if (!containers) {
|
||||||
|
return currentContainerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentContainerId &&
|
||||||
|
containers.some(({ containerId }) => containerId === currentContainerId)
|
||||||
|
) {
|
||||||
|
return currentContainerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return containers[0]?.containerId;
|
||||||
|
};
|
||||||
|
|
||||||
interface LogStyle {
|
interface LogStyle {
|
||||||
type: LogType;
|
type: LogType;
|
||||||
variant: LogVariant;
|
variant: LogVariant;
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { Copy, Loader2, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const CONFIRMATION_WORD = "DELETE";
|
||||||
|
|
||||||
|
export const DeleteAccountByEmail = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [record, setRecord] = useState<string | null>(null);
|
||||||
|
const { mutateAsync, isPending } =
|
||||||
|
api.user.deleteAccountByEmail.useMutation();
|
||||||
|
|
||||||
|
const canConfirm =
|
||||||
|
email.trim().length > 0 && confirmation.trim() === CONFIRMATION_WORD;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setEmail("");
|
||||||
|
setConfirmation("");
|
||||||
|
setRecord(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
const result = await mutateAsync({ email: email.trim() });
|
||||||
|
setRecord(JSON.stringify(result, null, 2));
|
||||||
|
toast.success(`Account ${result.email} deleted`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error deleting account",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => {
|
||||||
|
setOpen(value);
|
||||||
|
if (!value) reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive" className="gap-2">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete account
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete an account by email</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Cancels every Stripe subscription and deletes the user with all the
|
||||||
|
organizations they own. Invoices stay in Stripe for accounting. Use
|
||||||
|
it to fulfil data deletion requests.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{record ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<AlertBlock type="success">
|
||||||
|
Deletion completed. Keep this record as evidence of the request
|
||||||
|
being fulfilled.
|
||||||
|
</AlertBlock>
|
||||||
|
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-64">
|
||||||
|
{record}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 self-end"
|
||||||
|
onClick={() => {
|
||||||
|
copy(record);
|
||||||
|
toast.success("Deletion record copied to clipboard");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Copy record
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="delete-account-email">Account email</Label>
|
||||||
|
<Input
|
||||||
|
id="delete-account-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="delete-account-word">
|
||||||
|
Type <span className="font-mono">{CONFIRMATION_WORD}</span> to
|
||||||
|
confirm
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="delete-account-word"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={CONFIRMATION_WORD}
|
||||||
|
value={confirmation}
|
||||||
|
onChange={(e) => setConfirmation(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
{record ? (
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!canConfirm || isPending}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Delete account
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -46,6 +46,7 @@ import { authClient } from "@/lib/auth-client";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
import { DeleteAccountByEmail } from "./delete-account-by-email";
|
||||||
|
|
||||||
type User = typeof authClient.$Infer.Session.user;
|
type User = typeof authClient.$Infer.Session.user;
|
||||||
|
|
||||||
@@ -78,12 +79,12 @@ export const ImpersonationBar = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filteredUsers = response.data?.users.filter(
|
const filteredUsers = response.data?.users.filter(
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
(user) => user.allowImpersonation && data?.user?.email !== user.email,
|
(user) => user.allowImpersonation && data?.user?.email !== user.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.error) {
|
if (!response.error) {
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
setUsers(filteredUsers || []);
|
setUsers(filteredUsers || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -282,6 +283,7 @@ export const ImpersonationBar = () => {
|
|||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
Impersonate
|
Impersonate
|
||||||
</Button>
|
</Button>
|
||||||
|
<DeleteAccountByEmail />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-4 w-full flex-wrap">
|
<div className="flex items-center gap-4 w-full flex-wrap">
|
||||||
|
|||||||
+6
-1
@@ -1,5 +1,6 @@
|
|||||||
import { formatMb } from "@dokploy/server/monitoring/units";
|
import { formatMb } from "@dokploy/server/monitoring/units";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
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";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -167,6 +168,8 @@ export const ContainerFreeMonitoring = ({
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!appName) return;
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
|
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
@@ -196,7 +199,9 @@ export const ContainerFreeMonitoring = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (e) => {
|
ws.onclose = (e) => {
|
||||||
console.log(e.reason);
|
if (e.reason) {
|
||||||
|
toast.error(e.reason);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => ws.close();
|
return () => ws.close();
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ interface NetworkChartProps {
|
|||||||
data: any[];
|
data: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNetworkGB(valueInMB: number) {
|
||||||
|
if (Number.isNaN(valueInMB)) return "0";
|
||||||
|
return (valueInMB / 1024).toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
networkIn: {
|
networkIn: {
|
||||||
label: "Network In",
|
label: "Network In",
|
||||||
@@ -38,8 +43,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
|||||||
<CardHeader className="border-b py-5">
|
<CardHeader className="border-b py-5">
|
||||||
<CardTitle>Network</CardTitle>
|
<CardTitle>Network</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Network Traffic: ↑ {latestData.networkOut} KB/s ↓{" "}
|
Network Total: ↑ {formatNetworkGB(latestData.networkOut)} GB ↓{" "}
|
||||||
{latestData.networkIn} KB/s
|
{formatNetworkGB(latestData.networkIn)} GB (since Boot)
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||||
@@ -83,7 +88,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
|||||||
minTickGap={32}
|
minTickGap={32}
|
||||||
tickFormatter={(value) => formatTimestamp(value)}
|
tickFormatter={(value) => formatTimestamp(value)}
|
||||||
/>
|
/>
|
||||||
<YAxis tickFormatter={(value) => `${value} KB/s`} />
|
<YAxis tickFormatter={(value) => `${formatNetworkGB(value)} GB`} />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
cursor={false}
|
cursor={false}
|
||||||
content={({ active, payload, label }) => {
|
content={({ active, payload, label }) => {
|
||||||
@@ -105,8 +110,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
|||||||
Network
|
Network
|
||||||
</span>
|
</span>
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
↑ {data.networkOut} KB/s
|
↑ {formatNetworkGB(data.networkOut)} GB
|
||||||
<br />↓ {data.networkIn} KB/s
|
<br />↓ {formatNetworkGB(data.networkIn)} GB
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
|||||||
const importMutation = api.network.import.useMutation();
|
const importMutation = api.network.import.useMutation();
|
||||||
const removeMutation = api.network.remove.useMutation();
|
const removeMutation = api.network.remove.useMutation();
|
||||||
const recreateMutation = api.network.recreate.useMutation();
|
const recreateMutation = api.network.recreate.useMutation();
|
||||||
|
const resyncMutation = api.network.resync.useMutation();
|
||||||
|
|
||||||
const toggleSelected = (name: string) => {
|
const toggleSelected = (name: string) => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
@@ -102,6 +103,20 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onResync = async (networkId: string, name: string) => {
|
||||||
|
try {
|
||||||
|
await resyncMutation.mutateAsync({ networkId });
|
||||||
|
toast.success(`Network "${name}" updated from Docker`);
|
||||||
|
await utils.network.all.invalidate();
|
||||||
|
await utils.network.networksToSync.invalidate();
|
||||||
|
await refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error updating network", {
|
||||||
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
@@ -177,6 +192,45 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!!data?.changed.length && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Changed ({data.changed.length})
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
These networks were deleted and recreated in Docker under
|
||||||
|
the same name — attributes in Dokploy are outdated.
|
||||||
|
</span>
|
||||||
|
{data.changed.map((changed) => (
|
||||||
|
<div
|
||||||
|
key={changed.networkId}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm">{changed.name}</span>
|
||||||
|
{changed.driver && (
|
||||||
|
<Badge variant="outline">{changed.driver}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
isLoading={resyncMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
onResync(changed.networkId, changed.name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3.5" />
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{!!data?.missing.length && (
|
{!!data?.missing.length && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Fraunces } from "next/font/google";
|
||||||
|
|
||||||
|
export const displayFont = Fraunces({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["600"],
|
||||||
|
style: ["normal", "italic"],
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const ONBOARDING_ACTIVE_KEY = "dokploy_onboarding_active";
|
||||||
|
const ONBOARDING_STATE_KEY = "dokploy_onboarding_state";
|
||||||
|
|
||||||
|
interface OnboardingState {
|
||||||
|
stepId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
environmentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isOnboardingActive = () => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(ONBOARDING_ACTIVE_KEY) === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markOnboardingActive = () => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(ONBOARDING_ACTIVE_KEY, "true");
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearOnboardingActive = () => {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(ONBOARDING_ACTIVE_KEY);
|
||||||
|
window.localStorage.removeItem(ONBOARDING_STATE_KEY);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOnboardingState = (): OnboardingState => {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(ONBOARDING_STATE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setOnboardingState = (state: OnboardingState) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
ONBOARDING_STATE_KEY,
|
||||||
|
JSON.stringify({ ...getOnboardingState(), ...state }),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { defineStepper } from "@stepperize/react";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||||
|
import { Logo } from "@/components/shared/logo";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { getOnboardingState, setOnboardingState } from "./onboarding-lock";
|
||||||
|
import { CompleteStep } from "./steps/complete-step";
|
||||||
|
import { DeployStep } from "./steps/deploy-step";
|
||||||
|
import { PlanStep } from "./steps/plan-step";
|
||||||
|
import { ProjectStep } from "./steps/project-step";
|
||||||
|
import { ServerStep } from "./steps/server-step";
|
||||||
|
import { WelcomeStep } from "./steps/welcome-step";
|
||||||
|
|
||||||
|
const { useStepper, steps, Scoped } = defineStepper(
|
||||||
|
{ id: "welcome", title: "Welcome" },
|
||||||
|
{ id: "plan", title: "Pick a plan" },
|
||||||
|
{ id: "project", title: "New project" },
|
||||||
|
{ id: "server", title: "Connect server" },
|
||||||
|
{ id: "deploy", title: "Ship something" },
|
||||||
|
{ id: "complete", title: "You're live" },
|
||||||
|
);
|
||||||
|
|
||||||
|
type StepId = (typeof steps)[number]["id"];
|
||||||
|
const isStepId = (id: string | undefined): id is StepId =>
|
||||||
|
!!id && steps.some((step) => step.id === id);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OnboardingWizard = ({ onClose }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const persisted = getOnboardingState();
|
||||||
|
const stepper = useStepper(
|
||||||
|
isStepId(persisted.stepId) ? persisted.stepId : undefined,
|
||||||
|
);
|
||||||
|
const [projectId, setProjectId] = useState<string | undefined>(
|
||||||
|
persisted.projectId,
|
||||||
|
);
|
||||||
|
const [environmentId, setEnvironmentId] = useState<string | undefined>(
|
||||||
|
persisted.environmentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: isCloud = true } = api.settings.isCloud.useQuery();
|
||||||
|
const visibleStepIds = isCloud
|
||||||
|
? steps.map((step) => step.id)
|
||||||
|
: steps
|
||||||
|
.filter((step) => step.id !== "plan" && step.id !== "server")
|
||||||
|
.map((step) => step.id);
|
||||||
|
const visibleIndex = visibleStepIds.indexOf(stepper.current.id);
|
||||||
|
const isLastVisible = visibleIndex === visibleStepIds.length - 1;
|
||||||
|
const goToNextVisible = () => {
|
||||||
|
const nextId = visibleStepIds[visibleIndex + 1];
|
||||||
|
if (nextId) stepper.goTo(nextId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [skipAllOpen, setSkipAllOpen] = useState(false);
|
||||||
|
const handleSkipAll = async () => {
|
||||||
|
await onClose();
|
||||||
|
router.push(
|
||||||
|
projectId && environmentId
|
||||||
|
? `/dashboard/project/${projectId}/environment/${environmentId}`
|
||||||
|
: "/dashboard/projects",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOnboardingState({ stepId: stepper.current.id });
|
||||||
|
}, [stepper.current.id]);
|
||||||
|
|
||||||
|
const { error: projectCheckError } = api.project.one.useQuery(
|
||||||
|
{ projectId: projectId ?? "" },
|
||||||
|
{ enabled: !!projectId, retry: false },
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectCheckError || !projectId) return;
|
||||||
|
setProjectId(undefined);
|
||||||
|
setEnvironmentId(undefined);
|
||||||
|
setOnboardingState({ projectId: undefined, environmentId: undefined });
|
||||||
|
stepper.goTo("project");
|
||||||
|
}, [projectCheckError, projectId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col md:flex-row bg-background text-foreground">
|
||||||
|
<aside className="relative shrink-0 md:w-[300px] lg:w-[340px] overflow-hidden bg-zinc-950 text-zinc-400">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.07]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px)",
|
||||||
|
backgroundSize: "28px 28px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute -top-24 -left-24 size-72 rounded-full bg-white/10 blur-[100px]" />
|
||||||
|
|
||||||
|
<div className="relative flex md:h-full flex-col p-6 lg:p-8">
|
||||||
|
<div className="flex items-center justify-between md:block">
|
||||||
|
<div className="flex items-center gap-2.5 text-zinc-50 [&_path]:!fill-white [&_path]:!stroke-white">
|
||||||
|
<Logo className="size-7" />
|
||||||
|
<span className="text-lg font-semibold tracking-tight">
|
||||||
|
Dokploy
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isLastVisible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSkipAllOpen(true)}
|
||||||
|
className="font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors md:hidden"
|
||||||
|
>
|
||||||
|
Skip all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Scoped>
|
||||||
|
<ol className="hidden md:flex flex-col gap-0.5 mt-12">
|
||||||
|
{visibleStepIds.map((id, index) => {
|
||||||
|
const step = steps.find((s) => s.id === id)!;
|
||||||
|
const isDone = index < visibleIndex;
|
||||||
|
const isCurrent = stepper.current.id === step.id;
|
||||||
|
return (
|
||||||
|
<li key={step.id} className="flex gap-3.5">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span
|
||||||
|
className={`flex items-center justify-center font-mono text-[11px] tabular-nums size-5 rounded-full ${
|
||||||
|
isCurrent
|
||||||
|
? "bg-white text-zinc-950 font-semibold"
|
||||||
|
: isDone
|
||||||
|
? "text-zinc-100"
|
||||||
|
: "text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isDone ? (
|
||||||
|
<CheckIcon className="size-3" />
|
||||||
|
) : (
|
||||||
|
String(index + 1).padStart(2, "0")
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{index < visibleStepIds.length - 1 && (
|
||||||
|
<span
|
||||||
|
className={`w-px flex-1 min-h-7 my-1.5 ${
|
||||||
|
isDone ? "bg-zinc-400" : "bg-zinc-700"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isDone ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => stepper.goTo(step.id)}
|
||||||
|
className="text-sm font-medium transition-colors pb-6 last:pb-0 text-zinc-100 hover:text-white text-left"
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium transition-colors pb-6 last:pb-0 ${
|
||||||
|
isCurrent ? "text-white" : "text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</Scoped>
|
||||||
|
|
||||||
|
<div className="hidden md:flex flex-col gap-4 mt-auto pt-8 border-t border-zinc-800">
|
||||||
|
{!isLastVisible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSkipAllOpen(true)}
|
||||||
|
className="w-fit font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||||
|
>
|
||||||
|
Skip all →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-zinc-500">
|
||||||
|
<Link
|
||||||
|
href="https://github.com/dokploy/dokploy"
|
||||||
|
target="_blank"
|
||||||
|
className="hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
<GithubIcon />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="https://x.com/getdokploy"
|
||||||
|
target="_blank"
|
||||||
|
className="hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="currentColor"
|
||||||
|
strokeWidth="0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="size-4"
|
||||||
|
>
|
||||||
|
<path d="M10.4883 14.651L15.25 21H22.25L14.3917 10.5223L20.9308 3H18.2808L13.1643 8.88578L8.75 3H1.75L9.26086 13.0145L2.31915 21H4.96917L10.4883 14.651ZM16.25 19L5.75 5H7.75L18.25 19H16.25Z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="https://discord.com/invite/2tBnJ3jDJc"
|
||||||
|
target="_blank"
|
||||||
|
className="hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
className="size-4"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M39.248,10.177c-2.804-1.287-5.812-2.235-8.956-2.778c-0.057-0.01-0.114,0.016-0.144,0.068 c-0.387,0.688-0.815,1.585-1.115,2.291c-3.382-0.506-6.747-0.506-10.059,0c-0.3-0.721-0.744-1.603-1.133-2.291 c-0.03-0.051-0.087-0.077-0.144-0.068c-3.143,0.541-6.15,1.489-8.956,2.778c-0.024,0.01-0.045,0.028-0.059,0.051 c-5.704,8.522-7.267,16.835-6.5,25.044c0.003,0.04,0.026,0.079,0.057,0.103c3.763,2.764,7.409,4.442,10.987,5.554 c0.057,0.017,0.118-0.003,0.154-0.051c0.846-1.156,1.601-2.374,2.248-3.656c0.038-0.075,0.002-0.164-0.076-0.194 c-1.197-0.454-2.336-1.007-3.432-1.636c-0.087-0.051-0.094-0.175-0.014-0.234c0.231-0.173,0.461-0.353,0.682-0.534 c0.04-0.033,0.095-0.04,0.142-0.019c7.201,3.288,14.997,3.288,22.113,0c0.047-0.023,0.102-0.016,0.144,0.017 c0.22,0.182,0.451,0.363,0.683,0.536c0.08,0.059,0.075,0.183-0.012,0.234c-1.096,0.641-2.236,1.182-3.434,1.634 c-0.078,0.03-0.113,0.12-0.075,0.196c0.661,1.28,1.415,2.498,2.246,3.654c0.035,0.049,0.097,0.07,0.154,0.052 c3.595-1.112,7.241-2.79,11.004-5.554c0.033-0.024,0.054-0.061,0.057-0.101c0.917-9.491-1.537-17.735-6.505-25.044 C39.293,10.205,39.272,10.187,39.248,10.177z M16.703,30.273c-2.168,0-3.954-1.99-3.954-4.435s1.752-4.435,3.954-4.435 c2.22,0,3.989,2.008,3.954,4.435C20.658,28.282,18.906,30.273,16.703,30.273z M31.324,30.273c-2.168,0-3.954-1.99-3.954-4.435 s1.752-4.435,3.954-4.435c2.22,0,3.989,2.008,3.954,4.435C35.278,28.282,33.544,30.273,31.324,30.273z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="mx-auto w-full max-w-2xl px-6 py-14 lg:py-20">
|
||||||
|
{stepper.switch({
|
||||||
|
welcome: () => <WelcomeStep onNext={goToNextVisible} />,
|
||||||
|
plan: () => <PlanStep onNext={goToNextVisible} />,
|
||||||
|
project: () => (
|
||||||
|
<ProjectStep
|
||||||
|
onNext={(project) => {
|
||||||
|
setProjectId(project.projectId);
|
||||||
|
setEnvironmentId(project.environmentId);
|
||||||
|
setOnboardingState({
|
||||||
|
projectId: project.projectId,
|
||||||
|
environmentId: project.environmentId,
|
||||||
|
});
|
||||||
|
goToNextVisible();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
server: () => <ServerStep onNext={goToNextVisible} />,
|
||||||
|
deploy: () => (
|
||||||
|
<DeployStep
|
||||||
|
environmentId={environmentId}
|
||||||
|
onNext={goToNextVisible}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
complete: () => (
|
||||||
|
<CompleteStep
|
||||||
|
projectId={projectId}
|
||||||
|
environmentId={environmentId}
|
||||||
|
onFinish={onClose}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={skipAllOpen} onOpenChange={setSkipAllOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Skip onboarding?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
If this is your first time using Dokploy, we recommend going
|
||||||
|
through these steps – it only takes a couple of minutes and gives
|
||||||
|
you a feel for how projects, servers, and deployments fit
|
||||||
|
together.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setSkipAllOpen(false)}>
|
||||||
|
Continue setup
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSkipAll}>Skip anyway</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
BookIcon,
|
||||||
|
DatabaseIcon,
|
||||||
|
GitMergeIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import ConfettiExplosion from "react-confetti-explosion";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId?: string;
|
||||||
|
environmentId?: string;
|
||||||
|
onFinish: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: DatabaseIcon,
|
||||||
|
title: "Databases",
|
||||||
|
description: "Postgres, MySQL, MongoDB, Redis and more, one click away.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GlobeIcon,
|
||||||
|
title: "Custom domains",
|
||||||
|
description: "Attach your own domains and get automatic HTTPS.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GitMergeIcon,
|
||||||
|
title: "CI/CD",
|
||||||
|
description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: UsersIcon,
|
||||||
|
title: "Team collaboration",
|
||||||
|
description: "Invite teammates with fine-grained permissions.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CompleteStep = ({ projectId, environmentId, onFinish }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
const [isFinishing, setIsFinishing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowConfetti(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const projectHref =
|
||||||
|
projectId && environmentId
|
||||||
|
? `/dashboard/project/${projectId}/environment/${environmentId}`
|
||||||
|
: "/dashboard/projects";
|
||||||
|
|
||||||
|
const handleFinish = async () => {
|
||||||
|
setIsFinishing(true);
|
||||||
|
await onFinish();
|
||||||
|
router.push(projectHref);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<div className="fixed inset-x-0 top-0 flex justify-center pointer-events-none">
|
||||||
|
{showConfetti && (
|
||||||
|
<ConfettiExplosion
|
||||||
|
duration={3000}
|
||||||
|
force={0.35}
|
||||||
|
particleCount={200}
|
||||||
|
width={1200}
|
||||||
|
zIndex={60}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Done
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||||
|
>
|
||||||
|
You're all set.
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
This is just the beginning — here's some of what else you can do.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="flex flex-col divide-y">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<div key={feature.title} className="flex gap-6 py-5 first:pt-0">
|
||||||
|
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</dt>
|
||||||
|
<dd className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium flex items-center gap-2">
|
||||||
|
<feature.icon className="size-4 text-muted-foreground" />
|
||||||
|
{feature.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{feature.description}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-fit px-8"
|
||||||
|
isLoading={isFinishing}
|
||||||
|
onClick={handleFinish}
|
||||||
|
>
|
||||||
|
Go to my project
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link
|
||||||
|
href="https://docs.dokploy.com/docs/core"
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<BookIcon size={14} />
|
||||||
|
Read the docs
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import {
|
||||||
|
ArrowUpRightIcon,
|
||||||
|
CheckCircle2,
|
||||||
|
CopyIcon,
|
||||||
|
Loader2,
|
||||||
|
XCircleIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
environmentId?: string;
|
||||||
|
onNext: () => void;
|
||||||
|
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||||
|
* post-checkout welcome modal) that want the app's regular typography. */
|
||||||
|
plainTitle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURATED_TEMPLATES = [
|
||||||
|
{ id: "wordpress", name: "WordPress", logo: "wordpress.png" },
|
||||||
|
{ id: "ghost", name: "Ghost", logo: "ghost.jpeg" },
|
||||||
|
{ id: "n8n", name: "n8n", logo: "n8n.png" },
|
||||||
|
{ id: "uptime-kuma", name: "Uptime Kuma", logo: "uptime-kuma.png" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Deploying = { kind: "app" | "template"; id: string; url: string };
|
||||||
|
|
||||||
|
export const DeployStep = ({ environmentId, onNext, plainTitle }: Props) => {
|
||||||
|
const titleClassName = plainTitle
|
||||||
|
? "text-xl font-semibold tracking-tight"
|
||||||
|
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||||
|
const { data: isCloud = true } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
|
const serverId = servers?.[0]?.serverId;
|
||||||
|
|
||||||
|
const [deploying, setDeploying] = useState<Deploying | null>(null);
|
||||||
|
const [starting, setStarting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { mutateAsync: deployNginx } =
|
||||||
|
api.application.deployNginxQuickstart.useMutation();
|
||||||
|
const { mutateAsync: deployTemplate } =
|
||||||
|
api.compose.deployTemplate.useMutation();
|
||||||
|
const { mutateAsync: deployCompose } = api.compose.deploy.useMutation();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { data: application } = api.application.one.useQuery(
|
||||||
|
{ applicationId: deploying?.id ?? "" },
|
||||||
|
{
|
||||||
|
enabled: deploying?.kind === "app",
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const status = query.state.data?.applicationStatus;
|
||||||
|
return status === "done" || status === "error" ? false : 2500;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { data: compose } = api.compose.one.useQuery(
|
||||||
|
{ composeId: deploying?.id ?? "" },
|
||||||
|
{
|
||||||
|
enabled: deploying?.kind === "template",
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const status = query.state.data?.composeStatus;
|
||||||
|
return status === "done" || status === "error" ? false : 2500;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const status =
|
||||||
|
deploying?.kind === "app"
|
||||||
|
? application?.applicationStatus
|
||||||
|
: compose?.composeStatus;
|
||||||
|
|
||||||
|
if (!environmentId || (isCloud && !serverId)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
First deploy
|
||||||
|
</span>
|
||||||
|
<h1 className={titleClassName}>Deploy something.</h1>
|
||||||
|
</div>
|
||||||
|
<AlertBlock type="info" className="max-w-md">
|
||||||
|
{isCloud
|
||||||
|
? "You'll need a project and a connected server before deploying — you can do that anytime from the dashboard."
|
||||||
|
: "You'll need a project before deploying — you can do that anytime from the dashboard."}
|
||||||
|
</AlertBlock>
|
||||||
|
<Button onClick={onNext} className="w-fit px-8">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNginx = async () => {
|
||||||
|
setStarting("nginx");
|
||||||
|
try {
|
||||||
|
const res = await deployNginx({ environmentId, serverId });
|
||||||
|
setDeploying({ kind: "app", id: res.applicationId, url: res.domainUrl });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error deploying the app",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setStarting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplate = async (id: string) => {
|
||||||
|
setStarting(id);
|
||||||
|
try {
|
||||||
|
const composeResult = await deployTemplate({
|
||||||
|
environmentId,
|
||||||
|
serverId,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
await deployCompose({ composeId: composeResult.composeId });
|
||||||
|
const domains = await utils.domain.byComposeId.fetch({
|
||||||
|
composeId: composeResult.composeId,
|
||||||
|
});
|
||||||
|
const host = domains?.[0]?.host;
|
||||||
|
setDeploying({
|
||||||
|
kind: "template",
|
||||||
|
id: composeResult.composeId,
|
||||||
|
url: host ? `http://${host}` : "",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error deploying the template",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setStarting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deploying) {
|
||||||
|
const isDone = status === "done";
|
||||||
|
const isError = status === "error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span
|
||||||
|
className={`font-mono text-xs uppercase tracking-[0.2em] flex items-center gap-2 ${
|
||||||
|
isError ? "text-destructive" : "text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isDone ? (
|
||||||
|
<CheckCircle2 className="size-3.5" />
|
||||||
|
) : isError ? (
|
||||||
|
<XCircleIcon className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
{isDone ? "Live" : isError ? "Deploy failed" : "Deploying"}
|
||||||
|
</span>
|
||||||
|
<h1 className={titleClassName}>
|
||||||
|
{isDone
|
||||||
|
? "It's live."
|
||||||
|
: isError
|
||||||
|
? "Something went wrong."
|
||||||
|
: "Building your app..."}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
{isDone
|
||||||
|
? "Your app is up and reachable at:"
|
||||||
|
: isError
|
||||||
|
? "The deployment failed — you can check the logs from the dashboard later."
|
||||||
|
: "This usually takes a minute or two the first time."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDone && deploying.url ? (
|
||||||
|
<div className="flex flex-col rounded-2xl border overflow-hidden max-w-md">
|
||||||
|
<div className="flex items-center gap-2 px-5 py-3 border-b bg-muted/30">
|
||||||
|
<span className="size-2 rounded-full bg-green-500 shrink-0" />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
Reachable now
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 p-5">
|
||||||
|
<span className="font-mono text-sm truncate">
|
||||||
|
{deploying.url}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
copy(deploying.url);
|
||||||
|
toast.success("Copied to clipboard");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<a href={deploying.url} target="_blank" rel="noreferrer">
|
||||||
|
<ArrowUpRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !isDone && !isError ? (
|
||||||
|
<div className="rounded-2xl border p-5 max-w-md">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||||
|
Waiting for the build to finish...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
className="w-fit px-8"
|
||||||
|
disabled={!isDone && !isError}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
First deploy
|
||||||
|
</span>
|
||||||
|
<h1 className={titleClassName}>Ship something.</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
Pick a quickstart — we'll generate a domain and deploy it for you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNginx}
|
||||||
|
disabled={!!starting}
|
||||||
|
className="flex flex-col items-start justify-between gap-8 bg-background p-7 text-left hover:bg-muted/40 transition-colors disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Simple app</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
A "Hello World" demo app, live in seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||||
|
{starting === "nginx" ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
Starting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Deploy now →"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-between gap-8 bg-background p-7">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Docker Compose template</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Popular open source stacks, one click away.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{CURATED_TEMPLATES.map((template) => (
|
||||||
|
<Button
|
||||||
|
key={template.id}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!!starting}
|
||||||
|
isLoading={starting === template.id}
|
||||||
|
onClick={() => handleTemplate(template.id)}
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`https://templates.dokploy.com/blueprints/${template.id}/${template.logo}`}
|
||||||
|
alt={template.name}
|
||||||
|
className="size-4 object-contain shrink-0"
|
||||||
|
/>
|
||||||
|
{template.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
|
import { ArrowRightIcon, CheckIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
calculatePriceHobby,
|
||||||
|
calculatePriceStartup,
|
||||||
|
STARTUP_SERVERS_INCLUDED,
|
||||||
|
} from "@/components/dashboard/settings/billing/show-billing";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
const stripePromise = loadStripe(
|
||||||
|
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||||
|
);
|
||||||
|
|
||||||
|
type Tier = "hobby" | "startup";
|
||||||
|
|
||||||
|
const PLANS: {
|
||||||
|
tier: Tier;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
features: string[];
|
||||||
|
recommended?: boolean;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
tier: "hobby",
|
||||||
|
name: "Hobby",
|
||||||
|
description: "For individual developers",
|
||||||
|
price: calculatePriceHobby(1, false),
|
||||||
|
features: [
|
||||||
|
"Setup 1 server",
|
||||||
|
"Unlimited apps & databases",
|
||||||
|
"2 environments",
|
||||||
|
"Community support",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: "startup",
|
||||||
|
name: "Startup",
|
||||||
|
description: "For small to mid-size teams",
|
||||||
|
price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false),
|
||||||
|
recommended: true,
|
||||||
|
features: [
|
||||||
|
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
|
||||||
|
"Unlimited users & environments",
|
||||||
|
"Basic RBAC + 2FA",
|
||||||
|
"Email & chat support",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlanStep = ({ onNext }: Props) => {
|
||||||
|
const [loading, setLoading] = useState<
|
||||||
|
`${"trial" | "checkout"}:${Tier}` | null
|
||||||
|
>(null);
|
||||||
|
const { data } = api.stripe.getProducts.useQuery();
|
||||||
|
const { mutateAsync: createCheckoutSession } =
|
||||||
|
api.stripe.createCheckoutSession.useMutation();
|
||||||
|
const { mutateAsync: startFreeTrial } =
|
||||||
|
api.stripe.startFreeTrial.useMutation();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const handleCheckout = async (tier: Tier) => {
|
||||||
|
if (!data) return;
|
||||||
|
const productId =
|
||||||
|
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
|
||||||
|
if (!productId) return;
|
||||||
|
setLoading(`checkout:${tier}`);
|
||||||
|
try {
|
||||||
|
const stripe = await stripePromise;
|
||||||
|
const session = await createCheckoutSession({
|
||||||
|
tier,
|
||||||
|
productId,
|
||||||
|
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
||||||
|
isAnnual: false,
|
||||||
|
});
|
||||||
|
await stripe?.redirectToCheckout({ sessionId: session.sessionId });
|
||||||
|
} catch {
|
||||||
|
toast.error("Error starting checkout");
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrial = async (tier: Tier) => {
|
||||||
|
setLoading(`trial:${tier}`);
|
||||||
|
try {
|
||||||
|
await startFreeTrial({ tier });
|
||||||
|
await utils.project.onboardingStatus.invalidate();
|
||||||
|
toast.success("Your 7-day trial has started");
|
||||||
|
onNext();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error starting trial",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Billing
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||||
|
>
|
||||||
|
Start free, upgrade when ready.
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
Try any plan free for 7 days. No credit card required — add one only
|
||||||
|
if you decide to stay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||||
|
{PLANS.map((plan) => {
|
||||||
|
const productId =
|
||||||
|
plan.tier === "hobby"
|
||||||
|
? data?.hobbyProductId
|
||||||
|
: data?.startupProductId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={plan.tier}
|
||||||
|
className="flex flex-col justify-between gap-6 bg-background p-7"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{plan.recommended && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-primary text-primary-foreground font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1 mb-3">
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="font-medium">{plan.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{plan.description}
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||||
|
${plan.price.toFixed(2)}
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
/mo
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-1.5 mt-4">
|
||||||
|
{plan.features.map((f) => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5 shrink-0" />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
isLoading={loading === `trial:${plan.tier}`}
|
||||||
|
disabled={loading !== null}
|
||||||
|
onClick={() => handleTrial(plan.tier)}
|
||||||
|
>
|
||||||
|
Start 7-day free trial
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
isLoading={loading === `checkout:${plan.tier}`}
|
||||||
|
disabled={loading !== null || !productId}
|
||||||
|
onClick={() => handleCheckout(plan.tier)}
|
||||||
|
>
|
||||||
|
Subscribe now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
type Schema = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onNext: (project: { projectId: string; environmentId: string }) => void;
|
||||||
|
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||||
|
* post-checkout welcome modal) that want the app's regular typography. */
|
||||||
|
plainTitle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectStep = ({ onNext, plainTitle }: Props) => {
|
||||||
|
const titleClassName = plainTitle
|
||||||
|
? "text-xl font-semibold tracking-tight"
|
||||||
|
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||||
|
const { mutateAsync, isPending } = api.project.create.useMutation();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const form = useForm<Schema>({
|
||||||
|
defaultValues: { name: "My First Project", description: "" },
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: Schema) => {
|
||||||
|
try {
|
||||||
|
const result = await mutateAsync(data);
|
||||||
|
await utils.project.all.invalidate();
|
||||||
|
toast.success("Project created");
|
||||||
|
onNext({
|
||||||
|
projectId: result.project.projectId,
|
||||||
|
environmentId: result.environment?.environmentId ?? "",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error creating the project",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Workspace
|
||||||
|
</span>
|
||||||
|
<h1 className={titleClassName}>Create your first project.</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
Projects group your apps, databases, and environments together.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-5 max-w-sm"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My First Project" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="What is this project for?"
|
||||||
|
className="resize-none"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isPending}
|
||||||
|
className="w-fit px-8 mt-2"
|
||||||
|
>
|
||||||
|
Create project
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
CopyIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useRef, 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 {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
ipAddress: z
|
||||||
|
.string()
|
||||||
|
.min(1, "IP address is required")
|
||||||
|
.refine((value) => !/\s/.test(value), "IP address cannot contain spaces"),
|
||||||
|
});
|
||||||
|
type Schema = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onNext: () => void;
|
||||||
|
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||||
|
* post-checkout welcome modal) that want the app's regular typography. */
|
||||||
|
plainTitle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusRow = ({
|
||||||
|
label,
|
||||||
|
ok,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
ok?: boolean;
|
||||||
|
pending?: boolean;
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{ok ? (
|
||||||
|
<CheckCircle2 className="size-4 text-green-600 dark:text-green-500 shrink-0" />
|
||||||
|
) : pending ? (
|
||||||
|
<Loader2 className="size-4 text-muted-foreground shrink-0 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className={ok ? "" : "text-muted-foreground"}>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ServerStep = ({ onNext, plainTitle }: Props) => {
|
||||||
|
const titleClassName = plainTitle
|
||||||
|
? "text-xl font-semibold tracking-tight"
|
||||||
|
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||||
|
|
||||||
|
const { data: sshKeys, refetch: refetchSSHKeys } = api.sshKey.all.useQuery();
|
||||||
|
const generateSSHKey = api.sshKey.generate.useMutation();
|
||||||
|
const createSSHKey = api.sshKey.create.useMutation();
|
||||||
|
const hasCreatedKey = useRef(false);
|
||||||
|
|
||||||
|
const cloudSSHKey = sshKeys?.find(
|
||||||
|
(sshKey) => sshKey.name === "dokploy-onboarding-ssh-key",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ensureKey = async () => {
|
||||||
|
if (!sshKeys || cloudSSHKey || hasCreatedKey.current) return;
|
||||||
|
hasCreatedKey.current = true;
|
||||||
|
try {
|
||||||
|
const keys = await generateSSHKey.mutateAsync({ type: "rsa" });
|
||||||
|
await createSSHKey.mutateAsync({
|
||||||
|
name: "dokploy-onboarding-ssh-key",
|
||||||
|
description: "Used during onboarding",
|
||||||
|
privateKey: keys.privateKey,
|
||||||
|
publicKey: keys.publicKey,
|
||||||
|
organizationId: "",
|
||||||
|
});
|
||||||
|
await refetchSSHKeys();
|
||||||
|
} catch {
|
||||||
|
hasCreatedKey.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ensureKey();
|
||||||
|
}, [sshKeys]);
|
||||||
|
|
||||||
|
const { data: existingServers } = api.server.all.useQuery();
|
||||||
|
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!createdServerId && existingServers && existingServers.length > 0) {
|
||||||
|
setCreatedServerId(existingServers[0]!.serverId);
|
||||||
|
}
|
||||||
|
}, [existingServers, createdServerId]);
|
||||||
|
|
||||||
|
const { data: canCreateMoreServers } =
|
||||||
|
api.stripe.canCreateMoreServers.useQuery();
|
||||||
|
const { mutateAsync: createServer, isPending: isCreating } =
|
||||||
|
api.server.create.useMutation();
|
||||||
|
const checkIsReady = (validation?: {
|
||||||
|
docker?: { enabled?: boolean };
|
||||||
|
isDokployNetworkInstalled?: boolean;
|
||||||
|
}) =>
|
||||||
|
!!validation?.docker?.enabled && !!validation?.isDokployNetworkInstalled;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: validation,
|
||||||
|
refetch: refetchValidation,
|
||||||
|
isFetching: isValidating,
|
||||||
|
} = api.server.validate.useQuery(
|
||||||
|
{ serverId: createdServerId ?? "" },
|
||||||
|
{
|
||||||
|
enabled: !!createdServerId,
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
checkIsReady(query.state.data) ? false : 5000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const isReady = checkIsReady(validation);
|
||||||
|
|
||||||
|
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||||
|
const hasStartedSetup = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (createdServerId && !hasStartedSetup.current && !isReady) {
|
||||||
|
hasStartedSetup.current = true;
|
||||||
|
setIsSettingUp(true);
|
||||||
|
}
|
||||||
|
}, [createdServerId, isReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSettingUp) return;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setIsSettingUp(false);
|
||||||
|
toast.error(
|
||||||
|
"Setup is taking too long — check the server is reachable, then try again.",
|
||||||
|
);
|
||||||
|
}, 120_000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [isSettingUp]);
|
||||||
|
|
||||||
|
api.server.setupWithLogs.useSubscription(
|
||||||
|
{ serverId: createdServerId ?? "" },
|
||||||
|
{
|
||||||
|
enabled: !!createdServerId && isSettingUp,
|
||||||
|
onData(log) {
|
||||||
|
if (typeof log === "string" && log.includes("Setup Server:")) {
|
||||||
|
setIsSettingUp(false);
|
||||||
|
refetchValidation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setIsSettingUp(false);
|
||||||
|
toast.error("Error setting up the server");
|
||||||
|
console.error(error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<Schema>({
|
||||||
|
defaultValues: { name: "My First Server", ipAddress: "" },
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: Schema) => {
|
||||||
|
if (!cloudSSHKey) {
|
||||||
|
toast.error("Still generating your SSH key, try again in a moment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const server = await createServer({
|
||||||
|
name: data.name,
|
||||||
|
description: "",
|
||||||
|
ipAddress: data.ipAddress.trim(),
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
sshKeyId: cloudSSHKey.sshKeyId,
|
||||||
|
serverType: "deploy",
|
||||||
|
enableDockerCleanup: false,
|
||||||
|
});
|
||||||
|
setCreatedServerId(server.serverId);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error creating the server",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Infrastructure
|
||||||
|
</span>
|
||||||
|
<h1 className={titleClassName}>Connect a server.</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
Dokploy deploys to servers you own. Buy one from any VPS provider
|
||||||
|
(Hetzner, DigitalOcean, Hostinger...) and paste its IP below.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-start gap-2.5 rounded-lg border p-3 max-w-md">
|
||||||
|
<InfoIcon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium">Requirements</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
To ensure a smooth experience with Dokploy, your server should
|
||||||
|
have at least 1GB of RAM and 30GB of disk space.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!createdServerId ? (
|
||||||
|
<div className="flex flex-col gap-4 max-w-sm">
|
||||||
|
{canCreateMoreServers === false && (
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
You'll need a plan or trial before connecting a server — go back
|
||||||
|
to the "Pick a plan" step.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
1. Run this on your server to authorize Dokploy
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1.5 text-xs">
|
||||||
|
{cloudSSHKey
|
||||||
|
? `echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`
|
||||||
|
: "Generating..."}
|
||||||
|
</code>
|
||||||
|
{cloudSSHKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copy(
|
||||||
|
`echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`,
|
||||||
|
);
|
||||||
|
toast.success("Copied to clipboard");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Server name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My First Server" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="ipAddress"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>2. IP address</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="192.168.1.100" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isCreating}
|
||||||
|
disabled={!cloudSSHKey || canCreateMoreServers === false}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Connect server
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 w-full max-w-sm">
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border p-4">
|
||||||
|
{isSettingUp ? (
|
||||||
|
<div className="flex items-center justify-center gap-3 py-4 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||||
|
Setting up your server – installing Docker and dependencies…
|
||||||
|
</div>
|
||||||
|
) : isValidating && !validation ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Checking your server...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<StatusRow
|
||||||
|
label="Docker installed"
|
||||||
|
ok={validation?.docker?.enabled}
|
||||||
|
pending={!validation?.docker?.enabled && !isReady}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Dokploy network created"
|
||||||
|
ok={validation?.isDokployNetworkInstalled}
|
||||||
|
pending={!validation?.isDokployNetworkInstalled && !isReady}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isReady && !isSettingUp && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
Setup runs after the server boots and this page checks for it
|
||||||
|
automatically. You can retry setup, or use "Skip for now" above to
|
||||||
|
continue and come back to this later.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
isLoading={isValidating || isSettingUp}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isReady && !isSettingUp) {
|
||||||
|
setIsSettingUp(true);
|
||||||
|
} else {
|
||||||
|
refetchValidation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isReady && !isSettingUp ? "Retry setup" : "Check now"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!isReady || isSettingUp}
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { GitBranchIcon, PuzzleIcon, ServerIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = [
|
||||||
|
{
|
||||||
|
icon: GitBranchIcon,
|
||||||
|
title: "Deploy from Git or Docker",
|
||||||
|
description:
|
||||||
|
"Push a repo, a compose file, or a container image and go live.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: PuzzleIcon,
|
||||||
|
title: "One-click templates",
|
||||||
|
description:
|
||||||
|
"WordPress, databases, and dozens of open source apps, pre-wired.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ServerIcon,
|
||||||
|
title: "Your own servers",
|
||||||
|
description: "Runs on infrastructure you control — no vendor lock-in.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WelcomeStep = ({ onNext }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-12">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||||
|
Welcome
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||||
|
>
|
||||||
|
Let's get your first app <em className="not-italic">live</em>.
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||||
|
It just takes a few steps: pick a plan, connect a server, ship
|
||||||
|
something. You'll have a working URL by the end.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="flex flex-col divide-y">
|
||||||
|
{points.map((point, index) => (
|
||||||
|
<div key={point.title} className="flex gap-6 py-5 first:pt-0">
|
||||||
|
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</dt>
|
||||||
|
<dd className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium flex items-center gap-2">
|
||||||
|
<point.icon className="size-4 text-muted-foreground" />
|
||||||
|
{point.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{point.description}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<Button size="lg" onClick={onNext} className="w-fit px-8">
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PenBoxIcon, Plus } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { PenBoxIcon, Plus, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Logo } from "@/components/shared/logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Dropzone } from "@/components/ui/dropzone";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -24,6 +27,8 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { resizeImage } from "@/utils/image-processing";
|
||||||
|
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||||
|
|
||||||
const organizationSchema = z.object({
|
const organizationSchema = z.object({
|
||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
@@ -37,10 +42,24 @@ type OrganizationFormValues = z.infer<typeof organizationSchema>;
|
|||||||
interface Props {
|
interface Props {
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddOrganization({ organizationId }: Props) {
|
export function AddOrganization({
|
||||||
const [open, setOpen] = useState(false);
|
organizationId,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange: controlledOnOpenChange,
|
||||||
|
}: Props) {
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const uploadCounter = useRef(0);
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled
|
||||||
|
? controlledOnOpenChange || (() => {})
|
||||||
|
: setInternalOpen;
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: organization } = api.organization.one.useQuery(
|
const { data: organization } = api.organization.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -48,6 +67,7 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!organizationId,
|
enabled: !!organizationId,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { mutateAsync, isPending } = organizationId
|
const { mutateAsync, isPending } = organizationId
|
||||||
@@ -64,14 +84,18 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (organization) {
|
if (organization) {
|
||||||
|
uploadCounter.current++;
|
||||||
|
setIsUploading(false);
|
||||||
form.reset({
|
form.reset({
|
||||||
name: organization.name,
|
name: organization.name,
|
||||||
logo: organization.logo || "",
|
logo: organization.logo || "",
|
||||||
});
|
});
|
||||||
|
setUploadedFileName(null);
|
||||||
}
|
}
|
||||||
}, [organization, form]);
|
}, [organization, form]);
|
||||||
|
|
||||||
const onSubmit = async (values: OrganizationFormValues) => {
|
const onSubmit = async (values: OrganizationFormValues) => {
|
||||||
|
if (isUploading) return;
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
logo: values.logo,
|
logo: values.logo,
|
||||||
@@ -79,6 +103,7 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setUploadedFileName(null);
|
||||||
toast.success(
|
toast.success(
|
||||||
`Organization ${organizationId ? "updated" : "created"} successfully`,
|
`Organization ${organizationId ? "updated" : "created"} successfully`,
|
||||||
);
|
);
|
||||||
@@ -98,8 +123,94 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const currentUploadId = ++uploadCounter.current;
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
const allowedTypes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!allowedTypes.includes(file.type) &&
|
||||||
|
!allowedExtensions.includes(fileExtension || "")
|
||||||
|
) {
|
||||||
|
toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed");
|
||||||
|
setIsUploading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
toast.error("Image size must be less than 2MB");
|
||||||
|
setIsUploading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const sanitizedDataUrl = sanitizeSvg(text);
|
||||||
|
if (currentUploadId !== uploadCounter.current) return;
|
||||||
|
if (!sanitizedDataUrl) {
|
||||||
|
toast.error("Invalid SVG file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.setValue("logo", sanitizedDataUrl);
|
||||||
|
form.trigger("logo");
|
||||||
|
setUploadedFileName(file.name);
|
||||||
|
} catch (error) {
|
||||||
|
if (currentUploadId === uploadCounter.current) {
|
||||||
|
toast.error("Error processing SVG");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (currentUploadId === uploadCounter.current) {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize raster images to max 256x256 and convert to WebP to save space
|
||||||
|
try {
|
||||||
|
const resizedDataUrl = await resizeImage(file, 256);
|
||||||
|
if (currentUploadId !== uploadCounter.current) return;
|
||||||
|
form.setValue("logo", resizedDataUrl);
|
||||||
|
form.trigger("logo");
|
||||||
|
setUploadedFileName(file.name);
|
||||||
|
} catch (error) {
|
||||||
|
if (currentUploadId === uploadCounter.current) {
|
||||||
|
toast.error("Error processing image");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (currentUploadId === uploadCounter.current) {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
if (!val) {
|
||||||
|
uploadCounter.current++;
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
setOpen(val);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{organizationId ? (
|
{organizationId ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -145,8 +256,10 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="tems-center gap-4">
|
<FormItem className="items-center gap-4">
|
||||||
<FormLabel className="text-right">Name</FormLabel>
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel className="text-right">Name</FormLabel>
|
||||||
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Organization name"
|
placeholder="Organization name"
|
||||||
@@ -161,23 +274,78 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="logo"
|
name="logo"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem className="gap-4">
|
const isDataUrl = field.value?.startsWith("data:");
|
||||||
<FormLabel className="text-right">Logo URL</FormLabel>
|
const displayValue = isDataUrl
|
||||||
<FormControl>
|
? uploadedFileName || "Uploaded image"
|
||||||
<Input
|
: field.value || "";
|
||||||
placeholder="https://example.com/logo.png"
|
|
||||||
{...field}
|
return (
|
||||||
value={field.value || ""}
|
<FormItem className="gap-4">
|
||||||
className="col-span-3"
|
<FormLabel className="text-right">
|
||||||
/>
|
Logo URL or Upload
|
||||||
</FormControl>
|
</FormLabel>
|
||||||
<FormMessage className="col-span-3 col-start-2" />
|
<FormControl>
|
||||||
</FormItem>
|
<div className="col-span-3 flex flex-col gap-3">
|
||||||
)}
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 overflow-hidden">
|
||||||
|
{field.value ? (
|
||||||
|
// biome-ignore lint/performance/noImgElement: user uploaded logo preview
|
||||||
|
<img
|
||||||
|
src={field.value}
|
||||||
|
alt="Logo preview"
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Logo className="size-7" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
|
{...field}
|
||||||
|
value={displayValue}
|
||||||
|
readOnly={isDataUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
uploadCounter.current++;
|
||||||
|
setIsUploading(false);
|
||||||
|
field.onChange(e);
|
||||||
|
if (isDataUrl) setUploadedFileName(null);
|
||||||
|
}}
|
||||||
|
className="w-full pr-8"
|
||||||
|
/>
|
||||||
|
{field.value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
uploadCounter.current++;
|
||||||
|
setIsUploading(false);
|
||||||
|
form.setValue("logo", "");
|
||||||
|
setUploadedFileName(null);
|
||||||
|
}}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dropzone
|
||||||
|
dropMessage="Drag & drop a logo or click to upload"
|
||||||
|
accept=".jpg,.jpeg,.png,.svg,.webp,image/jpeg,image/png,image/svg+xml,image/webp"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
|
||||||
|
classNameContent="h-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="col-span-3 col-start-2" />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" isLoading={isPending}>
|
<Button type="submit" isLoading={isPending || isUploading}>
|
||||||
{organizationId ? "Update organization" : "Create organization"}
|
{organizationId ? "Update organization" : "Create organization"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Rocket } from "lucide-react";
|
import { Rocket } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
||||||
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +9,14 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
const SUBTAB_VALUES = ["deployments", "queue"] as const;
|
const SUBTAB_VALUES = ["deployments", "queue"] as const;
|
||||||
@@ -26,6 +35,10 @@ export const ShowOverviewDeployments = () => {
|
|||||||
? router.query.subtab
|
? router.query.subtab
|
||||||
: DEFAULT_SUBTAB;
|
: DEFAULT_SUBTAB;
|
||||||
|
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
|
|
||||||
const setSubtab = (value: string) => {
|
const setSubtab = (value: string) => {
|
||||||
if (!isValidSubtab(value)) return;
|
if (!isValidSubtab(value)) return;
|
||||||
const { subtab: _current, ...query } = router.query;
|
const { subtab: _current, ...query } = router.query;
|
||||||
@@ -59,12 +72,50 @@ export const ShowOverviewDeployments = () => {
|
|||||||
onValueChange={setSubtab}
|
onValueChange={setSubtab}
|
||||||
className="w-full min-w-0"
|
className="w-full min-w-0"
|
||||||
>
|
>
|
||||||
<TabsList className="mt-2">
|
<div className="flex flex-col lg:flex-row lg:items-center gap-y-4 gap-x-3 mt-2">
|
||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsList className="self-start shrink-0">
|
||||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
{subtab === "deployments" && (
|
||||||
|
<div className="flex flex-wrap lg:flex-nowrap items-center gap-2 lg:ml-auto">
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name, project, environment, server..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectItem value="running">Running</SelectItem>
|
||||||
|
<SelectItem value="done">Done</SelectItem>
|
||||||
|
<SelectItem value="error">Error</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All types</SelectItem>
|
||||||
|
<SelectItem value="application">Application</SelectItem>
|
||||||
|
<SelectItem value="compose">Compose</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
|
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
|
||||||
<ShowDeploymentsTable />
|
<ShowDeploymentsTable
|
||||||
|
globalFilter={globalFilter}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
typeFilter={typeFilter}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="queue" className="mt-0 pt-4">
|
<TabsContent value="queue" className="mt-0 pt-4">
|
||||||
<ShowQueueTable />
|
<ShowQueueTable />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||||
|
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -156,7 +157,17 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
|||||||
name="env"
|
name="env"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Environment variables</FormLabel>
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Environment variables</FormLabel>
|
||||||
|
<VaultImportDialog
|
||||||
|
projectId={data?.projectId}
|
||||||
|
environmentId={environmentId}
|
||||||
|
currentEnv={field.value ?? ""}
|
||||||
|
onImport={(next) =>
|
||||||
|
form.setValue("env", next, { shouldDirty: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
completionSource={completionSource}
|
completionSource={completionSource}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||||
|
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -152,7 +153,16 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
|||||||
name="env"
|
name="env"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Environment variables</FormLabel>
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Environment variables</FormLabel>
|
||||||
|
<VaultImportDialog
|
||||||
|
projectId={projectId}
|
||||||
|
currentEnv={field.value ?? ""}
|
||||||
|
onImport={(next) =>
|
||||||
|
form.setValue("env", next, { shouldDirty: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
completionSource={completionSource}
|
completionSource={completionSource}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Globe,
|
Globe,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
|
Loader2,
|
||||||
Server,
|
Server,
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
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 {
|
import {
|
||||||
@@ -100,7 +102,12 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
|||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: statsLogs } = api.settings.readStatsLogs.useQuery(
|
const {
|
||||||
|
data: statsLogs,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = api.settings.readStatsLogs.useQuery(
|
||||||
{
|
{
|
||||||
sort: sorting[0],
|
sort: sorting[0],
|
||||||
page: pagination,
|
page: pagination,
|
||||||
@@ -273,7 +280,18 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
|||||||
colSpan={columns.length}
|
colSpan={columns.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
{statsLogs?.data.length === 0 && (
|
{isLoading ? (
|
||||||
|
<div className="w-full flex gap-4 items-center justify-center h-[55vh] text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span>Loading requests...</span>
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="w-full flex items-center justify-center h-[55vh]">
|
||||||
|
<AlertBlock type="error" className="w-full">
|
||||||
|
{error?.message}
|
||||||
|
</AlertBlock>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
|
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
|
||||||
<span className="text-muted-foreground text-lg font-medium">
|
<span className="text-muted-foreground text-lg font-medium">
|
||||||
No results.
|
No results.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bell,
|
Bell,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
Clock,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -93,6 +94,7 @@ export const ShowBilling = () => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: servers } = api.server.count.useQuery();
|
const { data: servers } = api.server.count.useQuery();
|
||||||
const { data: admin } = api.user.get.useQuery();
|
const { data: admin } = api.user.get.useQuery();
|
||||||
|
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||||
const { data, isPending } = api.stripe.getProducts.useQuery();
|
const { data, isPending } = api.stripe.getProducts.useQuery();
|
||||||
const { mutateAsync: createCheckoutSession } =
|
const { mutateAsync: createCheckoutSession } =
|
||||||
api.stripe.createCheckoutSession.useMutation();
|
api.stripe.createCheckoutSession.useMutation();
|
||||||
@@ -103,7 +105,67 @@ export const ShowBilling = () => {
|
|||||||
api.stripe.upgradeSubscription.useMutation();
|
api.stripe.upgradeSubscription.useMutation();
|
||||||
const { mutateAsync: updateInvoiceNotifications } =
|
const { mutateAsync: updateInvoiceNotifications } =
|
||||||
api.stripe.updateInvoiceNotifications.useMutation();
|
api.stripe.updateInvoiceNotifications.useMutation();
|
||||||
|
const { mutateAsync: startFreeTrial } =
|
||||||
|
api.stripe.startFreeTrial.useMutation();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
|
||||||
|
const [switchingTrial, setSwitchingTrial] = useState(false);
|
||||||
|
|
||||||
|
const handleSwitchTrialPlan = async (
|
||||||
|
tier: "hobby" | "startup",
|
||||||
|
isAnnualPlan: boolean,
|
||||||
|
label?: string,
|
||||||
|
) => {
|
||||||
|
const planName =
|
||||||
|
label ??
|
||||||
|
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
|
||||||
|
|
||||||
|
const switchPlan = async () => {
|
||||||
|
await upgradeSubscription({
|
||||||
|
tier,
|
||||||
|
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
||||||
|
isAnnual: isAnnualPlan,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
await Promise.all([
|
||||||
|
utils.stripe.getBillingStatus.invalidate(),
|
||||||
|
utils.stripe.getProducts.invalidate(),
|
||||||
|
utils.user.get.invalidate(),
|
||||||
|
utils.server.count.invalidate(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
setSwitchingTrial(true);
|
||||||
|
await toast
|
||||||
|
.promise(switchPlan(), {
|
||||||
|
loading: `Switching to ${planName}...`,
|
||||||
|
success: `Your trial is now on ${planName}`,
|
||||||
|
error: (error) =>
|
||||||
|
error instanceof Error ? error.message : "Error switching plan",
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.catch(() => undefined);
|
||||||
|
setSwitchingTrial(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartTrial = async (tier: "hobby" | "startup") => {
|
||||||
|
setTrialTier(tier);
|
||||||
|
try {
|
||||||
|
await startFreeTrial({ tier });
|
||||||
|
await Promise.all([
|
||||||
|
utils.stripe.getBillingStatus.invalidate(),
|
||||||
|
utils.stripe.getProducts.invalidate(),
|
||||||
|
utils.user.get.invalidate(),
|
||||||
|
]);
|
||||||
|
toast.success("Your 7-day trial has started");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error starting trial",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setTrialTier(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||||
const [startupServerQuantity, setStartupServerQuantity] = useState(
|
const [startupServerQuantity, setStartupServerQuantity] = useState(
|
||||||
@@ -150,12 +212,23 @@ export const ShowBilling = () => {
|
|||||||
|
|
||||||
const useNewPricing = data?.hobbyProductId && data?.startupProductId;
|
const useNewPricing = data?.hobbyProductId && data?.startupProductId;
|
||||||
const products = data?.products.filter((product) => {
|
const products = data?.products.filter((product) => {
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
const interval = product?.default_price?.recurring?.interval;
|
const interval = product?.default_price?.recurring?.interval;
|
||||||
return isAnnual ? interval === "year" : interval === "month";
|
return isAnnual ? interval === "year" : interval === "month";
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
|
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
|
||||||
|
const canStartTrial =
|
||||||
|
!isEnterpriseCloud &&
|
||||||
|
!!billingStatus &&
|
||||||
|
!billingStatus.plan &&
|
||||||
|
!billingStatus.isOnTrial &&
|
||||||
|
!billingStatus.hasUsedTrial;
|
||||||
|
/** Checkout would create a second subscription alongside a running trial. */
|
||||||
|
const canSubscribe =
|
||||||
|
!isEnterpriseCloud &&
|
||||||
|
(data?.subscriptions?.length ?? 0) === 0 &&
|
||||||
|
!billingStatus?.isOnTrial;
|
||||||
const maxServers = admin?.user.serversQuantity ?? 1;
|
const maxServers = admin?.user.serversQuantity ?? 1;
|
||||||
const percentage = ((servers ?? 0) / maxServers) * 100;
|
const percentage = ((servers ?? 0) / maxServers) * 100;
|
||||||
const safePercentage = Math.min(percentage, 100);
|
const safePercentage = Math.min(percentage, 100);
|
||||||
@@ -249,6 +322,126 @@ export const ShowBilling = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full mt-6">
|
<div className="flex flex-col gap-4 w-full mt-6">
|
||||||
|
{!isEnterpriseCloud &&
|
||||||
|
billingStatus &&
|
||||||
|
(billingStatus.plan || billingStatus.isOnTrial) && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4 max-w-2xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{billingStatus.isOnTrial ? (
|
||||||
|
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||||
|
) : (
|
||||||
|
<CreditCard className="h-5 w-5 text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{billingStatus.isOnTrial
|
||||||
|
? "Free trial"
|
||||||
|
: "Current plan"}
|
||||||
|
</span>
|
||||||
|
<Badge className="capitalize" variant="secondary">
|
||||||
|
{billingStatus.plan ?? "Trial"}
|
||||||
|
</Badge>
|
||||||
|
{billingStatus.plan && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{billingStatus.isAnnual ? "Annual" : "Monthly"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{billingStatus.isOnTrial
|
||||||
|
? `${billingStatus.trialDaysRemaining} day${billingStatus.trialDaysRemaining === 1 ? "" : "s"} left${
|
||||||
|
billingStatus.trialEndsAt
|
||||||
|
? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}`
|
||||||
|
: ""
|
||||||
|
}${
|
||||||
|
billingStatus.hasPaymentMethod
|
||||||
|
? " · billing starts automatically"
|
||||||
|
: " · add a card to keep your servers"
|
||||||
|
}`
|
||||||
|
: "You're subscribed and billed automatically."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{admin?.user.stripeCustomerId && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{billingStatus.isOnTrial &&
|
||||||
|
useNewPricing &&
|
||||||
|
billingStatus.plan &&
|
||||||
|
billingStatus.plan !== "legacy" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
isLoading={switchingTrial}
|
||||||
|
onClick={() =>
|
||||||
|
handleSwitchTrialPlan(
|
||||||
|
billingStatus.plan === "startup"
|
||||||
|
? "hobby"
|
||||||
|
: "startup",
|
||||||
|
billingStatus.isAnnual,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{billingStatus.plan === "startup"
|
||||||
|
? "Switch to Hobby"
|
||||||
|
: "Switch to Startup"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
isLoading={switchingTrial}
|
||||||
|
onClick={() =>
|
||||||
|
handleSwitchTrialPlan(
|
||||||
|
billingStatus.plan as "hobby" | "startup",
|
||||||
|
!billingStatus.isAnnual,
|
||||||
|
billingStatus.isAnnual
|
||||||
|
? "monthly billing"
|
||||||
|
: "annual billing",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{billingStatus.isAnnual
|
||||||
|
? "Switch to monthly"
|
||||||
|
: "Switch to annual"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={
|
||||||
|
billingStatus.isOnTrial &&
|
||||||
|
!billingStatus.hasPaymentMethod
|
||||||
|
? "default"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
const session = await createCustomerPortalSession();
|
||||||
|
window.open(session.url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{billingStatus.isOnTrial &&
|
||||||
|
!billingStatus.hasPaymentMethod
|
||||||
|
? "Add payment method"
|
||||||
|
: "Manage Subscription"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canStartTrial && (
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
||||||
|
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
7-day free trial on any plan
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
No credit card required — pick Hobby or Startup below.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||||
<div className="space-y-2 flex flex-col">
|
<div className="space-y-2 flex flex-col">
|
||||||
<h3 className="text-lg font-medium">Servers Plan</h3>
|
<h3 className="text-lg font-medium">Servers Plan</h3>
|
||||||
@@ -809,7 +1002,7 @@ export const ShowBilling = () => {
|
|||||||
"Unlimited Deployments",
|
"Unlimited Deployments",
|
||||||
"Unlimited Databases",
|
"Unlimited Databases",
|
||||||
"Unlimited Applications",
|
"Unlimited Applications",
|
||||||
"1 Server Included",
|
"Setup 1 Server",
|
||||||
"1 Organization",
|
"1 Organization",
|
||||||
"1 User",
|
"1 User",
|
||||||
"2 Environments",
|
"2 Environments",
|
||||||
@@ -825,68 +1018,71 @@ export const ShowBilling = () => {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-6 flex flex-col gap-3">
|
<div className="mt-6 flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
{canSubscribe && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
Servers:
|
<span className="text-sm text-muted-foreground">
|
||||||
</span>
|
Servers:
|
||||||
<Button
|
</span>
|
||||||
disabled={hobbyServerQuantity <= 1}
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() =>
|
|
||||||
setHobbyServerQuantity((q) => Math.max(1, q - 1))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MinusIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<NumberInput
|
|
||||||
value={hobbyServerQuantity}
|
|
||||||
onChange={(e) =>
|
|
||||||
setHobbyServerQuantity(
|
|
||||||
Math.max(
|
|
||||||
1,
|
|
||||||
Number(
|
|
||||||
(e.target as HTMLInputElement).value,
|
|
||||||
) || 1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="text-center"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setHobbyServerQuantity((q) => q + 1)}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
{admin?.user.stripeCustomerId && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
disabled={hobbyServerQuantity <= 1}
|
||||||
className="w-full"
|
variant="outline"
|
||||||
onClick={async () => {
|
size="icon"
|
||||||
const session =
|
onClick={() =>
|
||||||
await createCustomerPortalSession();
|
setHobbyServerQuantity((q) =>
|
||||||
window.open(session.url);
|
Math.max(1, q - 1),
|
||||||
}}
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Manage Subscription
|
<MinusIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<NumberInput
|
||||||
|
value={hobbyServerQuantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
setHobbyServerQuantity(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
Number(
|
||||||
|
(e.target as HTMLInputElement).value,
|
||||||
|
) || 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-center"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
setHobbyServerQuantity((q) => q + 1)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
{canStartTrial && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
isLoading={trialTier === "hobby"}
|
||||||
|
disabled={trialTier !== null}
|
||||||
|
onClick={() => handleStartTrial("hobby")}
|
||||||
|
>
|
||||||
|
Start 7-day free trial
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canSubscribe && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant={canStartTrial ? "outline" : "default"}
|
||||||
|
onClick={() =>
|
||||||
|
handleCheckout("hobby", data!.hobbyProductId!)
|
||||||
|
}
|
||||||
|
disabled={hobbyServerQuantity < 1}
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isEnterpriseCloud &&
|
|
||||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
onClick={() =>
|
|
||||||
handleCheckout("hobby", data!.hobbyProductId!)
|
|
||||||
}
|
|
||||||
disabled={hobbyServerQuantity < 1}
|
|
||||||
>
|
|
||||||
Get Started
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -941,7 +1137,7 @@ export const ShowBilling = () => {
|
|||||||
All the features of Hobby, plus…
|
All the features of Hobby, plus…
|
||||||
</li>
|
</li>
|
||||||
{[
|
{[
|
||||||
"3 Servers Included",
|
"Setup up to 3 Servers",
|
||||||
"3 Organizations",
|
"3 Organizations",
|
||||||
"Unlimited Users",
|
"Unlimited Users",
|
||||||
"Unlimited Environments",
|
"Unlimited Environments",
|
||||||
@@ -959,85 +1155,83 @@ export const ShowBilling = () => {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-6 flex flex-col gap-3">
|
<div className="mt-6 flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-2">
|
{canSubscribe && (
|
||||||
<span className="text-sm font-medium text-foreground">
|
<div className="flex flex-col gap-2">
|
||||||
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
<span className="text-sm font-medium text-foreground">
|
||||||
</span>
|
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
||||||
<div className="flex items-center gap-2">
|
</span>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
disabled={
|
|
||||||
startupServerQuantity <=
|
|
||||||
STARTUP_SERVERS_INCLUDED
|
|
||||||
}
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() =>
|
|
||||||
setStartupServerQuantity((q) =>
|
|
||||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MinusIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<NumberInput
|
|
||||||
value={startupServerQuantity}
|
|
||||||
onChange={(e) =>
|
|
||||||
setStartupServerQuantity(
|
|
||||||
Math.max(
|
|
||||||
STARTUP_SERVERS_INCLUDED,
|
|
||||||
Number(
|
|
||||||
(e.target as HTMLInputElement).value,
|
|
||||||
) || STARTUP_SERVERS_INCLUDED,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="h-8 text-center"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() =>
|
|
||||||
setStartupServerQuantity((q) => q + 1)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
{admin?.user.stripeCustomerId && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="w-full"
|
|
||||||
onClick={async () => {
|
|
||||||
const session =
|
|
||||||
await createCustomerPortalSession();
|
|
||||||
window.open(session.url);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Manage Subscription
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isEnterpriseCloud &&
|
|
||||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
|
||||||
onClick={() =>
|
|
||||||
handleCheckout(
|
|
||||||
"startup",
|
|
||||||
data!.startupProductId!,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={
|
disabled={
|
||||||
startupServerQuantity <
|
startupServerQuantity <=
|
||||||
STARTUP_SERVERS_INCLUDED
|
STARTUP_SERVERS_INCLUDED
|
||||||
}
|
}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() =>
|
||||||
|
setStartupServerQuantity((q) =>
|
||||||
|
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Get Started
|
<MinusIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<NumberInput
|
||||||
|
value={startupServerQuantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
setStartupServerQuantity(
|
||||||
|
Math.max(
|
||||||
|
STARTUP_SERVERS_INCLUDED,
|
||||||
|
Number(
|
||||||
|
(e.target as HTMLInputElement).value,
|
||||||
|
) || STARTUP_SERVERS_INCLUDED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 text-center"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() =>
|
||||||
|
setStartupServerQuantity((q) => q + 1)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
{canStartTrial && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
isLoading={trialTier === "startup"}
|
||||||
|
disabled={trialTier !== null}
|
||||||
|
onClick={() => handleStartTrial("startup")}
|
||||||
|
>
|
||||||
|
Start 7-day free trial
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canSubscribe && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant={canStartTrial ? "outline" : "default"}
|
||||||
|
onClick={() =>
|
||||||
|
handleCheckout(
|
||||||
|
"startup",
|
||||||
|
data!.startupProductId!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
startupServerQuantity < STARTUP_SERVERS_INCLUDED
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1229,31 +1423,17 @@ export const ShowBilling = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 mt-4 w-full">
|
<div className="flex flex-col gap-2 mt-4 w-full">
|
||||||
{admin?.user.stripeCustomerId && (
|
{canSubscribe && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const session =
|
handleCheckout("legacy", product.id);
|
||||||
await createCustomerPortalSession();
|
|
||||||
window.open(session.url);
|
|
||||||
}}
|
}}
|
||||||
|
disabled={hobbyServerQuantity < 1}
|
||||||
>
|
>
|
||||||
Manage Subscription
|
Subscribe
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isEnterpriseCloud &&
|
|
||||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
onClick={async () => {
|
|
||||||
handleCheckout("legacy", product.id);
|
|
||||||
}}
|
|
||||||
disabled={hobbyServerQuantity < 1}
|
|
||||||
>
|
|
||||||
Subscribe
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user