mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-07 23:15:27 +02:00
Compare commits
325 Commits
2702-add-d
...
fix/env-ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f48c023250 | ||
|
|
b06138b230 | ||
|
|
af8072d7ad | ||
|
|
6e342ee2f2 | ||
|
|
ef0cf9bd02 | ||
|
|
8d88a34a64 | ||
|
|
a50f958a6f | ||
|
|
1fdbe87d84 | ||
|
|
67278d8783 | ||
|
|
aff200f84f | ||
|
|
558d809871 | ||
|
|
f8fcf68909 | ||
|
|
7a568aadac | ||
|
|
63e33a29cc | ||
|
|
754774ea02 | ||
|
|
a714e0f83f | ||
|
|
9f10f0f4e9 | ||
|
|
b109e0ebc4 | ||
|
|
282d358d04 | ||
|
|
2f08b33931 | ||
|
|
62aeed5aed | ||
|
|
5e021797f3 | ||
|
|
1c6fdc1b43 | ||
|
|
6270bad9af | ||
|
|
9c71458eff | ||
|
|
547ba2d04b | ||
|
|
b9e97eb321 | ||
|
|
a4e2317f3e | ||
|
|
06a349152f | ||
|
|
fef2de1ec5 | ||
|
|
b20ff64cbf | ||
|
|
5177580d51 | ||
|
|
d3292a2810 | ||
|
|
0f526af2c8 | ||
|
|
72f5d711c8 | ||
|
|
ffd51cf32f | ||
|
|
e8b3d7ba7d | ||
|
|
c182755591 | ||
|
|
8227a48ef4 | ||
|
|
f5ddc36f24 | ||
|
|
d5d8914bf6 | ||
|
|
bf0890a6b0 | ||
|
|
4e07669464 | ||
|
|
4a3fa6e63f | ||
|
|
14af5d293a | ||
|
|
746bb3ddc6 | ||
|
|
b13308dc69 | ||
|
|
16746a1609 | ||
|
|
bca62d43d2 | ||
|
|
d502f4a206 | ||
|
|
de7d6f8147 | ||
|
|
9d6bc4cd18 | ||
|
|
65b27af0f5 | ||
|
|
6165114bc3 | ||
|
|
d3109359fb | ||
|
|
58f527d029 | ||
|
|
1ed41fe2f8 | ||
|
|
9b416b3699 | ||
|
|
096b8b33fc | ||
|
|
741792883a | ||
|
|
e0c6ed699d | ||
|
|
5f5ed0f2c2 | ||
|
|
b9ff576682 | ||
|
|
c854a38adb | ||
|
|
5fb365c08b | ||
|
|
15296d5c85 | ||
|
|
0e5fc584b2 | ||
|
|
cc7ea5108b | ||
|
|
8f3d824ea6 | ||
|
|
0bdcbf5827 | ||
|
|
34564aec84 | ||
|
|
ed006dc5f9 | ||
|
|
fb6b06f064 | ||
|
|
09824facf8 | ||
|
|
bd46eaec5c | ||
|
|
e9fdc19b96 | ||
|
|
3e81cdac4d | ||
|
|
e72c51444c | ||
|
|
940d18ad25 | ||
|
|
c41b69c925 | ||
|
|
b610f7aeff | ||
|
|
cdd77a04dc | ||
|
|
05f22edfe5 | ||
|
|
29480cde90 | ||
|
|
232ccc9139 | ||
|
|
018e2b153e | ||
|
|
ad490dca3f | ||
|
|
f8c6c8f7cc | ||
|
|
d7af82731c | ||
|
|
c3fa638a56 | ||
|
|
ce703ef478 | ||
|
|
fc6df3ae05 | ||
|
|
8fb517152a | ||
|
|
ba3591b3ac | ||
|
|
98a586478e | ||
|
|
13248c8d8a | ||
|
|
54417ca8e7 | ||
|
|
598fae0e92 | ||
|
|
b392e58001 | ||
|
|
d9945c0a4f | ||
|
|
f6e2c033ba | ||
|
|
5c787adae1 | ||
|
|
2ba1df1eaa | ||
|
|
e7859395b1 | ||
|
|
6f0ed89ce7 | ||
|
|
4277a509b2 | ||
|
|
f7b576cbf3 | ||
|
|
425fef6e28 | ||
|
|
958372c5f9 | ||
|
|
e7c581476e | ||
|
|
0cae8330e2 | ||
|
|
4a271c11e7 | ||
|
|
fda367b2c5 | ||
|
|
ea1238b1d1 | ||
|
|
b060f80932 | ||
|
|
04b9f56333 | ||
|
|
599b97da51 | ||
|
|
415298fddb | ||
|
|
ddff8b9de7 | ||
|
|
90f97912a4 | ||
|
|
9af745ce67 | ||
|
|
d99f2cd460 | ||
|
|
d234558822 | ||
|
|
7f25ddca44 | ||
|
|
638b3dd546 | ||
|
|
1a8fd8396d | ||
|
|
385850f354 | ||
|
|
a48306a2c6 | ||
|
|
89737e7b65 | ||
|
|
00c708483e | ||
|
|
ddf570a807 | ||
|
|
f8eb2ba4ba | ||
|
|
9f07f8e9e1 | ||
|
|
3cefa43a21 | ||
|
|
0941ec9f3e | ||
|
|
879218a8b1 | ||
|
|
d6124aae81 | ||
|
|
f404b231a6 | ||
|
|
7a986e5fb3 | ||
|
|
9687ed0d83 | ||
|
|
b4c57b6326 | ||
|
|
f8eb3c2b76 | ||
|
|
a30617d85d | ||
|
|
b079cbd427 | ||
|
|
aeda19db8a | ||
|
|
cb64482649 | ||
|
|
f4cae5f775 | ||
|
|
825e6b654c | ||
|
|
c1b19376a9 | ||
|
|
6c3578a475 | ||
|
|
b8db120432 | ||
|
|
7c10610a5a | ||
|
|
8d8658a478 | ||
|
|
fbde5be02c | ||
|
|
090c0226ed | ||
|
|
4a1b42899b | ||
|
|
343514d4eb | ||
|
|
36067618f4 | ||
|
|
cc74f9e38c | ||
|
|
df7e1da776 | ||
|
|
df9aa50ece | ||
|
|
ebbc008dbe | ||
|
|
645a81b2ce | ||
|
|
a6db83c758 | ||
|
|
ac65cc97f4 | ||
|
|
30d5493281 | ||
|
|
91b44720ef | ||
|
|
f700017ccf | ||
|
|
9287721dbf | ||
|
|
6cde04ea39 | ||
|
|
283eeeb3e6 | ||
|
|
19ae575fa8 | ||
|
|
4077af1308 | ||
|
|
8a043dcc5c | ||
|
|
46204831f7 | ||
|
|
c854d4eb01 | ||
|
|
b8812dd7f2 | ||
|
|
ddde6a7bcb | ||
|
|
04ffa43008 | ||
|
|
17393af717 | ||
|
|
24b56c868d | ||
|
|
be871a0c59 | ||
|
|
2d6136a633 | ||
|
|
acfab54810 | ||
|
|
5e7328b00d | ||
|
|
882acd5c4c | ||
|
|
e7c7d6a7cf | ||
|
|
45f2f52cf0 | ||
|
|
31f53197eb | ||
|
|
cfed61fb96 | ||
|
|
bfa4ebc801 | ||
|
|
c160f24765 | ||
|
|
b445e05202 | ||
|
|
239e2d4d96 | ||
|
|
791c9d1268 | ||
|
|
f076e72046 | ||
|
|
32758b29a7 | ||
|
|
6e9c5c79dc | ||
|
|
182bbf43c8 | ||
|
|
760edc6d5d | ||
|
|
a1a5141da6 | ||
|
|
b573ccc90c | ||
|
|
6c28451ca1 | ||
|
|
6c834a9127 | ||
|
|
2af420ef77 | ||
|
|
87c7305cb2 | ||
|
|
31fdf69286 | ||
|
|
f1bc3758b2 | ||
|
|
396fb9f57f | ||
|
|
8e54e88370 | ||
|
|
7e0fde8041 | ||
|
|
3969d2d2fe | ||
|
|
b6ec2d510e | ||
|
|
1753ac6605 | ||
|
|
8dd970674d | ||
|
|
b3919be628 | ||
|
|
5a0ec2c9dc | ||
|
|
012b67a491 | ||
|
|
b003fb4ffe | ||
|
|
85c409e748 | ||
|
|
745cf9d979 | ||
|
|
70c611964e | ||
|
|
0f02c4dfc3 | ||
|
|
8557432db0 | ||
|
|
e36ae4b4d6 | ||
|
|
ed5e483f0b | ||
|
|
2e027a7da5 | ||
|
|
791ca657a3 | ||
|
|
1bf4f56ae6 | ||
|
|
02f2829af9 | ||
|
|
b2ca51cee7 | ||
|
|
1cfc15ca0b | ||
|
|
0cb5ee49e0 | ||
|
|
3d838aa074 | ||
|
|
eafbd0353e | ||
|
|
1506d8f21e | ||
|
|
e1e175b1e0 | ||
|
|
8001304e98 | ||
|
|
987cb41bfc | ||
|
|
199589d42e | ||
|
|
91d4fe2420 | ||
|
|
92caee5a77 | ||
|
|
092212e225 | ||
|
|
5c053777c5 | ||
|
|
eed36e52af | ||
|
|
dd28a8e703 | ||
|
|
e211feb801 | ||
|
|
da239675bd | ||
|
|
0d5f452494 | ||
|
|
2eb460ba63 | ||
|
|
d8e15a60f0 | ||
|
|
1b3b439257 | ||
|
|
964d79d552 | ||
|
|
1730f427df | ||
|
|
28845c145e | ||
|
|
b7adb7fb0a | ||
|
|
e4f6e5ea54 | ||
|
|
96d1abb4b6 | ||
|
|
c5f804421c | ||
|
|
c51d71848d | ||
|
|
da5d9b2c75 | ||
|
|
e102876e4d | ||
|
|
4c06a72075 | ||
|
|
cfa60aa971 | ||
|
|
d2e4922c2f | ||
|
|
192716b8ae | ||
|
|
13f1de5bd7 | ||
|
|
2e8e2dc2da | ||
|
|
fd2097ea23 | ||
|
|
71de71fb8a | ||
|
|
6192c08400 | ||
|
|
435d812e1d | ||
|
|
18b8b2624b | ||
|
|
91ebf3b6f5 | ||
|
|
d9b2b48643 | ||
|
|
148c91bf5e | ||
|
|
c42a16d658 | ||
|
|
b222409129 | ||
|
|
a322ac374c | ||
|
|
92975a6865 | ||
|
|
4ef8c94340 | ||
|
|
ff369c9d3a | ||
|
|
2a2acbfe9a | ||
|
|
fc8a5153f1 | ||
|
|
2da45d3ca9 | ||
|
|
bf9d2615c2 | ||
|
|
40d07357bc | ||
|
|
1e5e361094 | ||
|
|
abc7014b61 | ||
|
|
a8648607b8 | ||
|
|
453a7b12b6 | ||
|
|
fadc7fede5 | ||
|
|
d7886fb7c9 | ||
|
|
939ff810a2 | ||
|
|
1926417458 | ||
|
|
864e2299ee | ||
|
|
be3d7825e1 | ||
|
|
c6efe6f35b | ||
|
|
84fb82ea99 | ||
|
|
1d96c4d534 | ||
|
|
bb02de690b | ||
|
|
c8fd999044 | ||
|
|
0a4becb614 | ||
|
|
e85adaedbd | ||
|
|
32657499ab | ||
|
|
67899c762c | ||
|
|
ab69d782c7 | ||
|
|
405fc69df4 | ||
|
|
ce5ad35981 | ||
|
|
c66902fb96 | ||
|
|
70776ba8ca | ||
|
|
2df1b42540 | ||
|
|
48902c488f | ||
|
|
09dd7cc938 | ||
|
|
eae83674b0 | ||
|
|
d1ebc133aa | ||
|
|
d29fe437b9 | ||
|
|
27ad851d45 | ||
|
|
42f8773c05 | ||
|
|
5eef844e5f | ||
|
|
14dafa9a8a | ||
|
|
c5eb31ab90 | ||
|
|
d6704dbd27 | ||
|
|
dcdbed047b | ||
|
|
c362b2c558 |
79
.github/workflows/dokploy.yml
vendored
79
.github/workflows/dokploy.yml
vendored
@@ -138,6 +138,8 @@ 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
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -160,3 +162,80 @@ jobs:
|
|||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
sync-version:
|
||||||
|
needs: [generate-release]
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Sync version to MCP repository
|
||||||
|
run: |
|
||||||
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||||
|
cd /tmp/mcp-repo
|
||||||
|
|
||||||
|
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
||||||
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
|
npm install -g pnpm
|
||||||
|
pnpm install
|
||||||
|
pnpm run fetch-openapi
|
||||||
|
pnpm run generate
|
||||||
|
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
||||||
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
|
--allow-empty
|
||||||
|
git push
|
||||||
|
|
||||||
|
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Sync version to CLI repository
|
||||||
|
run: |
|
||||||
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||||
|
cd /tmp/cli-repo
|
||||||
|
|
||||||
|
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
||||||
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
|
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||||
|
npm install -g pnpm
|
||||||
|
pnpm install
|
||||||
|
pnpm run generate
|
||||||
|
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
||||||
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
|
--allow-empty
|
||||||
|
git push
|
||||||
|
|
||||||
|
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Sync version to SDK repository
|
||||||
|
run: |
|
||||||
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
||||||
|
cd /tmp/sdk-repo
|
||||||
|
|
||||||
|
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
||||||
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
|
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||||
|
npm install -g pnpm
|
||||||
|
pnpm install
|
||||||
|
pnpm run generate
|
||||||
|
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
||||||
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
|
--allow-empty
|
||||||
|
git push
|
||||||
|
|
||||||
|
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
|
||||||
|
|||||||
63
.github/workflows/sync-openapi-docs.yml
vendored
63
.github/workflows/sync-openapi-docs.yml
vendored
@@ -68,3 +68,66 @@ jobs:
|
|||||||
|
|
||||||
echo "✅ OpenAPI synced to website successfully"
|
echo "✅ OpenAPI synced to website successfully"
|
||||||
|
|
||||||
|
- name: Sync to MCP repository
|
||||||
|
run: |
|
||||||
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
|
||||||
|
|
||||||
|
cd mcp-repo
|
||||||
|
|
||||||
|
cp -f ../openapi.json openapi.json
|
||||||
|
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
|
||||||
|
git add openapi.json
|
||||||
|
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||||
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
|
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||||
|
--allow-empty
|
||||||
|
|
||||||
|
git push
|
||||||
|
|
||||||
|
echo "✅ OpenAPI synced to MCP repository successfully"
|
||||||
|
|
||||||
|
- name: Sync to CLI repository
|
||||||
|
run: |
|
||||||
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
|
||||||
|
|
||||||
|
cd cli-repo
|
||||||
|
|
||||||
|
cp -f ../openapi.json openapi.json
|
||||||
|
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
|
||||||
|
git add openapi.json
|
||||||
|
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||||
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
|
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||||
|
--allow-empty
|
||||||
|
|
||||||
|
git push
|
||||||
|
|
||||||
|
echo "✅ OpenAPI synced to CLI repository successfully"
|
||||||
|
|
||||||
|
- name: Sync to SDK repository
|
||||||
|
run: |
|
||||||
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
|
||||||
|
|
||||||
|
cd sdk-repo
|
||||||
|
|
||||||
|
cp -f ../openapi.json openapi.json
|
||||||
|
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
|
||||||
|
git add openapi.json
|
||||||
|
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||||
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
|
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||||
|
--allow-empty
|
||||||
|
|
||||||
|
git push
|
||||||
|
|
||||||
|
echo "✅ OpenAPI synced to SDK repository successfully"
|
||||||
|
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,5 +4,8 @@
|
|||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.biome": "explicit",
|
"source.fixAll.biome": "explicit",
|
||||||
"source.organizeImports.biome": "explicit"
|
"source.organizeImports.biome": "explicit"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
||||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||||
|
|
||||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ To get started, run the following command on a VPS:
|
|||||||
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
|
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://dokploy.com/install.sh | sh
|
curl -sSL https://dokploy.com/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ describe("Host rule format regression tests", () => {
|
|||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
customEntrypoint: null,
|
customEntrypoint: null,
|
||||||
|
middlewares: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Host rule format validation", () => {
|
describe("Host rule format validation", () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe("createDomainLabels", () => {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
|
middlewares: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
it("should create basic labels for web entrypoint", async () => {
|
||||||
@@ -172,12 +173,12 @@ describe("createDomainLabels", () => {
|
|||||||
"websecure",
|
"websecure",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Web entrypoint should have both middlewares with redirect first
|
// Web entrypoint with HTTPS should only have redirect
|
||||||
expect(webLabels).toContain(
|
expect(webLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Websecure should only have the addprefix middleware
|
// Websecure should have the addprefix middleware
|
||||||
expect(websecureLabels).toContain(
|
expect(websecureLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
|
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
@@ -209,9 +210,9 @@ describe("createDomainLabels", () => {
|
|||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should have middlewares in correct order: redirect, stripprefix, addprefix
|
// Web router with HTTPS should only have redirect
|
||||||
expect(webLabels).toContain(
|
expect(webLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,6 +243,131 @@ describe("createDomainLabels", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should add single custom middleware to router", async () => {
|
||||||
|
const customMiddlewareDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
middlewares: ["auth@file"],
|
||||||
|
};
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
appName,
|
||||||
|
customMiddlewareDomain,
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(labels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add multiple custom middlewares to router", async () => {
|
||||||
|
const customMiddlewareDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
middlewares: ["auth@file", "rate-limit@file"],
|
||||||
|
};
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
appName,
|
||||||
|
customMiddlewareDomain,
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(labels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
|
||||||
|
const combinedDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
https: true,
|
||||||
|
middlewares: ["auth@file"],
|
||||||
|
};
|
||||||
|
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
||||||
|
|
||||||
|
// Web router with HTTPS should only redirect, custom middlewares go on websecure
|
||||||
|
expect(labels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||||
|
);
|
||||||
|
expect(labels).not.toContain("auth@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
|
||||||
|
const combinedDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
path: "/api",
|
||||||
|
stripPath: true,
|
||||||
|
middlewares: ["auth@file"],
|
||||||
|
};
|
||||||
|
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
||||||
|
|
||||||
|
// stripprefix should come before custom middleware
|
||||||
|
expect(labels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
|
||||||
|
const fullDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
https: true,
|
||||||
|
path: "/api",
|
||||||
|
stripPath: true,
|
||||||
|
internalPath: "/hello",
|
||||||
|
middlewares: ["auth@file", "rate-limit@file"],
|
||||||
|
};
|
||||||
|
const webLabels = await createDomainLabels(appName, fullDomain, "web");
|
||||||
|
|
||||||
|
// Web router with HTTPS should only redirect
|
||||||
|
expect(webLabels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||||
|
);
|
||||||
|
// Middleware definitions should still be present (Traefik needs them registered)
|
||||||
|
expect(webLabels).toContain(
|
||||||
|
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
||||||
|
);
|
||||||
|
expect(webLabels).toContain(
|
||||||
|
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||||
|
);
|
||||||
|
// But they should NOT be attached to the router
|
||||||
|
expect(webLabels).not.toContain("stripprefix-test-app-1,");
|
||||||
|
expect(webLabels).not.toContain("auth@file");
|
||||||
|
expect(webLabels).not.toContain("rate-limit@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include custom middlewares on websecure entrypoint", async () => {
|
||||||
|
const customMiddlewareDomain = {
|
||||||
|
...baseDomain,
|
||||||
|
https: true,
|
||||||
|
middlewares: ["auth@file"],
|
||||||
|
};
|
||||||
|
const websecureLabels = await createDomainLabels(
|
||||||
|
appName,
|
||||||
|
customMiddlewareDomain,
|
||||||
|
"websecure",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Websecure should have custom middleware but not redirect-to-https
|
||||||
|
expect(websecureLabels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
|
||||||
|
);
|
||||||
|
expect(websecureLabels).not.toContain("redirect-to-https");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
|
||||||
|
const domain = {
|
||||||
|
...baseDomain,
|
||||||
|
https: true,
|
||||||
|
middlewares: ["rate-limit@file", "auth@file"],
|
||||||
|
};
|
||||||
|
const webLabels = await createDomainLabels(appName, domain, "web");
|
||||||
|
|
||||||
|
// Web router with HTTPS should ONLY have redirect, not custom middlewares
|
||||||
|
expect(webLabels).toContain(
|
||||||
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||||
|
);
|
||||||
|
expect(webLabels).not.toContain("rate-limit@file");
|
||||||
|
expect(webLabels).not.toContain("auth@file");
|
||||||
|
});
|
||||||
|
|
||||||
it("should create basic labels for custom entrypoint", async () => {
|
it("should create basic labels for custom entrypoint", async () => {
|
||||||
const labels = await createDomainLabels(
|
const labels = await createDomainLabels(
|
||||||
appName,
|
appName,
|
||||||
|
|||||||
@@ -415,5 +415,24 @@ describe("Docker Image Name and Tag Extraction", () => {
|
|||||||
expect(extractImageTag("my-image:123")).toBe("123");
|
expect(extractImageTag("my-image:123")).toBe("123");
|
||||||
expect(extractImageTag("my-image:1")).toBe("1");
|
expect(extractImageTag("my-image:1")).toBe("1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return 'latest' for registry with port but no tag", () => {
|
||||||
|
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
|
||||||
|
"latest",
|
||||||
|
);
|
||||||
|
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
|
||||||
|
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
|
||||||
|
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract tag from registry with port and tag", () => {
|
||||||
|
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
|
||||||
|
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
|
||||||
|
"v2.0",
|
||||||
|
);
|
||||||
|
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
|
||||||
|
"sha-abc123",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
41
apps/dokploy/__test__/deploy/should-deploy.test.ts
Normal file
41
apps/dokploy/__test__/deploy/should-deploy.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { shouldDeploy } from "@dokploy/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("shouldDeploy", () => {
|
||||||
|
it("should deploy when no watch paths are configured", () => {
|
||||||
|
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
|
||||||
|
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should deploy when watch paths match modified files", () => {
|
||||||
|
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
|
||||||
|
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not deploy when watch paths do not match", () => {
|
||||||
|
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw when modified files contain non-string values", () => {
|
||||||
|
expect(() =>
|
||||||
|
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(
|
||||||
|
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw when modified files are undefined or null", () => {
|
||||||
|
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
|
||||||
|
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
|
||||||
|
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
|
||||||
|
expect(shouldDeploy(["src/**"], null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw when every modified file is non-string", () => {
|
||||||
|
expect(() =>
|
||||||
|
shouldDeploy(["src/**"], [undefined, undefined] as any),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,6 +120,7 @@ const baseApp: ApplicationNested = {
|
|||||||
environmentId: "",
|
environmentId: "",
|
||||||
enabled: null,
|
enabled: null,
|
||||||
env: null,
|
env: null,
|
||||||
|
icon: null,
|
||||||
healthCheckSwarm: null,
|
healthCheckSwarm: null,
|
||||||
labelsSwarm: null,
|
labelsSwarm: null,
|
||||||
memoryLimit: null,
|
memoryLimit: null,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const createApplication = (
|
|||||||
env: null,
|
env: null,
|
||||||
},
|
},
|
||||||
replicas: 1,
|
replicas: 1,
|
||||||
stopGracePeriodSwarm: 0n,
|
stopGracePeriodSwarm: 0,
|
||||||
ulimitsSwarm: null,
|
ulimitsSwarm: null,
|
||||||
serverId: "server-id",
|
serverId: "server-id",
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
|
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
|
||||||
const application = createApplication({ stopGracePeriodSwarm: 0n });
|
const application = createApplication({ stopGracePeriodSwarm: 0 });
|
||||||
|
|
||||||
await mechanizeDockerContainer(application);
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
|
|||||||
@@ -494,4 +494,49 @@ describe("processTemplate", () => {
|
|||||||
expect(result.mounts).toHaveLength(1);
|
expect(result.mounts).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isolated deployment config", () => {
|
||||||
|
it("should default to isolated=true when not specified", () => {
|
||||||
|
const template: CompleteTemplate = {
|
||||||
|
metadata: {} as any,
|
||||||
|
variables: {},
|
||||||
|
config: {
|
||||||
|
domains: [],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(template.config.isolated).toBeUndefined();
|
||||||
|
// undefined !== false => isolatedDeployment = true
|
||||||
|
expect(template.config.isolated !== false).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be isolated when isolated=true is explicitly set", () => {
|
||||||
|
const template: CompleteTemplate = {
|
||||||
|
metadata: {} as any,
|
||||||
|
variables: {},
|
||||||
|
config: {
|
||||||
|
isolated: true,
|
||||||
|
domains: [],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(template.config.isolated !== false).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable isolated deployment when isolated=false", () => {
|
||||||
|
const template: CompleteTemplate = {
|
||||||
|
metadata: {} as any,
|
||||||
|
variables: {},
|
||||||
|
config: {
|
||||||
|
isolated: false,
|
||||||
|
domains: [],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(template.config.isolated !== false).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ describe("helpers functions", () => {
|
|||||||
const domain = processValue("${domain}", {}, mockSchema);
|
const domain = processValue("${domain}", {}, mockSchema);
|
||||||
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
domain.endsWith(
|
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
|
||||||
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
|
|
||||||
),
|
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ const baseApp: ApplicationNested = {
|
|||||||
dropBuildPath: null,
|
dropBuildPath: null,
|
||||||
enabled: null,
|
enabled: null,
|
||||||
env: null,
|
env: null,
|
||||||
|
icon: null,
|
||||||
healthCheckSwarm: null,
|
healthCheckSwarm: null,
|
||||||
labelsSwarm: null,
|
labelsSwarm: null,
|
||||||
memoryLimit: null,
|
memoryLimit: null,
|
||||||
@@ -146,6 +147,7 @@ const baseDomain: Domain = {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
|
middlewares: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
@@ -265,6 +267,80 @@ test("Websecure entrypoint on https domain with redirect", async () => {
|
|||||||
expect(router.middlewares).toContain("redirect-test-1");
|
expect(router.middlewares).toContain("redirect-test-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Custom Middlewares */
|
||||||
|
|
||||||
|
test("Web entrypoint with single custom middleware", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, middlewares: ["auth@file"] },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(router.middlewares).toContain("auth@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Web entrypoint with multiple custom middlewares", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(router.middlewares).toContain("auth@file");
|
||||||
|
expect(router.middlewares).toContain("rate-limit@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Web entrypoint on https domain with custom middleware", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should only have HTTPS redirect - custom middleware applies on websecure
|
||||||
|
expect(router.middlewares).toContain("redirect-to-https");
|
||||||
|
expect(router.middlewares).not.toContain("auth@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Websecure entrypoint with custom middleware", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
||||||
|
"websecure",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should have custom middleware but not HTTPS redirect
|
||||||
|
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||||
|
expect(router.middlewares).toContain("auth@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Web entrypoint with redirect and custom middleware", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
{
|
||||||
|
...baseApp,
|
||||||
|
appName: "test",
|
||||||
|
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
|
||||||
|
},
|
||||||
|
{ ...baseDomain, middlewares: ["auth@file"] },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should have both redirect middleware and custom middleware
|
||||||
|
expect(router.middlewares).toContain("redirect-test-1");
|
||||||
|
expect(router.middlewares).toContain("auth@file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Web entrypoint with empty middlewares array", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, https: false, middlewares: [] },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should behave same as no middlewares - no redirect for http
|
||||||
|
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||||
|
});
|
||||||
|
|
||||||
/** Certificates */
|
/** Certificates */
|
||||||
|
|
||||||
test("CertificateType on websecure entrypoint", async () => {
|
test("CertificateType on websecure entrypoint", async () => {
|
||||||
@@ -348,6 +424,26 @@ test("Custom entrypoint with internalPath adds addprefix middleware", async () =
|
|||||||
expect(router.entryPoints).toEqual(["custom"]);
|
expect(router.entryPoints).toEqual(["custom"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{
|
||||||
|
...baseDomain,
|
||||||
|
path: "/public",
|
||||||
|
stripPath: true,
|
||||||
|
internalPath: "/app/v2",
|
||||||
|
},
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
|
||||||
|
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
|
||||||
|
|
||||||
|
expect(stripIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(addIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(stripIndex).toBeLessThan(addIndex);
|
||||||
|
});
|
||||||
|
|
||||||
test("Custom entrypoint with https and custom cert resolver", async () => {
|
test("Custom entrypoint with https and custom cert resolver", async () => {
|
||||||
const router = await createRouterConfig(
|
const router = await createRouterConfig(
|
||||||
baseApp,
|
baseApp,
|
||||||
|
|||||||
@@ -78,4 +78,20 @@ describe("readValidDirectory (path traversal)", () => {
|
|||||||
it("returns false for empty string (resolves to cwd)", () => {
|
it("returns false for empty string (resolves to cwd)", () => {
|
||||||
expect(readValidDirectory("")).toBe(false);
|
expect(readValidDirectory("")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns true for Next.js dynamic route paths with square brackets", () => {
|
||||||
|
expect(
|
||||||
|
readValidDirectory(
|
||||||
|
`${BASE}/applications/myapp/code/app/api/[id]/route.ts`,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
readValidDirectory(
|
||||||
|
`${BASE}/applications/myapp/code/app/[...catch]/page.tsx`,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ const menuItems: MenuItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const hasStopGracePeriodSwarm = (
|
||||||
|
value: unknown,
|
||||||
|
): value is { stopGracePeriodSwarm: number | string | null } =>
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"stopGracePeriodSwarm" in value;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
const hasStopGracePeriodSwarm = (
|
const hasStopGracePeriodSwarm = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
): value is { stopGracePeriodSwarm: number | string | null } =>
|
||||||
typeof value === "object" &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
"stopGracePeriodSwarm" in value;
|
"stopGracePeriodSwarm" in value;
|
||||||
@@ -68,7 +68,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
|
|
||||||
const form = useForm<any>({
|
const form = useForm<any>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
value: null as bigint | null,
|
value: null as number | null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,11 +76,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
if (hasStopGracePeriodSwarm(data)) {
|
if (hasStopGracePeriodSwarm(data)) {
|
||||||
const value = data.stopGracePeriodSwarm;
|
const value = data.stopGracePeriodSwarm;
|
||||||
const normalizedValue =
|
const normalizedValue =
|
||||||
value === null || value === undefined
|
value === null || value === undefined ? null : Number(value);
|
||||||
? null
|
|
||||||
: typeof value === "bigint"
|
|
||||||
? value
|
|
||||||
: BigInt(value);
|
|
||||||
form.reset({
|
form.reset({
|
||||||
value: normalizedValue,
|
value: normalizedValue,
|
||||||
});
|
});
|
||||||
@@ -136,7 +132,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
}
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(
|
field.onChange(
|
||||||
e.target.value ? BigInt(e.target.value) : null,
|
e.target.value ? Number(e.target.value) : null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { Check, Copy, Loader2 } from "lucide-react";
|
import { Check, Copy, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
|
||||||
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 { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -165,6 +166,7 @@ export const ShowDeployment = ({
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<AnalyzeLogs logs={filteredLogs} context="build" />
|
||||||
|
|
||||||
{serverId && (
|
{serverId && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
CheckCircle2,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
PenBoxIcon,
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
Trash2,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { RouterOutputs } from "@/utils/api";
|
||||||
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
|
import { AddDomain } from "./handle-domain";
|
||||||
|
import type { ValidationStates } from "./show-domains";
|
||||||
|
|
||||||
|
export type Domain =
|
||||||
|
| RouterOutputs["domain"]["byApplicationId"][0]
|
||||||
|
| RouterOutputs["domain"]["byComposeId"][0];
|
||||||
|
|
||||||
|
interface ColumnsProps {
|
||||||
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
|
validationStates: ValidationStates;
|
||||||
|
handleValidateDomain: (host: string) => Promise<void>;
|
||||||
|
handleDeleteDomain: (domainId: string) => Promise<void>;
|
||||||
|
isDeleting: boolean;
|
||||||
|
serverIp?: string;
|
||||||
|
canCreateDomain: boolean;
|
||||||
|
canDeleteDomain: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createColumns = ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
validationStates,
|
||||||
|
handleValidateDomain,
|
||||||
|
handleDeleteDomain,
|
||||||
|
isDeleting,
|
||||||
|
serverIp,
|
||||||
|
canCreateDomain,
|
||||||
|
canDeleteDomain,
|
||||||
|
}: ColumnsProps): ColumnDef<Domain>[] => [
|
||||||
|
...(type === "compose"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
accessorKey: "serviceName",
|
||||||
|
header: "Service",
|
||||||
|
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
|
||||||
|
const serviceName = row.getValue("serviceName") as string | null;
|
||||||
|
if (!serviceName) return null;
|
||||||
|
return (
|
||||||
|
<Badge variant="outline">
|
||||||
|
<Server className="size-3 mr-1" />
|
||||||
|
{serviceName}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} satisfies ColumnDef<Domain>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
accessorKey: "host",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Host
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const domain = row.original;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className="flex items-center gap-2 font-medium hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||||
|
>
|
||||||
|
{domain.host}
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "path",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Path
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const path = row.getValue("path") as string;
|
||||||
|
return <div className="font-mono text-sm">{path || "/"}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "port",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Port
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const port = row.getValue("port") as number;
|
||||||
|
return <Badge variant="secondary">{port}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "customEntrypoint",
|
||||||
|
header: "Entrypoint",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const entrypoint = row.getValue("customEntrypoint") as string | null;
|
||||||
|
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
|
||||||
|
return <div className="font-mono text-sm">{entrypoint}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "https",
|
||||||
|
header: "Protocol",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const https = row.getValue("https") as boolean;
|
||||||
|
return (
|
||||||
|
<Badge variant={https ? "outline" : "secondary"}>
|
||||||
|
{https ? "HTTPS" : "HTTP"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "certificate",
|
||||||
|
header: "Certificate",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const domain = row.original;
|
||||||
|
const validationState = validationStates[domain.host];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{domain.certificateType && (
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{domain.certificateType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!domain.host.includes("sslip.io") && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
validationState?.isValid
|
||||||
|
? "bg-green-500/10 text-green-500 cursor-pointer"
|
||||||
|
: validationState?.error
|
||||||
|
? "bg-red-500/10 text-red-500 cursor-pointer"
|
||||||
|
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
||||||
|
}
|
||||||
|
onClick={() => handleValidateDomain(domain.host)}
|
||||||
|
>
|
||||||
|
{validationState?.isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3 mr-1 animate-spin" />
|
||||||
|
Checking...
|
||||||
|
</>
|
||||||
|
) : validationState?.isValid ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="size-3 mr-1" />
|
||||||
|
{validationState.message && validationState.cdnProvider
|
||||||
|
? `${validationState.cdnProvider}`
|
||||||
|
: "Valid"}
|
||||||
|
</>
|
||||||
|
) : validationState?.error ? (
|
||||||
|
<>
|
||||||
|
<XCircle className="size-3 mr-1" />
|
||||||
|
Invalid
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="size-3 mr-1" />
|
||||||
|
Validate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
{validationState?.error ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium text-red-500">Error:</p>
|
||||||
|
<p>{validationState.error}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Click to validate DNS configuration"
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Created
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const createdAt = row.getValue("createdAt") as string;
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{new Date(createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const domain = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!domain.host.includes("sslip.io") && (
|
||||||
|
<DnsHelperModal
|
||||||
|
domain={{
|
||||||
|
host: domain.host,
|
||||||
|
https: domain.https,
|
||||||
|
path: domain.path || undefined,
|
||||||
|
}}
|
||||||
|
serverIp={serverIp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canCreateDomain && (
|
||||||
|
<AddDomain id={id} type={type} domainId={domain.domainId}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10 h-8 w-8"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
</AddDomain>
|
||||||
|
)}
|
||||||
|
{canDeleteDomain && (
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Domain"
|
||||||
|
description="Are you sure you want to delete this domain?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await handleDeleteDomain(domain.domainId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10 h-8 w-8"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, 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 { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -68,6 +69,7 @@ export const domain = z
|
|||||||
customCertResolver: z.string().optional(),
|
customCertResolver: z.string().optional(),
|
||||||
serviceName: z.string().optional(),
|
serviceName: z.string().optional(),
|
||||||
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
||||||
|
middlewares: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((input, ctx) => {
|
.superRefine((input, ctx) => {
|
||||||
if (input.https && !input.certificateType) {
|
if (input.https && !input.certificateType) {
|
||||||
@@ -213,6 +215,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
serviceName: undefined,
|
serviceName: undefined,
|
||||||
domainType: type,
|
domainType: type,
|
||||||
|
middlewares: [],
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
@@ -222,7 +225,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domainType = form.watch("domainType");
|
const domainType = form.watch("domainType");
|
||||||
const host = form.watch("host");
|
const host = form.watch("host");
|
||||||
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
const isTraefikMeDomain = host?.includes("sslip.io") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -239,6 +242,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
customCertResolver: data?.customCertResolver || undefined,
|
customCertResolver: data?.customCertResolver || undefined,
|
||||||
serviceName: data?.serviceName || undefined,
|
serviceName: data?.serviceName || undefined,
|
||||||
domainType: data?.domainType || type,
|
domainType: data?.domainType || type,
|
||||||
|
middlewares: data?.middlewares || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +259,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
domainType: type,
|
domainType: type,
|
||||||
|
middlewares: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data, isPending, domainId]);
|
}, [form, data, isPending, domainId]);
|
||||||
@@ -285,6 +290,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
composeId: id,
|
composeId: id,
|
||||||
}),
|
}),
|
||||||
...data,
|
...data,
|
||||||
|
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(dictionary.success);
|
toast.success(dictionary.success);
|
||||||
@@ -507,7 +513,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
{!canGenerateTraefikMeDomains &&
|
{!canGenerateTraefikMeDomains &&
|
||||||
field.value.includes("traefik.me") && (
|
field.value.includes("sslip.io") && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to set an IP address in your{" "}
|
You need to set an IP address in your{" "}
|
||||||
<Link
|
<Link
|
||||||
@@ -518,12 +524,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
: "Web Server -> Server -> Update Server IP"}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to make your traefik.me domain work.
|
to make your sslip.io domain work.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> traefik.me is a public HTTP
|
<strong>Note:</strong> sslip.io is a public HTTP
|
||||||
service and does not support SSL/HTTPS. HTTPS and
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
certificate options will not have any effect.
|
certificate options will not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -561,7 +567,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-[10rem]"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>Generate traefik.me domain</p>
|
<p>Generate sslip.io domain</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -660,7 +666,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Custom Entrypoint</FormLabel>
|
<FormLabel>Custom Entrypoint</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Use custom entrypoint for domina
|
Use custom entrypoint for domain
|
||||||
<br />
|
<br />
|
||||||
"web" and/or "websecure" is used by default.
|
"web" and/or "websecure" is used by default.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
@@ -669,7 +675,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<Switch
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(checked);
|
||||||
|
if (!checked) {
|
||||||
|
form.setValue("customEntrypoint", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -786,6 +797,88 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="middlewares"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel>Middlewares</FormLabel>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[300px]">
|
||||||
|
<p>
|
||||||
|
Add Traefik middleware references. Middlewares
|
||||||
|
must be defined in your Traefik configuration.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{field.value?.map((name, index) => (
|
||||||
|
<Badge key={index} variant="secondary">
|
||||||
|
{name}
|
||||||
|
<X
|
||||||
|
className="ml-1 size-3 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const newMiddlewares = [...(field.value || [])];
|
||||||
|
newMiddlewares.splice(index, 1);
|
||||||
|
form.setValue("middlewares", newMiddlewares);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., rate-limit@file, auth@file"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value && !field.value?.includes(value)) {
|
||||||
|
form.setValue("middlewares", [
|
||||||
|
...(field.value || []),
|
||||||
|
value,
|
||||||
|
]);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.querySelector(
|
||||||
|
'input[placeholder="e.g., rate-limit@file, auth@file"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value && !field.value?.includes(value)) {
|
||||||
|
form.setValue("middlewares", [
|
||||||
|
...(field.value || []),
|
||||||
|
value,
|
||||||
|
]);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
|
import {
|
||||||
|
type ColumnFiltersState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type SortingState,
|
||||||
|
useReactTable,
|
||||||
|
type VisibilityState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
|
LayoutGrid,
|
||||||
|
LayoutList,
|
||||||
Loader2,
|
Loader2,
|
||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -23,6 +37,21 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -30,6 +59,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { createColumns } from "./columns";
|
||||||
import { DnsHelperModal } from "./dns-helper-modal";
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
import { AddDomain } from "./handle-domain";
|
import { AddDomain } from "./handle-domain";
|
||||||
|
|
||||||
@@ -74,6 +104,19 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return (
|
||||||
|
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
|
||||||
|
"grid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "grid";
|
||||||
|
});
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
|
const [rowSelection, setRowSelection] = useState({});
|
||||||
const { data: ip } = api.settings.getIp.useQuery();
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -103,6 +146,16 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
api.domain.delete.useMutation();
|
||||||
|
|
||||||
|
const handleDeleteDomain = async (domainId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteDomain({ domainId });
|
||||||
|
refetch();
|
||||||
|
toast.success("Domain deleted successfully");
|
||||||
|
} catch {
|
||||||
|
toast.error("Error deleting domain");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleValidateDomain = async (host: string) => {
|
const handleValidateDomain = async (host: string) => {
|
||||||
setValidationStates((prev) => ({
|
setValidationStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -140,6 +193,37 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const columns = createColumns({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
validationStates,
|
||||||
|
handleValidateDomain,
|
||||||
|
handleDeleteDomain,
|
||||||
|
isDeleting: isRemoving,
|
||||||
|
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
||||||
|
canCreateDomain,
|
||||||
|
canDeleteDomain,
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: data ?? [],
|
||||||
|
columns,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
@@ -151,13 +235,32 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-2 flex-wrap">
|
||||||
{canCreateDomain && data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<AddDomain id={id} type={type}>
|
<>
|
||||||
<Button>
|
<Button
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
const next = viewMode === "grid" ? "table" : "grid";
|
||||||
|
localStorage.setItem("domains-view-mode", next);
|
||||||
|
setViewMode(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{viewMode === "grid" ? (
|
||||||
|
<LayoutList className="size-4" />
|
||||||
|
) : (
|
||||||
|
<LayoutGrid className="size-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</AddDomain>
|
{canCreateDomain && (
|
||||||
|
<AddDomain id={id} type={type}>
|
||||||
|
<Button>
|
||||||
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
|
</Button>
|
||||||
|
</AddDomain>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -186,6 +289,122 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : viewMode === "table" ? (
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter by host..."
|
||||||
|
value={
|
||||||
|
(table.getColumn("host")?.getFilterValue() as string) ?? ""
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
table.getColumn("host")?.setFilterValue(event.target.value)
|
||||||
|
}
|
||||||
|
className="md:max-w-sm"
|
||||||
|
/>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="sm:ml-auto max-sm:w-full"
|
||||||
|
>
|
||||||
|
Columns <ChevronDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
className="capitalize"
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
column.toggleVisibility(!!value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table?.getRowModel()?.rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{data && data?.length > 0 && (
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<div className="space-x-2 flex flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||||
{data?.map((item) => {
|
{data?.map((item) => {
|
||||||
@@ -206,7 +425,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{!item.host.includes("traefik.me") && (
|
{!item.host.includes("sslip.io") && (
|
||||||
<DnsHelperModal
|
<DnsHelperModal
|
||||||
domain={{
|
domain={{
|
||||||
host: item.host,
|
host: item.host,
|
||||||
@@ -341,6 +560,22 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{item.middlewares?.map((middleware, index) => (
|
||||||
|
<TooltipProvider key={`${middleware}-${index}`}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<InfoIcon className="size-3 mr-1" />
|
||||||
|
Middleware: {middleware}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Traefik middleware reference</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
))}
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -56,17 +56,17 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
compose: () => api.compose.update.useMutation(),
|
compose: () => api.compose.saveEnvironment.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
libsql: () => api.libsql.saveEnvironment.useMutation(),
|
||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.saveEnvironment.useMutation(),
|
||||||
mysql: () => api.mysql.update.useMutation(),
|
mysql: () => api.mysql.saveEnvironment.useMutation(),
|
||||||
postgres: () => api.postgres.update.useMutation(),
|
postgres: () => api.postgres.saveEnvironment.useMutation(),
|
||||||
redis: () => api.redis.update.useMutation(),
|
redis: () => api.redis.saveEnvironment.useMutation(),
|
||||||
};
|
};
|
||||||
const { mutateAsync, isPending } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.update.useMutation();
|
: api.mongo.saveEnvironment.useMutation();
|
||||||
|
|
||||||
const form = useForm<EnvironmentSchema>({
|
const form = useForm<EnvironmentSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -116,7 +116,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -57,7 +58,10 @@ const BitbucketProviderSchema = z.object({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -41,7 +42,10 @@ const GitProviderSchema = z.object({
|
|||||||
repositoryURL: z.string().min(1, {
|
repositoryURL: z.string().min(1, {
|
||||||
message: "Repository URL is required",
|
message: "Repository URL is required",
|
||||||
}),
|
}),
|
||||||
branch: z.string().min(1, "Branch required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -55,7 +59,7 @@ interface Props {
|
|||||||
|
|
||||||
export const SaveGitProvider = ({ applicationId }: Props) => {
|
export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isPending } =
|
const { mutateAsync, isPending } =
|
||||||
@@ -107,110 +111,103 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start">
|
||||||
className="flex flex-col gap-4"
|
<FormField
|
||||||
>
|
control={form.control}
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
name="repositoryURL"
|
||||||
<div className="flex items-end col-span-2 gap-4">
|
render={({ field }) => (
|
||||||
<div className="grow">
|
<FormItem className="col-span-2 lg:col-span-3">
|
||||||
<FormField
|
<div className="flex items-center justify-between h-5">
|
||||||
control={form.control}
|
<FormLabel>Repository URL</FormLabel>
|
||||||
name="repositoryURL"
|
{field.value?.startsWith("https://") && (
|
||||||
render={({ field }) => (
|
<Link
|
||||||
<FormItem>
|
href={field.value}
|
||||||
<div className="flex items-center justify-between">
|
target="_blank"
|
||||||
<FormLabel>Repository URL</FormLabel>
|
rel="noopener noreferrer"
|
||||||
{field.value?.startsWith("https://") && (
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
<Link
|
>
|
||||||
href={field.value}
|
<GitIcon className="h-4 w-4" />
|
||||||
target="_blank"
|
<span>View Repository</span>
|
||||||
rel="noopener noreferrer"
|
</Link>
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
)}
|
||||||
>
|
</div>
|
||||||
<GitIcon className="h-4 w-4" />
|
<FormControl>
|
||||||
<span>View Repository</span>
|
<Input placeholder="Repository URL" {...field} />
|
||||||
</Link>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
</div>
|
</FormItem>
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Repository URL" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{sshKeys && sshKeys.length > 0 ? (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="sshKey"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="basis-40">
|
|
||||||
<FormLabel className="w-full inline-flex justify-between">
|
|
||||||
SSH Key
|
|
||||||
<LockIcon className="size-4 text-muted-foreground" />
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
key={field.value}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a key" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{sshKeys?.map((sshKey) => (
|
|
||||||
<SelectItem
|
|
||||||
key={sshKey.sshKeyId}
|
|
||||||
value={sshKey.sshKeyId}
|
|
||||||
>
|
|
||||||
{sshKey.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="none">None</SelectItem>
|
|
||||||
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<KeyRoundIcon className="size-4" /> Add SSH Key
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
<div className="space-y-4">
|
{sshKeys && sshKeys.length > 0 ? (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="branch"
|
name="sshKey"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className="col-span-2 lg:col-span-1">
|
||||||
<FormLabel>Branch</FormLabel>
|
<FormLabel className="w-full inline-flex justify-between">
|
||||||
|
SSH Key
|
||||||
|
<LockIcon className="size-4 text-muted-foreground" />
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Branch" {...field} />
|
<Select
|
||||||
|
key={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a key" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{sshKeys?.map((sshKey) => (
|
||||||
|
<SelectItem
|
||||||
|
key={sshKey.sshKeyId}
|
||||||
|
value={sshKey.sshKeyId}
|
||||||
|
>
|
||||||
|
{sshKey.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
||||||
|
type="button"
|
||||||
|
className="col-span-2 lg:col-span-1 lg:mt-7"
|
||||||
|
>
|
||||||
|
<KeyRoundIcon className="size-4" /> Add SSH Key
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="branch"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>Branch</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Branch" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="buildPath"
|
name="buildPath"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className="col-span-2">
|
||||||
<FormLabel>Build Path</FormLabel>
|
<FormLabel>Build Path</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="/" {...field} />
|
<Input placeholder="/" {...field} />
|
||||||
@@ -223,7 +220,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="watchPaths"
|
name="watchPaths"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="col-span-2 lg:col-span-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { GiteaIcon } from "@/components/icons/data-tools-icons";
|
import { GiteaIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -72,7 +73,10 @@ const GiteaProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).default([]),
|
watchPaths: z.array(z.string()).default([]),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -55,7 +56,10 @@ const GithubProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo } 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 { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -58,7 +59,10 @@ const GitlabProviderSchema = z.object({
|
|||||||
id: z.number().nullable(),
|
id: z.number().nullable(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
<CardContent className="grid grid-cols-2 lg:flex lg:flex-row lg:flex-wrap gap-4">
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||||
{canDeploy && (
|
{canDeploy && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
@@ -274,14 +274,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2 col-span-2"
|
||||||
>
|
>
|
||||||
<Terminal className="size-4 mr-1" />
|
<Terminal className="size-4 mr-1" />
|
||||||
Open Terminal
|
Open Terminal
|
||||||
</Button>
|
</Button>
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
{canUpdateService && (
|
{canUpdateService && (
|
||||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
|
||||||
<span className="text-sm font-medium">Autodeploy</span>
|
<span className="text-sm font-medium">Autodeploy</span>
|
||||||
<Switch
|
<Switch
|
||||||
aria-label="Toggle autodeploy"
|
aria-label="Toggle autodeploy"
|
||||||
@@ -305,7 +305,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{canUpdateService && (
|
{canUpdateService && (
|
||||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
|
||||||
<span className="text-sm font-medium">Clean Cache</span>
|
<span className="text-sm font-medium">Clean Cache</span>
|
||||||
<Switch
|
<Switch
|
||||||
aria-label="Toggle clean cache"
|
aria-label="Toggle clean cache"
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Dropzone } from "@/components/ui/dropzone";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface ShowIconSettingsProps {
|
||||||
|
applicationId: string;
|
||||||
|
icon?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgToDataUrl = (icon: BundledIcon): string => {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#${icon.hex}"><path d="${icon.path}"/></svg>`;
|
||||||
|
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowIconSettings = ({
|
||||||
|
applicationId,
|
||||||
|
icon,
|
||||||
|
}: ShowIconSettingsProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [iconSearchQuery, setIconSearchQuery] = useState("");
|
||||||
|
const [iconsToShow, setIconsToShow] = useState(24);
|
||||||
|
|
||||||
|
const filteredIcons = useMemo(() => {
|
||||||
|
if (!iconSearchQuery) return bundledIcons;
|
||||||
|
const q = iconSearchQuery.toLowerCase();
|
||||||
|
return bundledIcons.filter(
|
||||||
|
(i) =>
|
||||||
|
i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [iconSearchQuery]);
|
||||||
|
|
||||||
|
const displayedIcons = filteredIcons.slice(0, iconsToShow);
|
||||||
|
const hasMoreIcons = filteredIcons.length > iconsToShow;
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { mutateAsync: updateApplication } =
|
||||||
|
api.application.update.useMutation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setIconSearchQuery("");
|
||||||
|
setIconsToShow(24);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleIconSelect = async (selectedIcon: BundledIcon) => {
|
||||||
|
try {
|
||||||
|
const dataUrl = svgToDataUrl(selectedIcon);
|
||||||
|
await updateApplication({
|
||||||
|
applicationId,
|
||||||
|
icon: dataUrl,
|
||||||
|
});
|
||||||
|
toast.success("Icon saved successfully");
|
||||||
|
await utils.application.one.invalidate({ applicationId });
|
||||||
|
setOpen(false);
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Error saving icon");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveIcon = async () => {
|
||||||
|
try {
|
||||||
|
await updateApplication({
|
||||||
|
applicationId,
|
||||||
|
icon: null,
|
||||||
|
});
|
||||||
|
toast.success("Icon removed");
|
||||||
|
await utils.application.one.invalidate({ applicationId });
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Error removing icon");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const allowedTypes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/svg+xml",
|
||||||
|
];
|
||||||
|
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
const allowedExtensions = ["jpg", "jpeg", "png", "svg"];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!allowedTypes.includes(file.type) &&
|
||||||
|
!allowedExtensions.includes(fileExtension || "")
|
||||||
|
) {
|
||||||
|
toast.error("Only JPG, JPEG, PNG, and SVG files are allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
toast.error("Image size must be less than 2MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
const text = await file.text();
|
||||||
|
const sanitizedDataUrl = sanitizeSvg(text);
|
||||||
|
if (!sanitizedDataUrl) {
|
||||||
|
toast.error("Invalid SVG file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateApplication({
|
||||||
|
applicationId,
|
||||||
|
icon: sanitizedDataUrl,
|
||||||
|
});
|
||||||
|
toast.success("Icon saved!");
|
||||||
|
await utils.application.one.invalidate({ applicationId });
|
||||||
|
setOpen(false);
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Error saving icon");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
const result = event.target?.result as string;
|
||||||
|
try {
|
||||||
|
await updateApplication({
|
||||||
|
applicationId,
|
||||||
|
icon: result,
|
||||||
|
});
|
||||||
|
toast.success("Icon saved!");
|
||||||
|
await utils.application.one.invalidate({ applicationId });
|
||||||
|
setOpen(false);
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Error saving icon");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative group flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
|
||||||
|
<img
|
||||||
|
src={icon}
|
||||||
|
alt="Application icon"
|
||||||
|
className="h-8 w-8 object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Pencil className="h-3 w-3 text-white" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center justify-between">
|
||||||
|
Change Icon
|
||||||
|
{icon && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveIcon}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4 mr-1" />
|
||||||
|
Remove icon
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search icons (e.g. react, vue, docker)..."
|
||||||
|
value={iconSearchQuery}
|
||||||
|
onChange={(e) => setIconSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[300px] overflow-y-auto border rounded-lg p-4">
|
||||||
|
{displayedIcons.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
No icons found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||||
|
{displayedIcons.map((i) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={i.slug}
|
||||||
|
onClick={() => handleIconSelect(i)}
|
||||||
|
className="flex flex-col items-center gap-1.5 p-2 rounded-lg border hover:border-primary hover:bg-muted transition-colors group"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="size-7 group-hover:scale-110 transition-transform"
|
||||||
|
fill={`#${i.hex}`}
|
||||||
|
>
|
||||||
|
<path d={i.path} />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[10px] text-muted-foreground capitalize truncate w-full text-center">
|
||||||
|
{i.title}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasMoreIcons && (
|
||||||
|
<div className="flex justify-center mt-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIconsToShow((prev) => prev + 24)}
|
||||||
|
>
|
||||||
|
Load More ({filteredIcons.length - iconsToShow} remaining)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative pt-3 border-t">
|
||||||
|
<p className="text-sm text-muted-foreground text-center mb-3">
|
||||||
|
or upload a custom icon
|
||||||
|
</p>
|
||||||
|
<Dropzone
|
||||||
|
dropMessage="Drag & drop an icon or click to upload"
|
||||||
|
accept=".jpg,.jpeg,.png,.svg,image/jpeg,image/png,image/svg+xml"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 text-center text-xs text-muted-foreground">
|
||||||
|
Supported formats: JPG, JPEG, PNG, SVG (max 2MB)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -87,7 +87,7 @@ export const AddPreviewDomain = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const host = form.watch("host");
|
const host = form.watch("host");
|
||||||
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
const isTraefikMeDomain = host?.includes("sslip.io") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -162,7 +162,7 @@ export const AddPreviewDomain = ({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> traefik.me is a public HTTP
|
<strong>Note:</strong> sslip.io is a public HTTP
|
||||||
service and does not support SSL/HTTPS. HTTPS and
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
certificate options will not have any effect.
|
certificate options will not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -202,7 +202,7 @@ export const AddPreviewDomain = ({
|
|||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-[10rem]"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>Generate traefik.me domain</p>
|
<p>Generate sslip.io domain</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
env: "",
|
env: "",
|
||||||
wildcardDomain: "*.traefik.me",
|
wildcardDomain: "*.sslip.io",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
previewLimit: 3,
|
previewLimit: 3,
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
@@ -102,7 +102,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
const previewHttps = form.watch("previewHttps");
|
const previewHttps = form.watch("previewHttps");
|
||||||
const wildcardDomain = form.watch("wildcardDomain");
|
const wildcardDomain = form.watch("wildcardDomain");
|
||||||
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
|
const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||||
@@ -114,7 +114,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
env: data.previewEnv || "",
|
env: data.previewEnv || "",
|
||||||
buildArgs: data.previewBuildArgs || "",
|
buildArgs: data.previewBuildArgs || "",
|
||||||
buildSecrets: data.previewBuildSecrets || "",
|
buildSecrets: data.previewBuildSecrets || "",
|
||||||
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
wildcardDomain: data.previewWildcard || "*.sslip.io",
|
||||||
port: data.previewPort || 3000,
|
port: data.previewPort || 3000,
|
||||||
previewLabels: data.previewLabels || [],
|
previewLabels: data.previewLabels || [],
|
||||||
previewLimit: data.previewLimit || 3,
|
previewLimit: data.previewLimit || 3,
|
||||||
@@ -173,7 +173,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> traefik.me is a public HTTP service and
|
<strong>Note:</strong> sslip.io is a public HTTP service and
|
||||||
does not support SSL/HTTPS. HTTPS and certificate options will
|
does not support SSL/HTTPS. HTTPS and certificate options will
|
||||||
not have any effect.
|
not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -192,7 +192,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Wildcard Domain</FormLabel>
|
<FormLabel>Wildcard Domain</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="*.traefik.me" {...field} />
|
<Input placeholder="*.sslip.io" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const commonCronExpressions = [
|
|||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||||
shellType: z.enum(["bash", "sh"]).default("bash"),
|
shellType: z.enum(["bash", "sh"]).default("bash"),
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
@@ -224,6 +225,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
resolver: standardSchemaResolver(formSchema),
|
resolver: standardSchemaResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
|
description: "",
|
||||||
cronExpression: "",
|
cronExpression: "",
|
||||||
shellType: "bash",
|
shellType: "bash",
|
||||||
command: "",
|
command: "",
|
||||||
@@ -263,6 +265,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
if (scheduleId && schedule) {
|
if (scheduleId && schedule) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: schedule.name,
|
name: schedule.name,
|
||||||
|
description: schedule.description || "",
|
||||||
cronExpression: schedule.cronExpression,
|
cronExpression: schedule.cronExpression,
|
||||||
shellType: schedule.shellType,
|
shellType: schedule.shellType,
|
||||||
command: schedule.command,
|
command: schedule.command,
|
||||||
@@ -479,6 +482,26 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Backs up the database every day at midnight"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Optional description of what this schedule does
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ScheduleFormField
|
<ScheduleFormField
|
||||||
name="cronExpression"
|
name="cronExpression"
|
||||||
formControl={form.control}
|
formControl={form.control}
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
{schedule.enabled ? "Enabled" : "Disabled"}
|
{schedule.enabled ? "Enabled" : "Disabled"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
{schedule.description && (
|
||||||
|
<p className="text-xs text-muted-foreground/70 [overflow-wrap:anywhere] line-clamp-2">
|
||||||
|
{schedule.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
|
||||||
|
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
|
||||||
|
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
|
||||||
|
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const DockerLogsId = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/dashboard/docker/logs/docker-logs-id").then(
|
||||||
|
(e) => e.DockerLogsId,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appName: string;
|
||||||
|
serverId?: string;
|
||||||
|
appType: "stack" | "docker-compose";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowComposeContainers = ({
|
||||||
|
appName,
|
||||||
|
appType,
|
||||||
|
serverId,
|
||||||
|
}: Props) => {
|
||||||
|
const { data, isPending, refetch } =
|
||||||
|
api.docker.getContainersByAppNameMatch.useQuery(
|
||||||
|
{
|
||||||
|
appName,
|
||||||
|
appType,
|
||||||
|
serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!appName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl">Containers</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Inspect each container in this compose and run basic lifecycle
|
||||||
|
actions.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isPending ? (
|
||||||
|
<div className="flex items-center justify-center h-[20vh]">
|
||||||
|
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : !data || data.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-[20vh]">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
No containers found. Deploy the compose to see containers here.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Container ID</TableHead>
|
||||||
|
<TableHead className="text-right" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((container) => (
|
||||||
|
<ContainerRow
|
||||||
|
key={container.containerId}
|
||||||
|
container={container}
|
||||||
|
serverId={serverId}
|
||||||
|
onActionComplete={() => refetch()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ContainerRowProps {
|
||||||
|
container: {
|
||||||
|
containerId: string;
|
||||||
|
name: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
serverId?: string;
|
||||||
|
onActionComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContainerRow = ({
|
||||||
|
container,
|
||||||
|
serverId,
|
||||||
|
onActionComplete,
|
||||||
|
}: ContainerRowProps) => {
|
||||||
|
const [logsOpen, setLogsOpen] = useState(false);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const restartMutation = api.docker.restartContainer.useMutation();
|
||||||
|
const startMutation = api.docker.startContainer.useMutation();
|
||||||
|
const stopMutation = api.docker.stopContainer.useMutation();
|
||||||
|
const killMutation = api.docker.killContainer.useMutation();
|
||||||
|
|
||||||
|
const handleAction = async (
|
||||||
|
action: string,
|
||||||
|
mutationFn: typeof restartMutation,
|
||||||
|
) => {
|
||||||
|
setActionLoading(action);
|
||||||
|
try {
|
||||||
|
await mutationFn.mutateAsync({
|
||||||
|
containerId: container.containerId,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
toast.success(`Container ${action} successfully`);
|
||||||
|
onActionComplete();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{container.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
container.state === "running"
|
||||||
|
? "default"
|
||||||
|
: container.state === "exited"
|
||||||
|
? "secondary"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{container.state}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{container.status}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm text-muted-foreground">
|
||||||
|
{container.containerId}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
{actionLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
View Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<ShowContainerConfig
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
/>
|
||||||
|
<ShowContainerMounts
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
/>
|
||||||
|
<ShowContainerNetworks
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
/>
|
||||||
|
<DockerTerminalModal
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
>
|
||||||
|
Terminal
|
||||||
|
</DockerTerminalModal>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={() => handleAction("restart", restartMutation)}
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={() => handleAction("start", startMutation)}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={() => handleAction("stop", stopMutation)}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer text-red-500 focus:text-red-600"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={() => handleAction("kill", killMutation)}
|
||||||
|
>
|
||||||
|
Kill
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DialogContent className="sm:max-w-7xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>View Logs</DialogTitle>
|
||||||
|
<DialogDescription>Logs for {container.name}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<DockerLogsId
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId}
|
||||||
|
runType="native"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -49,12 +49,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
const composeFile = form.watch("composeFile");
|
const composeFile = form.watch("composeFile");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && !composeFile) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
composeFile: data.composeFile || "",
|
composeFile: data.composeFile || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.composeFile !== undefined) {
|
if (data?.composeFile !== undefined) {
|
||||||
@@ -95,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -57,7 +58,10 @@ const BitbucketProviderSchema = z.object({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -41,7 +42,10 @@ const GitProviderSchema = z.object({
|
|||||||
repositoryURL: z.string().min(1, {
|
repositoryURL: z.string().min(1, {
|
||||||
message: "Repository URL is required",
|
message: "Repository URL is required",
|
||||||
}),
|
}),
|
||||||
branch: z.string().min(1, "Branch required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -55,7 +59,7 @@ interface Props {
|
|||||||
|
|
||||||
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { GiteaIcon } from "@/components/icons/data-tools-icons";
|
import { GiteaIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -57,7 +58,10 @@ const GiteaProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -409,10 +413,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -55,7 +56,10 @@ const GithubProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo } 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 { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -58,7 +59,10 @@ const GitlabProviderSchema = z.object({
|
|||||||
gitlabPathNamespace: z.string().min(1),
|
gitlabPathNamespace: z.string().min(1),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -288,7 +288,6 @@ export const RestoreBackup = ({
|
|||||||
toast.error("Please select a database type");
|
toast.error("Please select a database type");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log({ data });
|
|
||||||
setIsDeploying(true);
|
setIsDeploying(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
220
apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx
Normal file
220
apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Loader2,
|
||||||
|
RotateCcw,
|
||||||
|
Settings,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import type { LogLine } from "./utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logs: LogLine[];
|
||||||
|
context: "build" | "runtime";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LOG_LINES = 200;
|
||||||
|
|
||||||
|
export function AnalyzeLogs({ logs, context }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [aiId, setAiId] = useState<string>("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Analysis failed", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAnalyze = () => {
|
||||||
|
if (!aiId || logs.length === 0) return;
|
||||||
|
|
||||||
|
const logsText = logs
|
||||||
|
.slice(-MAX_LOG_LINES)
|
||||||
|
.map((l) => l.message)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
mutate({ aiId, logs: logsText, context });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (!data?.analysis) return;
|
||||||
|
const success = copy(data.analysis);
|
||||||
|
if (success) {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
setOpen(isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
reset();
|
||||||
|
setAiId("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9"
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
title="Analyze logs with AI"
|
||||||
|
>
|
||||||
|
<Bot className="mr-2 size-4" />
|
||||||
|
AI
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[550px] p-0" align="end">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">Log Analysis</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{!data?.analysis ? (
|
||||||
|
providers && providers.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-2 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No AI providers configured. Set up a provider to start
|
||||||
|
analyzing logs.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link href="/dashboard/settings/ai">
|
||||||
|
<Settings className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Configure AI Provider
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Select value={aiId} onValueChange={setAiId}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select AI provider..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{providers?.map((p) => (
|
||||||
|
<SelectItem key={p.aiId} value={p.aiId}>
|
||||||
|
{p.name} ({p.model})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!aiId || isPending || logs.length === 0}
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
Analyzing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Bot className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Analyze{" "}
|
||||||
|
{logs.length > MAX_LOG_LINES
|
||||||
|
? `last ${MAX_LOG_LINES}`
|
||||||
|
: logs.length}{" "}
|
||||||
|
lines
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="max-h-[400px] overflow-y-auto">
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none text-sm break-words">
|
||||||
|
<ReactMarkdown>{data.analysis}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
reset();
|
||||||
|
handleAnalyze();
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Re-analyze
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCopy}
|
||||||
|
title="Copy analysis to clipboard"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
reset();
|
||||||
|
setAiId("");
|
||||||
|
}}
|
||||||
|
title="Change provider"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { AlertBlock } from "@/components/shared/alert-block";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { AnalyzeLogs } from "./analyze-logs";
|
||||||
import { LineCountFilter } from "./line-count-filter";
|
import { LineCountFilter } from "./line-count-filter";
|
||||||
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
||||||
import { StatusLogsFilter } from "./status-logs-filter";
|
import { StatusLogsFilter } from "./status-logs-filter";
|
||||||
@@ -346,11 +347,13 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
title={isPaused ? "Resume logs" : "Pause logs"}
|
title={isPaused ? "Resume logs" : "Pause logs"}
|
||||||
>
|
>
|
||||||
{isPaused ? (
|
{isPaused ? (
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<Pause className="mr-2 h-4 w-4" />
|
<Pause className="size-4" />
|
||||||
)}
|
)}
|
||||||
{isPaused ? "Resume" : "Pause"}
|
<span className="hidden lg:ml-2 lg:inline">
|
||||||
|
{isPaused ? "Resume" : "Pause"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -361,11 +364,13 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
title="Copy logs to clipboard"
|
title="Copy logs to clipboard"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="mr-2 h-4 w-4" />
|
<Check className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="size-4" />
|
||||||
)}
|
)}
|
||||||
Copy
|
<span className="hidden lg:ml-2 lg:inline">
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -373,16 +378,18 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
className="h-9 sm:w-auto w-full"
|
className="h-9 sm:w-auto w-full"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={filteredLogs.length === 0 || !data?.Name}
|
disabled={filteredLogs.length === 0 || !data?.Name}
|
||||||
|
title="Download logs as text file"
|
||||||
>
|
>
|
||||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
<DownloadIcon className="size-4" />
|
||||||
Download logs
|
<span className="hidden lg:ml-2 lg:inline">Download logs</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isPaused && (
|
{isPaused && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning" className="items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pause className="h-4 w-4" />
|
<Pause className="size-4" />
|
||||||
<span>
|
<span>
|
||||||
Logs paused
|
Logs paused
|
||||||
{messageBuffer.length > 0 && (
|
{messageBuffer.length > 0 && (
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ export function parseLogs(logString: string): LogLine[] {
|
|||||||
|
|
||||||
// Detect log type based on message content
|
// Detect log type based on message content
|
||||||
export const getLogType = (message: string): LogStyle => {
|
export const getLogType = (message: string): LogStyle => {
|
||||||
|
// Detect HTTP statusCode
|
||||||
|
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
|
||||||
|
|
||||||
|
if (statusMatch) {
|
||||||
|
const statusCode = Number(statusMatch[1]);
|
||||||
|
|
||||||
|
if (statusCode >= 500) return LOG_STYLES.error;
|
||||||
|
if (statusCode >= 400) return LOG_STYLES.warning;
|
||||||
|
if (statusCode >= 200 && statusCode < 300) return LOG_STYLES.success;
|
||||||
|
return LOG_STYLES.info;
|
||||||
|
}
|
||||||
|
|
||||||
const lowerMessage = message.toLowerCase();
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
containerId: string;
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Mount {
|
||||||
|
Type: string;
|
||||||
|
Source: string;
|
||||||
|
Destination: string;
|
||||||
|
Mode: string;
|
||||||
|
RW: boolean;
|
||||||
|
Propagation: string;
|
||||||
|
Name?: string;
|
||||||
|
Driver?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowContainerMounts = ({ containerId, serverId }: Props) => {
|
||||||
|
const { data } = api.docker.getConfig.useQuery(
|
||||||
|
{
|
||||||
|
containerId,
|
||||||
|
serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!containerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mounts: Mount[] = data?.Mounts ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
View Mounts
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Container Mounts</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Volume and bind mounts for this container
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-auto max-h-[70vh]">
|
||||||
|
{mounts.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
No mounts found for this container.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Source</TableHead>
|
||||||
|
<TableHead>Destination</TableHead>
|
||||||
|
<TableHead>Mode</TableHead>
|
||||||
|
<TableHead>Read/Write</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{mounts.map((mount, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{mount.Type}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
||||||
|
{mount.Name || mount.Source}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
||||||
|
{mount.Destination}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{mount.Mode || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={mount.RW ? "default" : "secondary"}>
|
||||||
|
{mount.RW ? "RW" : "RO"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
containerId: string;
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Network {
|
||||||
|
IPAMConfig: unknown;
|
||||||
|
Links: unknown;
|
||||||
|
Aliases: string[] | null;
|
||||||
|
MacAddress: string;
|
||||||
|
NetworkID: string;
|
||||||
|
EndpointID: string;
|
||||||
|
Gateway: string;
|
||||||
|
IPAddress: string;
|
||||||
|
IPPrefixLen: number;
|
||||||
|
IPv6Gateway: string;
|
||||||
|
GlobalIPv6Address: string;
|
||||||
|
GlobalIPv6PrefixLen: number;
|
||||||
|
DriverOpts: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowContainerNetworks = ({ containerId, serverId }: Props) => {
|
||||||
|
const { data } = api.docker.getConfig.useQuery(
|
||||||
|
{
|
||||||
|
containerId,
|
||||||
|
serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!containerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const networks: Record<string, Network> =
|
||||||
|
data?.NetworkSettings?.Networks ?? {};
|
||||||
|
const entries = Object.entries(networks);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
View Networks
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Container Networks</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Networks attached to this container
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-auto max-h-[70vh]">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
No networks found for this container.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Network</TableHead>
|
||||||
|
<TableHead>IP Address</TableHead>
|
||||||
|
<TableHead>Gateway</TableHead>
|
||||||
|
<TableHead>MAC Address</TableHead>
|
||||||
|
<TableHead>Aliases</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{entries.map(([name, network]) => (
|
||||||
|
<TableRow key={name}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{name}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{network.IPAddress
|
||||||
|
? `${network.IPAddress}/${network.IPPrefixLen}`
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{network.Gateway || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{network.MacAddress || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{network.Aliases?.join(", ") || "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,8 +10,11 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ShowContainerConfig } from "../config/show-container-config";
|
import { ShowContainerConfig } from "../config/show-container-config";
|
||||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||||
|
import { ShowContainerMounts } from "../mounts/show-container-mounts";
|
||||||
|
import { ShowContainerNetworks } from "../networks/show-container-networks";
|
||||||
import { RemoveContainerDialog } from "../remove/remove-container";
|
import { RemoveContainerDialog } from "../remove/remove-container";
|
||||||
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
||||||
|
import { UploadFileModal } from "../upload/upload-file-modal";
|
||||||
import type { Container } from "./show-containers";
|
import type { Container } from "./show-containers";
|
||||||
|
|
||||||
export const columns: ColumnDef<Container>[] = [
|
export const columns: ColumnDef<Container>[] = [
|
||||||
@@ -122,12 +125,26 @@ export const columns: ColumnDef<Container>[] = [
|
|||||||
containerId={container.containerId}
|
containerId={container.containerId}
|
||||||
serverId={container.serverId || ""}
|
serverId={container.serverId || ""}
|
||||||
/>
|
/>
|
||||||
|
<ShowContainerMounts
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={container.serverId || ""}
|
||||||
|
/>
|
||||||
|
<ShowContainerNetworks
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={container.serverId || ""}
|
||||||
|
/>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
containerId={container.containerId}
|
containerId={container.containerId}
|
||||||
serverId={container.serverId || ""}
|
serverId={container.serverId || ""}
|
||||||
>
|
>
|
||||||
Terminal
|
Terminal
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
|
<UploadFileModal
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={container.serverId || undefined}
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</UploadFileModal>
|
||||||
<RemoveContainerDialog
|
<RemoveContainerDialog
|
||||||
containerId={container.containerId}
|
containerId={container.containerId}
|
||||||
serverId={container.serverId ?? undefined}
|
serverId={container.serverId ?? undefined}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { Upload } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Dropzone } from "@/components/ui/dropzone";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import {
|
||||||
|
type UploadFileToContainer,
|
||||||
|
uploadFileToContainerSchema,
|
||||||
|
} from "@/utils/schema";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
containerId: string;
|
||||||
|
serverId?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UploadFileModal = ({ children, containerId, serverId }: Props) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: uploadFile, isPending: isLoading } =
|
||||||
|
api.docker.uploadFileToContainer.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("File uploaded successfully");
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Failed to upload file to container");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(uploadFileToContainerSchema),
|
||||||
|
defaultValues: {
|
||||||
|
containerId,
|
||||||
|
destinationPath: "/",
|
||||||
|
serverId: serverId || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = form.watch("file");
|
||||||
|
|
||||||
|
const onSubmit = async (values: UploadFileToContainer) => {
|
||||||
|
if (!values.file) {
|
||||||
|
toast.error("Please select a file to upload");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("containerId", values.containerId);
|
||||||
|
formData.append("file", values.file);
|
||||||
|
formData.append("destinationPath", values.destinationPath);
|
||||||
|
if (values.serverId) {
|
||||||
|
formData.append("serverId", values.serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadFile(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer space-x-3"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Upload className="h-5 w-5" />
|
||||||
|
Upload File to Container
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload a file directly into the container's filesystem
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="destinationPath"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Destination Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="/path/to/file"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enter the full path where the file should be uploaded in the
|
||||||
|
container (e.g., /app/config.json)
|
||||||
|
</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="file"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>File</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Dropzone
|
||||||
|
{...field}
|
||||||
|
dropMessage="Drop file here or click to browse"
|
||||||
|
onChange={(files) => {
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
field.onChange(files[0]);
|
||||||
|
} else {
|
||||||
|
field.onChange(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
{file instanceof File && (
|
||||||
|
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
|
||||||
|
<span className="text-sm text-muted-foreground flex-1">
|
||||||
|
{file.name} ({(file.size / 1024).toFixed(2)} KB)
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => field.onChange(null)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={!file || isLoading}
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
291
apps/dokploy/components/dashboard/home/show-home.tsx
Normal file
291
apps/dokploy/components/dashboard/home/show-home.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { ArrowRight, Rocket, Server } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
type DeploymentStatus = "idle" | "running" | "done" | "error";
|
||||||
|
|
||||||
|
const statusDotClass: Record<string, string> = {
|
||||||
|
done: "bg-emerald-500",
|
||||||
|
running: "bg-amber-500",
|
||||||
|
error: "bg-red-500",
|
||||||
|
idle: "bg-muted-foreground/40",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getServiceInfo(d: any) {
|
||||||
|
const app = d.application;
|
||||||
|
const comp = d.compose;
|
||||||
|
const serverName: string =
|
||||||
|
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
|
||||||
|
if (app?.environment?.project && app.environment) {
|
||||||
|
return {
|
||||||
|
name: app.name as string,
|
||||||
|
environment: app.environment.name as string,
|
||||||
|
projectName: app.environment.project.name as string,
|
||||||
|
serverName,
|
||||||
|
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (comp?.environment?.project && comp.environment) {
|
||||||
|
return {
|
||||||
|
name: comp.name as string,
|
||||||
|
environment: comp.environment.name as string,
|
||||||
|
projectName: comp.environment.project.name as string,
|
||||||
|
serverName,
|
||||||
|
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
delta,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
delta?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
|
||||||
|
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-3xl font-semibold tracking-tight">{value}</span>
|
||||||
|
{delta && (
|
||||||
|
<span className="text-xs text-muted-foreground">{delta}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusListCard({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
items: { dotClass: string; label: string; count: number }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
|
||||||
|
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.label} className="flex items-center gap-2.5 text-sm">
|
||||||
|
<span
|
||||||
|
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="font-semibold tabular-nums w-8">{item.count}</span>
|
||||||
|
<span className="text-muted-foreground">{item.label}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowHome = () => {
|
||||||
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
const { data: homeStats } = api.project.homeStats.useQuery();
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canReadDeployments = !!permissions?.deployment.read;
|
||||||
|
const { data: deployments } = api.deployment.allCentralized.useQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
enabled: canReadDeployments,
|
||||||
|
refetchInterval: 10000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstName = auth?.user?.firstName?.trim();
|
||||||
|
|
||||||
|
const totals = homeStats ?? {
|
||||||
|
projects: 0,
|
||||||
|
environments: 0,
|
||||||
|
applications: 0,
|
||||||
|
compose: 0,
|
||||||
|
databases: 0,
|
||||||
|
services: 0,
|
||||||
|
};
|
||||||
|
const statusBreakdown = homeStats?.status ?? {
|
||||||
|
running: 0,
|
||||||
|
error: 0,
|
||||||
|
idle: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recentDeployments = useMemo(() => {
|
||||||
|
if (!deployments) return [];
|
||||||
|
return [...deployments]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
)
|
||||||
|
.slice(0, 10);
|
||||||
|
}, [deployments]);
|
||||||
|
|
||||||
|
const deployStats = useMemo(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const lastStart = now - weekMs;
|
||||||
|
const prevStart = now - 2 * weekMs;
|
||||||
|
|
||||||
|
const last: NonNullable<typeof deployments> = [];
|
||||||
|
const prev: NonNullable<typeof deployments> = [];
|
||||||
|
for (const d of deployments ?? []) {
|
||||||
|
const t = new Date(d.createdAt).getTime();
|
||||||
|
if (t >= lastStart) last.push(d);
|
||||||
|
else if (t >= prevStart) prev.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastCount = last.length;
|
||||||
|
const prevCount = prev.length;
|
||||||
|
let delta: string | undefined;
|
||||||
|
if (prevCount > 0) {
|
||||||
|
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
|
||||||
|
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
|
||||||
|
} else if (lastCount > 0) {
|
||||||
|
delta = "no prior data";
|
||||||
|
} else {
|
||||||
|
delta = "no activity yet";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: String(lastCount), delta };
|
||||||
|
}, [deployments]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[85vh]">
|
||||||
|
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6 h-full">
|
||||||
|
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">
|
||||||
|
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
|
||||||
|
</h1>
|
||||||
|
<Button asChild variant="secondary" className="w-fit">
|
||||||
|
<Link href="/dashboard/projects">
|
||||||
|
Go to projects
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="Projects"
|
||||||
|
value={String(totals.projects)}
|
||||||
|
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Services"
|
||||||
|
value={String(totals.services)}
|
||||||
|
delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Deploys / 7d"
|
||||||
|
value={deployStats.value}
|
||||||
|
delta={deployStats.delta}
|
||||||
|
/>
|
||||||
|
<StatusListCard
|
||||||
|
label="Status"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
dotClass: "bg-emerald-500",
|
||||||
|
label: "running",
|
||||||
|
count: statusBreakdown.running,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dotClass: "bg-red-500",
|
||||||
|
label: "errored",
|
||||||
|
count: statusBreakdown.error,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dotClass: "bg-muted-foreground/40",
|
||||||
|
label: "idle",
|
||||||
|
count: statusBreakdown.idle,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border bg-background">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Rocket className="size-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-semibold">Recent deployments</h2>
|
||||||
|
</div>
|
||||||
|
{canReadDeployments && (
|
||||||
|
<Link
|
||||||
|
href="/dashboard/deployments"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
view all →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!canReadDeployments ? (
|
||||||
|
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
|
||||||
|
<Rocket className="size-8 opacity-40" />
|
||||||
|
<span>You do not have permission to view deployments.</span>
|
||||||
|
</div>
|
||||||
|
) : recentDeployments.length === 0 ? (
|
||||||
|
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
|
||||||
|
<Rocket className="size-8 opacity-40" />
|
||||||
|
<span>No deployments yet.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{recentDeployments.map((d) => {
|
||||||
|
const info = getServiceInfo(d);
|
||||||
|
if (!info) return null;
|
||||||
|
const status = (d.status ?? "idle") as DeploymentStatus;
|
||||||
|
return (
|
||||||
|
<li key={d.deploymentId}>
|
||||||
|
<Link
|
||||||
|
href={info.href}
|
||||||
|
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="text-sm truncate">{info.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
{info.projectName} · {info.environment}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground w-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
|
||||||
|
<Server className="size-3 shrink-0" />
|
||||||
|
<span className="truncate">{info.serverName}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
|
||||||
|
{formatDistanceToNow(new Date(d.createdAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
logs →
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
|
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -9,6 +11,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||||
const { data } = api.mariadb.one.useQuery({ mariadbId });
|
const { data } = api.mariadb.one.useQuery({ mariadbId });
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { mutateAsync: changePassword } =
|
||||||
|
api.mariadb.changePassword.useMutation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -28,20 +33,43 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={data?.databasePassword}
|
value={data?.databasePassword}
|
||||||
/>
|
/>
|
||||||
|
<UpdateDatabasePassword
|
||||||
|
onUpdatePassword={async (newPassword) => {
|
||||||
|
await changePassword({
|
||||||
|
mariadbId,
|
||||||
|
password: newPassword,
|
||||||
|
type: "user",
|
||||||
|
});
|
||||||
|
toast.success("Password updated successfully");
|
||||||
|
utils.mariadb.one.invalidate({ mariadbId });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Root Password</Label>
|
<Label>Root Password</Label>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={data?.databaseRootPassword}
|
value={data?.databaseRootPassword}
|
||||||
/>
|
/>
|
||||||
|
<UpdateDatabasePassword
|
||||||
|
label="Root Password"
|
||||||
|
onUpdatePassword={async (newPassword) => {
|
||||||
|
await changePassword({
|
||||||
|
mariadbId,
|
||||||
|
password: newPassword,
|
||||||
|
type: "root",
|
||||||
|
});
|
||||||
|
toast.success("Root password updated successfully");
|
||||||
|
utils.mariadb.one.invalidate({ mariadbId });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
|||||||
const buildConnectionUrl = () => {
|
const buildConnectionUrl = () => {
|
||||||
const port = form.watch("externalPort") || data?.externalPort;
|
const port = form.watch("externalPort") || data?.externalPort;
|
||||||
|
|
||||||
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
const params = `authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`;
|
||||||
|
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/?${params}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
setConnectionUrl(buildConnectionUrl());
|
setConnectionUrl(buildConnectionUrl());
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
|
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -9,6 +11,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||||
const { data } = api.mongo.one.useQuery({ mongoId });
|
const { data } = api.mongo.one.useQuery({ mongoId });
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { mutateAsync: changePassword } =
|
||||||
|
api.mongo.changePassword.useMutation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -25,11 +30,21 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={data?.databasePassword}
|
value={data?.databasePassword}
|
||||||
/>
|
/>
|
||||||
|
<UpdateDatabasePassword
|
||||||
|
onUpdatePassword={async (newPassword) => {
|
||||||
|
await changePassword({
|
||||||
|
mongoId,
|
||||||
|
password: newPassword,
|
||||||
|
});
|
||||||
|
toast.success("Password updated successfully");
|
||||||
|
utils.mongo.one.invalidate({ mongoId });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,7 +62,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
|||||||
<Label>Internal Connection URL </Label>
|
<Label>Internal Connection URL </Label>
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
|
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017/?authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,103 +1,103 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||||
import {
|
import {
|
||||||
Area,
|
type ChartConfig,
|
||||||
AreaChart,
|
ChartContainer,
|
||||||
CartesianGrid,
|
ChartLegend,
|
||||||
Legend,
|
ChartLegendContent,
|
||||||
ResponsiveContainer,
|
ChartTooltip,
|
||||||
Tooltip,
|
ChartTooltipContent,
|
||||||
YAxis,
|
} from "@/components/ui/chart";
|
||||||
} from "recharts";
|
|
||||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accumulativeData: DockerStatsJSON["block"];
|
accumulativeData: DockerStatsJSON["block"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
readMb: {
|
||||||
|
label: "Read (MB)",
|
||||||
|
color: "hsl(var(--chart-1))",
|
||||||
|
},
|
||||||
|
writeMb: {
|
||||||
|
label: "Write (MB)",
|
||||||
|
color: "hsl(var(--chart-2))",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
export const DockerBlockChart = ({ accumulativeData }: Props) => {
|
export const DockerBlockChart = ({ accumulativeData }: Props) => {
|
||||||
const transformedData = accumulativeData.map((item, index) => {
|
const transformedData = accumulativeData.map((item, index) => ({
|
||||||
return {
|
time: item.time,
|
||||||
time: item.time,
|
name: `Point ${index + 1}`,
|
||||||
name: `Point ${index + 1}`,
|
readMb: item.value.readMb,
|
||||||
readMb: item.value.readMb,
|
writeMb: item.value.writeMb,
|
||||||
writeMb: item.value.writeMb,
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 w-full h-[10rem]">
|
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||||
<ResponsiveContainer>
|
<AreaChart
|
||||||
<AreaChart
|
data={transformedData}
|
||||||
data={transformedData}
|
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||||
margin={{
|
>
|
||||||
top: 10,
|
<defs>
|
||||||
right: 30,
|
<linearGradient id="fillBlockRead" x1="0" y1="0" x2="0" y2="1">
|
||||||
left: 0,
|
<stop
|
||||||
bottom: 0,
|
offset="5%"
|
||||||
}}
|
stopColor="var(--color-readMb)"
|
||||||
>
|
stopOpacity={0.8}
|
||||||
<defs>
|
/>
|
||||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
<stop
|
||||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
offset="95%"
|
||||||
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
|
stopColor="var(--color-readMb)"
|
||||||
</linearGradient>
|
stopOpacity={0.1}
|
||||||
<linearGradient id="colorWrite" x1="0" y1="0" x2="0" y2="1">
|
/>
|
||||||
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
|
</linearGradient>
|
||||||
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
|
<linearGradient id="fillBlockWrite" x1="0" y1="0" x2="0" y2="1">
|
||||||
</linearGradient>
|
<stop
|
||||||
</defs>
|
offset="5%"
|
||||||
<YAxis stroke="#A1A1AA" />
|
stopColor="var(--color-writeMb)"
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
stopOpacity={0.8}
|
||||||
{/* @ts-ignore */}
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<stop
|
||||||
<Legend />
|
offset="95%"
|
||||||
<Area
|
stopColor="var(--color-writeMb)"
|
||||||
type="monotone"
|
stopOpacity={0.1}
|
||||||
dataKey="readMb"
|
/>
|
||||||
stroke="#27272A"
|
</linearGradient>
|
||||||
fillOpacity={1}
|
</defs>
|
||||||
fill="url(#colorUv)"
|
<CartesianGrid vertical={false} />
|
||||||
name="Read Mb"
|
<YAxis tickLine={false} axisLine={false} />
|
||||||
/>
|
<ChartTooltip
|
||||||
<Area
|
cursor={false}
|
||||||
type="monotone"
|
content={
|
||||||
dataKey="writeMb"
|
<ChartTooltipContent
|
||||||
stroke="#82ca9d"
|
labelFormatter={(_, payload) => {
|
||||||
fillOpacity={1}
|
const time = payload?.[0]?.payload?.time;
|
||||||
fill="url(#colorWrite)"
|
return time ? format(new Date(time), "PPpp") : "";
|
||||||
name="Write Mb"
|
}}
|
||||||
/>
|
formatter={(value, name) => {
|
||||||
</AreaChart>
|
const label = name === "readMb" ? "Read" : "Write";
|
||||||
</ResponsiveContainer>
|
return [`${value} MB`, label];
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="readMb"
|
||||||
|
stroke="var(--color-readMb)"
|
||||||
|
fill="url(#fillBlockRead)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="writeMb"
|
||||||
|
stroke="var(--color-writeMb)"
|
||||||
|
fill="url(#fillBlockWrite)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
interface CustomTooltipProps {
|
|
||||||
active: boolean;
|
|
||||||
payload?: {
|
|
||||||
color?: string;
|
|
||||||
dataKey?: string;
|
|
||||||
value?: number;
|
|
||||||
payload: {
|
|
||||||
time: string;
|
|
||||||
readMb: number;
|
|
||||||
writeMb: number;
|
|
||||||
};
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
|
||||||
if (active && payload && payload.length && payload[0]) {
|
|
||||||
return (
|
|
||||||
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
|
||||||
{payload[0].payload.time && (
|
|
||||||
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
|
||||||
)}
|
|
||||||
<p>{`Read ${payload[0].payload.readMb} `}</p>
|
|
||||||
<p>{`Write: ${payload[0].payload.writeMb} `}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,87 +1,81 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||||
import {
|
import {
|
||||||
Area,
|
type ChartConfig,
|
||||||
AreaChart,
|
ChartContainer,
|
||||||
CartesianGrid,
|
ChartLegend,
|
||||||
Legend,
|
ChartLegendContent,
|
||||||
ResponsiveContainer,
|
ChartTooltip,
|
||||||
Tooltip,
|
ChartTooltipContent,
|
||||||
YAxis,
|
} from "@/components/ui/chart";
|
||||||
} from "recharts";
|
|
||||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accumulativeData: DockerStatsJSON["cpu"];
|
accumulativeData: DockerStatsJSON["cpu"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
usage: {
|
||||||
|
label: "CPU Usage",
|
||||||
|
color: "hsl(var(--chart-1))",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
export const DockerCpuChart = ({ accumulativeData }: Props) => {
|
export const DockerCpuChart = ({ accumulativeData }: Props) => {
|
||||||
const transformedData = accumulativeData.map((item, index) => {
|
const transformedData = accumulativeData.map((item, index) => ({
|
||||||
return {
|
name: `Point ${index + 1}`,
|
||||||
name: `Point ${index + 1}`,
|
time: item.time,
|
||||||
time: item.time,
|
usage: item.value.toString().split("%")[0],
|
||||||
usage: item.value.toString().split("%")[0],
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 w-full h-[10rem]">
|
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||||
<ResponsiveContainer>
|
<AreaChart
|
||||||
<AreaChart
|
data={transformedData}
|
||||||
data={transformedData}
|
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||||
margin={{
|
>
|
||||||
top: 10,
|
<defs>
|
||||||
right: 30,
|
<linearGradient id="fillCpu" x1="0" y1="0" x2="0" y2="1">
|
||||||
left: 0,
|
<stop
|
||||||
bottom: 0,
|
offset="5%"
|
||||||
}}
|
stopColor="var(--color-usage)"
|
||||||
>
|
stopOpacity={0.8}
|
||||||
<defs>
|
/>
|
||||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
<stop
|
||||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
offset="95%"
|
||||||
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
stopColor="var(--color-usage)"
|
||||||
</linearGradient>
|
stopOpacity={0.1}
|
||||||
</defs>
|
/>
|
||||||
<YAxis stroke="#A1A1AA" domain={[0, 100]} />
|
</linearGradient>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
</defs>
|
||||||
{/* @ts-ignore */}
|
<CartesianGrid vertical={false} />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<YAxis
|
||||||
<Legend />
|
tickFormatter={(value) => `${value}%`}
|
||||||
<Area
|
domain={[0, 100]}
|
||||||
type="monotone"
|
tickLine={false}
|
||||||
dataKey="usage"
|
axisLine={false}
|
||||||
stroke="#27272A"
|
/>
|
||||||
fillOpacity={1}
|
<ChartTooltip
|
||||||
fill="url(#colorUv)"
|
cursor={false}
|
||||||
/>
|
content={
|
||||||
</AreaChart>
|
<ChartTooltipContent
|
||||||
</ResponsiveContainer>
|
labelFormatter={(_, payload) => {
|
||||||
</div>
|
const time = payload?.[0]?.payload?.time;
|
||||||
|
return time ? format(new Date(time), "PPpp") : "";
|
||||||
|
}}
|
||||||
|
formatter={(value) => [`${value}%`, "CPU Usage"]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="usage"
|
||||||
|
stroke="var(--color-usage)"
|
||||||
|
fill="url(#fillCpu)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CustomTooltipProps {
|
|
||||||
active: boolean;
|
|
||||||
payload?: {
|
|
||||||
color?: string;
|
|
||||||
dataKey?: string;
|
|
||||||
value?: number;
|
|
||||||
payload: {
|
|
||||||
time: string;
|
|
||||||
usage: number;
|
|
||||||
};
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
|
||||||
if (active && payload && payload.length && payload[0]) {
|
|
||||||
return (
|
|
||||||
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
|
||||||
{payload[0].payload.time && (
|
|
||||||
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
|
||||||
)}
|
|
||||||
<p>{`CPU Usage: ${payload[0].payload.usage}%`}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||||
import {
|
import {
|
||||||
Area,
|
type ChartConfig,
|
||||||
AreaChart,
|
ChartContainer,
|
||||||
CartesianGrid,
|
ChartTooltip,
|
||||||
Legend,
|
ChartTooltipContent,
|
||||||
ResponsiveContainer,
|
} from "@/components/ui/chart";
|
||||||
Tooltip,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts";
|
|
||||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,91 +13,70 @@ interface Props {
|
|||||||
diskTotal: number;
|
diskTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
usedGb: {
|
||||||
|
label: "Used (GB)",
|
||||||
|
color: "hsl(var(--chart-3))",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
|
export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
|
||||||
const transformedData = accumulativeData.map((item, index) => {
|
const transformedData = accumulativeData.map((item, index) => ({
|
||||||
return {
|
time: item.time,
|
||||||
time: item.time,
|
name: `Point ${index + 1}`,
|
||||||
name: `Point ${index + 1}`,
|
usedGb: +item.value.diskUsage,
|
||||||
usedGb: +item.value.diskUsage,
|
totalGb: +item.value.diskTotal,
|
||||||
totalGb: +item.value.diskTotal,
|
}));
|
||||||
freeGb: item.value.diskFree,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 w-full h-[10rem]">
|
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||||
<ResponsiveContainer>
|
<AreaChart
|
||||||
<AreaChart
|
data={transformedData}
|
||||||
data={transformedData}
|
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||||
margin={{
|
>
|
||||||
top: 10,
|
<defs>
|
||||||
right: 30,
|
<linearGradient id="fillDiskUsed" x1="0" y1="0" x2="0" y2="1">
|
||||||
left: 0,
|
<stop
|
||||||
bottom: 0,
|
offset="5%"
|
||||||
}}
|
stopColor="var(--color-usedGb)"
|
||||||
>
|
stopOpacity={0.8}
|
||||||
<defs>
|
/>
|
||||||
<linearGradient id="colorUsed" x1="0" y1="0" x2="0" y2="1">
|
<stop
|
||||||
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.8} />
|
offset="95%"
|
||||||
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
|
stopColor="var(--color-usedGb)"
|
||||||
</linearGradient>
|
stopOpacity={0.1}
|
||||||
<linearGradient id="colorFree" x1="0" y1="0" x2="0" y2="1">
|
/>
|
||||||
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.2} />
|
</linearGradient>
|
||||||
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
|
</defs>
|
||||||
</linearGradient>
|
<CartesianGrid vertical={false} />
|
||||||
</defs>
|
<YAxis
|
||||||
<YAxis stroke="#A1A1AA" domain={[0, diskTotal]} />
|
domain={[0, diskTotal]}
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
tickLine={false}
|
||||||
{/* @ts-ignore */}
|
axisLine={false}
|
||||||
<Tooltip content={<CustomTooltip />} />
|
tickFormatter={(value) => `${value} GB`}
|
||||||
<Legend />
|
/>
|
||||||
<Area
|
<ChartTooltip
|
||||||
type="monotone"
|
cursor={false}
|
||||||
dataKey="usedGb"
|
content={
|
||||||
stroke="#6C28D9"
|
<ChartTooltipContent
|
||||||
fillOpacity={1}
|
labelFormatter={(_, payload) => {
|
||||||
fill="url(#colorUsed)"
|
const time = payload?.[0]?.payload?.time;
|
||||||
name="Used GB"
|
return time ? format(new Date(time), "PPpp") : "";
|
||||||
/>
|
}}
|
||||||
<Area
|
formatter={(value) => {
|
||||||
type="monotone"
|
return [`${value} GB`, "Used"];
|
||||||
dataKey="freeGb"
|
}}
|
||||||
stroke="#8884d8"
|
/>
|
||||||
fillOpacity={1}
|
}
|
||||||
fill="url(#colorFree)"
|
/>
|
||||||
name="Free GB"
|
<Area
|
||||||
/>
|
type="monotone"
|
||||||
</AreaChart>
|
dataKey="usedGb"
|
||||||
</ResponsiveContainer>
|
stroke="var(--color-usedGb)"
|
||||||
</div>
|
fill="url(#fillDiskUsed)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
interface CustomTooltipProps {
|
|
||||||
active: boolean;
|
|
||||||
payload?: {
|
|
||||||
color?: string;
|
|
||||||
dataKey?: string;
|
|
||||||
value?: number;
|
|
||||||
payload: {
|
|
||||||
time: string;
|
|
||||||
usedGb: number;
|
|
||||||
freeGb: number;
|
|
||||||
totalGb: number;
|
|
||||||
};
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
|
||||||
if (active && payload && payload.length && payload[0]) {
|
|
||||||
return (
|
|
||||||
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
|
||||||
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
|
||||||
<p>{`Disk usage: ${payload[0].payload.usedGb} GB`}</p>
|
|
||||||
<p>{`Disk free: ${payload[0].payload.freeGb} GB`}</p>
|
|
||||||
<p>{`Total disk: ${payload[0].payload.totalGb} GB`}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Cell, Label, Pie, PieChart } from "recharts";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
type ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const TYPE_TO_KEY: Record<string, string> = {
|
||||||
|
Images: "images",
|
||||||
|
Containers: "containers",
|
||||||
|
"Local Volumes": "volumes",
|
||||||
|
"Build Cache": "buildCache",
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
value: {
|
||||||
|
label: "Size",
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
label: "Images",
|
||||||
|
color: "hsl(var(--chart-1))",
|
||||||
|
},
|
||||||
|
containers: {
|
||||||
|
label: "Containers",
|
||||||
|
color: "hsl(var(--chart-2))",
|
||||||
|
},
|
||||||
|
volumes: {
|
||||||
|
label: "Volumes",
|
||||||
|
color: "hsl(var(--chart-3))",
|
||||||
|
},
|
||||||
|
buildCache: {
|
||||||
|
label: "Build Cache",
|
||||||
|
color: "hsl(var(--chart-4))",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||||
|
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||||
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${bytes} B`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DockerDiskUsageChart = () => {
|
||||||
|
const { data, isLoading, refetch, isRefetching } =
|
||||||
|
api.settings.getDockerDiskUsage.useQuery(undefined, {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { chartData, totalBytes } = useMemo(() => {
|
||||||
|
const items =
|
||||||
|
data
|
||||||
|
?.filter((item) => item.sizeBytes > 0)
|
||||||
|
.map((item) => {
|
||||||
|
const key = TYPE_TO_KEY[item.type] ?? item.type;
|
||||||
|
return {
|
||||||
|
name: key,
|
||||||
|
value: item.sizeBytes,
|
||||||
|
size: item.size,
|
||||||
|
active: item.active,
|
||||||
|
totalCount: item.totalCount,
|
||||||
|
reclaimable: item.reclaimable,
|
||||||
|
fill: `var(--color-${key})`,
|
||||||
|
};
|
||||||
|
}) ?? [];
|
||||||
|
return {
|
||||||
|
chartData: items,
|
||||||
|
totalBytes: items.reduce((sum, item) => sum + item.value, 0),
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[16rem]">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-muted-foreground mt-4">
|
||||||
|
No Docker disk usage data available.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Total: {formatSize(totalBytes)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isRefetching}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`size-3.5 ${isRefetching ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="mx-auto w-full max-h-[250px] [&_.recharts-pie-label-text]:fill-foreground"
|
||||||
|
>
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
nameKey="name"
|
||||||
|
formatter={(value, name) => {
|
||||||
|
const item = chartData.find((d) => d.name === name);
|
||||||
|
if (!item) return [formatSize(value as number), name];
|
||||||
|
return [
|
||||||
|
`${item.size} — ${item.active} active / ${item.totalCount} total — Reclaimable: ${item.reclaimable}`,
|
||||||
|
chartConfig[name as keyof typeof chartConfig]?.label ??
|
||||||
|
name,
|
||||||
|
];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={85}
|
||||||
|
strokeWidth={3}
|
||||||
|
stroke="hsl(var(--background))"
|
||||||
|
minAngle={15}
|
||||||
|
>
|
||||||
|
{chartData.map((entry) => (
|
||||||
|
<Cell key={entry.name} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
<Label
|
||||||
|
content={({ viewBox }) => {
|
||||||
|
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={viewBox.cy}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
<tspan
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={(viewBox.cy || 0) - 8}
|
||||||
|
className="fill-foreground text-2xl font-bold"
|
||||||
|
>
|
||||||
|
{formatSize(totalBytes)}
|
||||||
|
</tspan>
|
||||||
|
<tspan
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={(viewBox.cy || 0) + 14}
|
||||||
|
className="fill-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
Docker Usage
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||||
import {
|
import {
|
||||||
Area,
|
type ChartConfig,
|
||||||
AreaChart,
|
ChartContainer,
|
||||||
CartesianGrid,
|
ChartLegend,
|
||||||
Legend,
|
ChartLegendContent,
|
||||||
ResponsiveContainer,
|
ChartTooltip,
|
||||||
Tooltip,
|
ChartTooltipContent,
|
||||||
YAxis,
|
} from "@/components/ui/chart";
|
||||||
} from "recharts";
|
|
||||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||||
import { convertMemoryToBytes } from "./show-free-container-monitoring";
|
import { convertMemoryToBytes } from "./show-free-container-monitoring";
|
||||||
|
|
||||||
@@ -16,78 +16,72 @@ interface Props {
|
|||||||
memoryLimitGB: number;
|
memoryLimitGB: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
usage: {
|
||||||
|
label: "Memory (GB)",
|
||||||
|
color: "hsl(var(--chart-2))",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
export const DockerMemoryChart = ({
|
export const DockerMemoryChart = ({
|
||||||
accumulativeData,
|
accumulativeData,
|
||||||
memoryLimitGB,
|
memoryLimitGB,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const transformedData = accumulativeData.map((item, index) => {
|
const transformedData = accumulativeData.map((item, index) => ({
|
||||||
return {
|
time: item.time,
|
||||||
time: item.time,
|
name: `Point ${index + 1}`,
|
||||||
name: `Point ${index + 1}`,
|
// @ts-ignore
|
||||||
// @ts-ignore
|
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
|
||||||
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 w-full h-[10rem]">
|
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||||
<ResponsiveContainer>
|
<AreaChart
|
||||||
<AreaChart
|
data={transformedData}
|
||||||
data={transformedData}
|
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||||
margin={{
|
>
|
||||||
top: 10,
|
<defs>
|
||||||
right: 30,
|
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
|
||||||
left: 0,
|
<stop
|
||||||
bottom: 0,
|
offset="5%"
|
||||||
}}
|
stopColor="var(--color-usage)"
|
||||||
>
|
stopOpacity={0.8}
|
||||||
<defs>
|
/>
|
||||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
<stop
|
||||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
offset="95%"
|
||||||
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
stopColor="var(--color-usage)"
|
||||||
</linearGradient>
|
stopOpacity={0.1}
|
||||||
</defs>
|
/>
|
||||||
<YAxis stroke="#A1A1AA" domain={[0, +memoryLimitGB.toFixed(2)]} />
|
</linearGradient>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
</defs>
|
||||||
{/* @ts-ignore */}
|
<CartesianGrid vertical={false} />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<YAxis
|
||||||
<Legend />
|
tickFormatter={(value) => `${value} GB`}
|
||||||
<Area
|
domain={[0, +memoryLimitGB.toFixed(2)]}
|
||||||
type="monotone"
|
tickLine={false}
|
||||||
dataKey="usage"
|
axisLine={false}
|
||||||
stroke="#27272A"
|
/>
|
||||||
fillOpacity={1}
|
<ChartTooltip
|
||||||
fill="url(#colorUv)"
|
cursor={false}
|
||||||
/>
|
content={
|
||||||
</AreaChart>
|
<ChartTooltipContent
|
||||||
</ResponsiveContainer>
|
labelFormatter={(_, payload) => {
|
||||||
</div>
|
const time = payload?.[0]?.payload?.time;
|
||||||
|
return time ? format(new Date(time), "PPpp") : "";
|
||||||
|
}}
|
||||||
|
formatter={(value) => [`${value} GB`, "Memory"]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="usage"
|
||||||
|
stroke="var(--color-usage)"
|
||||||
|
fill="url(#fillMemory)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
interface CustomTooltipProps {
|
|
||||||
active: boolean;
|
|
||||||
payload?: {
|
|
||||||
color?: string;
|
|
||||||
dataKey?: string;
|
|
||||||
value?: number;
|
|
||||||
payload: {
|
|
||||||
time: string;
|
|
||||||
usage: number;
|
|
||||||
};
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
|
||||||
if (active && payload && payload.length && payload[0] && payload[0].payload) {
|
|
||||||
return (
|
|
||||||
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
|
||||||
{payload[0].payload.time && (
|
|
||||||
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p>{`Memory usage: ${payload[0].payload.usage} GB`}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,99 +1,99 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||||
import {
|
import {
|
||||||
Area,
|
type ChartConfig,
|
||||||
AreaChart,
|
ChartContainer,
|
||||||
CartesianGrid,
|
ChartLegend,
|
||||||
Legend,
|
ChartLegendContent,
|
||||||
ResponsiveContainer,
|
ChartTooltip,
|
||||||
Tooltip,
|
ChartTooltipContent,
|
||||||
YAxis,
|
} from "@/components/ui/chart";
|
||||||
} from "recharts";
|
|
||||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accumulativeData: DockerStatsJSON["network"];
|
accumulativeData: DockerStatsJSON["network"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
inMB: {
|
||||||
|
label: "In (MB)",
|
||||||
|
color: "hsl(var(--chart-1))",
|
||||||
|
},
|
||||||
|
outMB: {
|
||||||
|
label: "Out (MB)",
|
||||||
|
color: "hsl(var(--chart-2))",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
export const DockerNetworkChart = ({ accumulativeData }: Props) => {
|
export const DockerNetworkChart = ({ accumulativeData }: Props) => {
|
||||||
const transformedData = accumulativeData.map((item, index) => {
|
const transformedData = accumulativeData.map((item, index) => ({
|
||||||
return {
|
time: item.time,
|
||||||
time: item.time,
|
name: `Point ${index + 1}`,
|
||||||
name: `Point ${index + 1}`,
|
inMB: item.value.inputMb,
|
||||||
inMB: item.value.inputMb,
|
outMB: item.value.outputMb,
|
||||||
outMB: item.value.outputMb,
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 w-full h-[10rem]">
|
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||||
<ResponsiveContainer>
|
<AreaChart
|
||||||
<AreaChart
|
data={transformedData}
|
||||||
data={transformedData}
|
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||||
margin={{
|
>
|
||||||
top: 10,
|
<defs>
|
||||||
right: 30,
|
<linearGradient id="fillNetIn" x1="0" y1="0" x2="0" y2="1">
|
||||||
left: 0,
|
<stop offset="5%" stopColor="var(--color-inMB)" stopOpacity={0.8} />
|
||||||
bottom: 0,
|
<stop
|
||||||
}}
|
offset="95%"
|
||||||
>
|
stopColor="var(--color-inMB)"
|
||||||
<defs>
|
stopOpacity={0.1}
|
||||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
/>
|
||||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
</linearGradient>
|
||||||
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
<linearGradient id="fillNetOut" x1="0" y1="0" x2="0" y2="1">
|
||||||
</linearGradient>
|
<stop
|
||||||
</defs>
|
offset="5%"
|
||||||
<YAxis stroke="#A1A1AA" />
|
stopColor="var(--color-outMB)"
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
stopOpacity={0.8}
|
||||||
{/* @ts-ignore */}
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<stop
|
||||||
<Legend />
|
offset="95%"
|
||||||
<Area
|
stopColor="var(--color-outMB)"
|
||||||
type="monotone"
|
stopOpacity={0.1}
|
||||||
dataKey="inMB"
|
/>
|
||||||
stroke="#8884d8"
|
</linearGradient>
|
||||||
fillOpacity={1}
|
</defs>
|
||||||
fill="url(#colorUv)"
|
<CartesianGrid vertical={false} />
|
||||||
name="In MB"
|
<YAxis tickLine={false} axisLine={false} />
|
||||||
/>
|
<ChartTooltip
|
||||||
<Area
|
cursor={false}
|
||||||
type="monotone"
|
content={
|
||||||
dataKey="outMB"
|
<ChartTooltipContent
|
||||||
stroke="#82ca9d"
|
labelFormatter={(_, payload) => {
|
||||||
fillOpacity={1}
|
const time = payload?.[0]?.payload?.time;
|
||||||
fill="url(#colorUv)"
|
return time ? format(new Date(time), "PPpp") : "";
|
||||||
name="Out MB"
|
}}
|
||||||
/>
|
formatter={(value, name) => {
|
||||||
</AreaChart>
|
const label = name === "inMB" ? "In" : "Out";
|
||||||
</ResponsiveContainer>
|
return [`${value} MB`, label];
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="inMB"
|
||||||
|
stroke="var(--color-inMB)"
|
||||||
|
fill="url(#fillNetIn)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="outMB"
|
||||||
|
stroke="var(--color-outMB)"
|
||||||
|
fill="url(#fillNetOut)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CustomTooltipProps {
|
|
||||||
active: boolean;
|
|
||||||
payload?: {
|
|
||||||
color?: string;
|
|
||||||
dataKey?: string;
|
|
||||||
value?: number;
|
|
||||||
payload: {
|
|
||||||
time: string;
|
|
||||||
inMB: number;
|
|
||||||
outMB: number;
|
|
||||||
};
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
|
||||||
if (active && payload && payload.length && payload[0]) {
|
|
||||||
return (
|
|
||||||
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
|
||||||
{payload[0].payload.time && (
|
|
||||||
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
|
||||||
)}
|
|
||||||
<p>{`In Usage: ${payload[0].payload.inMB} `}</p>
|
|
||||||
<p>{`Out Usage: ${payload[0].payload.outMB} `}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { api } from "@/utils/api";
|
|||||||
import { DockerBlockChart } from "./docker-block-chart";
|
import { DockerBlockChart } from "./docker-block-chart";
|
||||||
import { DockerCpuChart } from "./docker-cpu-chart";
|
import { DockerCpuChart } from "./docker-cpu-chart";
|
||||||
import { DockerDiskChart } from "./docker-disk-chart";
|
import { DockerDiskChart } from "./docker-disk-chart";
|
||||||
|
import { DockerDiskUsageChart } from "./docker-disk-usage-chart";
|
||||||
import { DockerMemoryChart } from "./docker-memory-chart";
|
import { DockerMemoryChart } from "./docker-memory-chart";
|
||||||
import { DockerNetworkChart } from "./docker-network-chart";
|
import { DockerNetworkChart } from "./docker-network-chart";
|
||||||
|
|
||||||
@@ -219,11 +220,11 @@ export const ContainerFreeMonitoring = ({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Used: {currentData.cpu.value}
|
Used: {String(currentData.cpu.value ?? "0%")}
|
||||||
</span>
|
</span>
|
||||||
<Progress
|
<Progress
|
||||||
value={Number.parseInt(
|
value={Number.parseInt(
|
||||||
currentData.cpu.value.replace("%", ""),
|
String(currentData.cpu.value ?? "0%").replace("%", ""),
|
||||||
10,
|
10,
|
||||||
)}
|
)}
|
||||||
className="w-[100%]"
|
className="w-[100%]"
|
||||||
@@ -284,6 +285,18 @@ export const ContainerFreeMonitoring = ({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
{appName === "dokploy" && (
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Docker Disk Usage
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DockerDiskUsageChart />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
|
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -9,6 +11,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||||
const { data } = api.mysql.one.useQuery({ mysqlId });
|
const { data } = api.mysql.one.useQuery({ mysqlId });
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { mutateAsync: changePassword } =
|
||||||
|
api.mysql.changePassword.useMutation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -28,20 +33,43 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={data?.databasePassword}
|
value={data?.databasePassword}
|
||||||
/>
|
/>
|
||||||
|
<UpdateDatabasePassword
|
||||||
|
onUpdatePassword={async (newPassword) => {
|
||||||
|
await changePassword({
|
||||||
|
mysqlId,
|
||||||
|
password: newPassword,
|
||||||
|
type: "user",
|
||||||
|
});
|
||||||
|
toast.success("Password updated successfully");
|
||||||
|
utils.mysql.one.invalidate({ mysqlId });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Root Password</Label>
|
<Label>Root Password</Label>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={data?.databaseRootPassword}
|
value={data?.databaseRootPassword}
|
||||||
/>
|
/>
|
||||||
|
<UpdateDatabasePassword
|
||||||
|
label="Root Password"
|
||||||
|
onUpdatePassword={async (newPassword) => {
|
||||||
|
await changePassword({
|
||||||
|
mysqlId,
|
||||||
|
password: newPassword,
|
||||||
|
type: "root",
|
||||||
|
});
|
||||||
|
toast.success("Root password updated successfully");
|
||||||
|
utils.mysql.one.invalidate({ mysqlId });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
|
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -9,6 +11,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||||
const { data } = api.postgres.one.useQuery({ postgresId });
|
const { data } = api.postgres.one.useQuery({ postgresId });
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { mutateAsync: changePassword } =
|
||||||
|
api.postgres.changePassword.useMutation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -28,11 +33,21 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
value={data?.databasePassword}
|
value={data?.databasePassword}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
<UpdateDatabasePassword
|
||||||
|
onUpdatePassword={async (newPassword) => {
|
||||||
|
await changePassword({
|
||||||
|
postgresId,
|
||||||
|
password: newPassword,
|
||||||
|
});
|
||||||
|
toast.success("Password updated successfully");
|
||||||
|
utils.postgres.one.invalidate({ postgresId });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { slugify } from "@/lib/slug";
|
import { slugify } from "@/lib/slug";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
||||||
|
|
||||||
const AddTemplateSchema = z.object({
|
const AddTemplateSchema = z.object({
|
||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
@@ -53,9 +54,8 @@ const AddTemplateSchema = z.object({
|
|||||||
.min(1, {
|
.min(1, {
|
||||||
message: "App name is required",
|
message: "App name is required",
|
||||||
})
|
})
|
||||||
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
.regex(APP_NAME_REGEX, {
|
||||||
message:
|
message: APP_NAME_MESSAGE,
|
||||||
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
|
||||||
}),
|
}),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { slugify } from "@/lib/slug";
|
import { slugify } from "@/lib/slug";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
||||||
|
|
||||||
const AddComposeSchema = z.object({
|
const AddComposeSchema = z.object({
|
||||||
composeType: z.enum(["docker-compose", "stack"]).optional(),
|
composeType: z.enum(["docker-compose", "stack"]).optional(),
|
||||||
@@ -54,9 +55,8 @@ const AddComposeSchema = z.object({
|
|||||||
.min(1, {
|
.min(1, {
|
||||||
message: "App name is required",
|
message: "App name is required",
|
||||||
})
|
})
|
||||||
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
.regex(APP_NAME_REGEX, {
|
||||||
message:
|
message: APP_NAME_MESSAGE,
|
||||||
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
|
||||||
}),
|
}),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
@@ -78,9 +78,6 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
const { mutateAsync, isPending, error, isError } =
|
const { mutateAsync, isPending, error, isError } =
|
||||||
api.compose.create.useMutation();
|
api.compose.create.useMutation();
|
||||||
|
|
||||||
// Get environment data to extract projectId
|
|
||||||
// const { data: environment } = api.environment.one.useQuery({ environmentId });
|
|
||||||
|
|
||||||
const hasServers = servers && servers.length > 0;
|
const hasServers = servers && servers.length > 0;
|
||||||
// Show dropdown logic based on cloud environment
|
// Show dropdown logic based on cloud environment
|
||||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||||
|
|||||||
@@ -52,12 +52,13 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { slugify } from "@/lib/slug";
|
import { slugify } from "@/lib/slug";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
||||||
|
|
||||||
type DbType = z.infer<typeof mySchema>["type"];
|
type DbType = z.infer<typeof mySchema>["type"];
|
||||||
|
|
||||||
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||||
|
mongo: "mongo:8",
|
||||||
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
|
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
|
||||||
mongo: "mongo:7",
|
|
||||||
mariadb: "mariadb:11",
|
mariadb: "mariadb:11",
|
||||||
mysql: "mysql:8",
|
mysql: "mysql:8",
|
||||||
postgres: "postgres:18",
|
postgres: "postgres:18",
|
||||||
@@ -82,9 +83,8 @@ const baseDatabaseSchema = z.object({
|
|||||||
.min(1, {
|
.min(1, {
|
||||||
message: "App name is required",
|
message: "App name is required",
|
||||||
})
|
})
|
||||||
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
.regex(APP_NAME_REGEX, {
|
||||||
message:
|
message: APP_NAME_MESSAGE,
|
||||||
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
|
||||||
}),
|
}),
|
||||||
databasePassword: z
|
databasePassword: z
|
||||||
.string()
|
.string()
|
||||||
@@ -632,7 +632,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="enableNamespaces"
|
name="enableNamespaces"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
console.log(field.value);
|
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Enable Namespaces</FormLabel>
|
<FormLabel>Enable Namespaces</FormLabel>
|
||||||
|
|||||||
494
apps/dokploy/components/dashboard/project/add-import.tsx
Normal file
494
apps/dokploy/components/dashboard/project/add-import.tsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { Code2, FileInput, Globe2, HardDrive, HelpCircle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { slugify } from "@/lib/slug";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
||||||
|
|
||||||
|
const AddImportSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: "Name is required" }),
|
||||||
|
appName: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "App name is required" })
|
||||||
|
.regex(APP_NAME_REGEX, { message: APP_NAME_MESSAGE }),
|
||||||
|
base64: z.string().min(1, { message: "Base64 content is required" }),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AddImport = z.infer<typeof AddImportSchema>;
|
||||||
|
|
||||||
|
type TemplateInfo = {
|
||||||
|
compose: string;
|
||||||
|
template: {
|
||||||
|
domains: Array<{
|
||||||
|
serviceName: string;
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
host?: string;
|
||||||
|
}>;
|
||||||
|
envs: string[];
|
||||||
|
mounts: Array<{ filePath: string; content: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
environmentId: string;
|
||||||
|
projectName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddImport = ({ environmentId, projectName }: Props) => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
const [mountOpen, setMountOpen] = useState(false);
|
||||||
|
const [selectedMount, setSelectedMount] = useState<{
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [templateInfo, setTemplateInfo] = useState<TemplateInfo | null>(null);
|
||||||
|
|
||||||
|
const slug = slugify(projectName);
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
|
const shouldShowServerDropdown = !!(servers && servers.length > 0);
|
||||||
|
|
||||||
|
const { mutateAsync: previewTemplate, isPending: isProcessing } =
|
||||||
|
api.compose.previewTemplate.useMutation();
|
||||||
|
const { mutateAsync: createCompose, isPending: isCreating } =
|
||||||
|
api.compose.create.useMutation();
|
||||||
|
const { mutateAsync: importCompose, isPending: isImporting } =
|
||||||
|
api.compose.import.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<AddImport>({
|
||||||
|
defaultValues: { name: "", appName: `${slug}-`, base64: "" },
|
||||||
|
resolver: zodResolver(AddImportSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetAll = () => {
|
||||||
|
form.reset({ name: "", appName: `${slug}-`, base64: "" });
|
||||||
|
setTemplateInfo(null);
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setMountOpen(false);
|
||||||
|
setSelectedMount(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open) resetAll();
|
||||||
|
setVisible(open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoad = async (data: AddImport) => {
|
||||||
|
try {
|
||||||
|
const result = await previewTemplate({
|
||||||
|
appName: data.appName,
|
||||||
|
base64: data.base64.trim(),
|
||||||
|
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||||
|
});
|
||||||
|
setTemplateInfo(result);
|
||||||
|
setPreviewOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error processing template",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
const data = form.getValues();
|
||||||
|
try {
|
||||||
|
const compose = await createCompose({
|
||||||
|
name: data.name,
|
||||||
|
appName: data.appName,
|
||||||
|
environmentId,
|
||||||
|
composeType: "docker-compose",
|
||||||
|
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||||
|
});
|
||||||
|
await importCompose({
|
||||||
|
composeId: compose.composeId,
|
||||||
|
base64: data.base64.trim(),
|
||||||
|
});
|
||||||
|
toast.success("Compose imported successfully");
|
||||||
|
await utils.environment.one.invalidate({ environmentId });
|
||||||
|
resetAll();
|
||||||
|
setVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error importing compose",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelPreview = () => {
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setTemplateInfo(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={visible} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger className="w-full">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer space-x-3"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<FileInput className="size-4 text-muted-foreground" />
|
||||||
|
<span>Import</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Compose</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Paste a base64-encoded compose export to preview and import it
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-import"
|
||||||
|
onSubmit={form.handleSubmit(handleLoad)}
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="My App"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value || "";
|
||||||
|
form.setValue(
|
||||||
|
"appName",
|
||||||
|
`${slug}-${slugify(val.trim())}`,
|
||||||
|
);
|
||||||
|
field.onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{shouldShowServerDropdown && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serverId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Select a Server {!isCloud ? "(Optional)" : ""}
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormLabel>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="z-[999] w-[300px]"
|
||||||
|
align="start"
|
||||||
|
side="top"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
If no server is selected, the compose will be
|
||||||
|
deployed on the server where the user is logged
|
||||||
|
in.
|
||||||
|
</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={
|
||||||
|
field.value || (!isCloud ? "dokploy" : undefined)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
!isCloud ? "Dokploy" : "Select a Server"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{!isCloud && (
|
||||||
|
<SelectItem value="dokploy">
|
||||||
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
|
<span>Dokploy</span>
|
||||||
|
<span className="text-muted-foreground text-xs self-center">
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
{servers?.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
|
<span>{server.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs self-center">
|
||||||
|
{server.ipAddress}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectLabel>
|
||||||
|
Servers (
|
||||||
|
{(servers?.length ?? 0) + (!isCloud ? 1 : 0)})
|
||||||
|
</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="appName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>App Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-app" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="base64"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Configuration (Base64)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Paste your base64-encoded compose export here..."
|
||||||
|
className="font-mono resize-none h-32"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
isLoading={isCreating || isProcessing}
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Preview modal */}
|
||||||
|
<Dialog
|
||||||
|
open={previewOpen}
|
||||||
|
onOpenChange={(open) => !open && handleCancelPreview()}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-[60vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold">
|
||||||
|
Template Information
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="space-y-2">
|
||||||
|
<p>Review the template information before importing</p>
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
Warning: This will remove all existing environment variables,
|
||||||
|
mounts, and domains from this service.
|
||||||
|
</AlertBlock>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code2 className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Docker Compose</h3>
|
||||||
|
</div>
|
||||||
|
<CodeEditor
|
||||||
|
language="yaml"
|
||||||
|
value={templateInfo?.compose || ""}
|
||||||
|
className="font-mono"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templateInfo?.template.domains &&
|
||||||
|
templateInfo.template.domains.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe2 className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Domains</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{templateInfo.template.domains.map((domain, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{domain.serviceName}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<div>Port: {domain.port}</div>
|
||||||
|
{domain.host && <div>Host: {domain.host}</div>}
|
||||||
|
{domain.path && <div>Path: {domain.path}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{templateInfo?.template.envs &&
|
||||||
|
templateInfo.template.envs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code2 className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Environment Variables
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{templateInfo.template.envs.map((env, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
|
||||||
|
>
|
||||||
|
{env}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{templateInfo?.template.mounts &&
|
||||||
|
templateInfo.template.mounts.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Mounts</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{templateInfo.template.mounts.map((mount, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMount(mount);
|
||||||
|
setMountOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mount.filePath}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button variant="outline" onClick={handleCancelPreview}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button isLoading={isImporting} onClick={handleImport}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Mount content modal */}
|
||||||
|
<Dialog open={mountOpen} onOpenChange={setMountOpen}>
|
||||||
|
<DialogContent className="max-w-[50vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">
|
||||||
|
{selectedMount?.filePath}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>Mount File Content</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="h-[45vh] pr-4">
|
||||||
|
<CodeEditor
|
||||||
|
language="yaml"
|
||||||
|
value={selectedMount?.content || ""}
|
||||||
|
className="font-mono"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button onClick={() => setMountOpen(false)}>Close</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Bookmark,
|
||||||
BookText,
|
BookText,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
@@ -82,6 +83,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [showBookmarksOnly, setShowBookmarksOnly] = useState(false);
|
||||||
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
|
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
|
||||||
// Try to get from props first, then localStorage
|
// Try to get from props first, then localStorage
|
||||||
if (baseUrl) return baseUrl;
|
if (baseUrl) return baseUrl;
|
||||||
@@ -122,8 +124,45 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
enabled: open,
|
enabled: open,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: bookmarkIds = [], isLoading: isLoadingBookmarks } =
|
||||||
|
api.user.getBookmarkedTemplates.useQuery(undefined, {
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { mutateAsync: toggleBookmark } =
|
||||||
|
api.user.toggleTemplateBookmark.useMutation({
|
||||||
|
onMutate: async ({ templateId }) => {
|
||||||
|
await utils.user.getBookmarkedTemplates.cancel();
|
||||||
|
const previousBookmarks = utils.user.getBookmarkedTemplates.getData();
|
||||||
|
|
||||||
|
utils.user.getBookmarkedTemplates.setData(undefined, (old = []) => {
|
||||||
|
if (old.includes(templateId)) {
|
||||||
|
return old.filter((id) => id !== templateId);
|
||||||
|
}
|
||||||
|
return [...old, templateId];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousBookmarks };
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
if (context?.previousBookmarks) {
|
||||||
|
utils.user.getBookmarkedTemplates.setData(
|
||||||
|
undefined,
|
||||||
|
context.previousBookmarks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toast.error("Failed to update bookmark");
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(
|
||||||
|
data.isBookmarked ? "Added to bookmarks" : "Removed from bookmarks",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const [serverId, setServerId] = useState<string | undefined>(undefined);
|
const [serverId, setServerId] = useState<string | undefined>(undefined);
|
||||||
const { mutateAsync, isPending, error, isError } =
|
const { mutateAsync, isPending, error, isError } =
|
||||||
api.compose.deployTemplate.useMutation();
|
api.compose.deployTemplate.useMutation();
|
||||||
@@ -137,7 +176,9 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
query === "" ||
|
query === "" ||
|
||||||
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
template.description.toLowerCase().includes(query.toLowerCase());
|
template.description.toLowerCase().includes(query.toLowerCase());
|
||||||
return matchesTags && matchesQuery;
|
const matchesBookmarks =
|
||||||
|
!showBookmarksOnly || bookmarkIds.includes(template.id);
|
||||||
|
return matchesTags && matchesQuery && matchesBookmarks;
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
const hasServers = servers && servers.length > 0;
|
const hasServers = servers && servers.length > 0;
|
||||||
@@ -146,6 +187,14 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||||
const shouldShowServerDropdown = hasServers;
|
const shouldShowServerDropdown = hasServers;
|
||||||
|
|
||||||
|
const handleToggleBookmark = async (
|
||||||
|
e: React.MouseEvent,
|
||||||
|
templateId: string,
|
||||||
|
) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await toggleBookmark({ templateId });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger className="w-full">
|
<DialogTrigger className="w-full">
|
||||||
@@ -243,6 +292,20 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
<Button
|
||||||
|
variant={showBookmarksOnly ? "default" : "outline"}
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowBookmarksOnly(!showBookmarksOnly)}
|
||||||
|
className="h-9 w-9 flex-shrink-0"
|
||||||
|
disabled={isLoadingBookmarks}
|
||||||
|
>
|
||||||
|
<Bookmark
|
||||||
|
className={cn(
|
||||||
|
"size-4",
|
||||||
|
showBookmarksOnly && "fill-current",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -299,11 +362,19 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : templates.length === 0 ? (
|
) : templates.length === 0 ? (
|
||||||
<div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
|
<div className="flex flex-col justify-center items-center w-full gap-2 min-h-[50vh]">
|
||||||
<SearchIcon className="text-muted-foreground size-6" />
|
<SearchIcon className="text-muted-foreground size-6" />
|
||||||
<div className="text-xl font-medium text-muted-foreground">
|
<div className="text-xl font-medium text-muted-foreground">
|
||||||
No templates found
|
{showBookmarksOnly
|
||||||
|
? "No bookmarked templates found"
|
||||||
|
: "No templates found"}
|
||||||
</div>
|
</div>
|
||||||
|
{showBookmarksOnly && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Click the bookmark icon on templates to add them to
|
||||||
|
bookmarks
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -323,9 +394,25 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
viewMode === "detailed" && "h-[400px]",
|
viewMode === "detailed" && "h-[400px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Badge className="absolute top-2 right-2" variant="blue">
|
<div className="absolute top-2 left-2 z-10">
|
||||||
{template?.version}
|
<Button
|
||||||
</Badge>
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background"
|
||||||
|
onClick={(e) => handleToggleBookmark(e, template.id)}
|
||||||
|
>
|
||||||
|
<Bookmark
|
||||||
|
className={cn(
|
||||||
|
"size-4",
|
||||||
|
bookmarkIds.includes(template.id) &&
|
||||||
|
"fill-yellow-400 text-yellow-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<Badge variant="blue">{template?.version}</Badge>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
|
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
|
||||||
|
|||||||
@@ -298,7 +298,19 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
|
|||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="flex items-center gap-2 w-full justify-end">
|
<div className="flex items-center gap-2 w-full justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={stepper.prev}
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
stepper.current.id === "variant" &&
|
||||||
|
templateInfo.details
|
||||||
|
) {
|
||||||
|
setTemplateInfo((prev) => ({
|
||||||
|
...prev,
|
||||||
|
details: null,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stepper.prev();
|
||||||
|
}}
|
||||||
disabled={stepper.isFirst}
|
disabled={stepper.isFirst}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -88,7 +88,12 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
|
if (
|
||||||
|
(e.ctrlKey || e.metaKey) &&
|
||||||
|
e.code === "KeyS" &&
|
||||||
|
!isPending &&
|
||||||
|
isOpen
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,12 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
|
if (
|
||||||
|
(e.ctrlKey || e.metaKey) &&
|
||||||
|
e.code === "KeyS" &&
|
||||||
|
!isPending &&
|
||||||
|
isOpen
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export const ShowProjects = () => {
|
|||||||
return (
|
return (
|
||||||
total +
|
total +
|
||||||
(env.applications?.length || 0) +
|
(env.applications?.length || 0) +
|
||||||
|
(env.libsql?.length || 0) +
|
||||||
(env.mariadb?.length || 0) +
|
(env.mariadb?.length || 0) +
|
||||||
(env.mongo?.length || 0) +
|
(env.mongo?.length || 0) +
|
||||||
(env.mysql?.length || 0) +
|
(env.mysql?.length || 0) +
|
||||||
@@ -178,6 +179,7 @@ export const ShowProjects = () => {
|
|||||||
return (
|
return (
|
||||||
total +
|
total +
|
||||||
(env.applications?.length || 0) +
|
(env.applications?.length || 0) +
|
||||||
|
(env.libsql?.length || 0) +
|
||||||
(env.mariadb?.length || 0) +
|
(env.mariadb?.length || 0) +
|
||||||
(env.mongo?.length || 0) +
|
(env.mongo?.length || 0) +
|
||||||
(env.mysql?.length || 0) +
|
(env.mysql?.length || 0) +
|
||||||
@@ -342,7 +344,7 @@ export const ShowProjects = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border flex flex-col">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
||||||
<span className="flex flex-col gap-1.5 ">
|
<span className="flex flex-col gap-1.5 ">
|
||||||
@@ -489,7 +491,7 @@ export const ShowProjects = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="pt-4">
|
<CardFooter className="pt-4 mt-auto">
|
||||||
<div className="space-y-1 text-xs flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
|
<div className="space-y-1 text-xs flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
|
||||||
<DateTooltip date={project.createdAt}>
|
<DateTooltip date={project.createdAt}>
|
||||||
Created
|
Created
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
|
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -9,6 +11,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
||||||
const { data } = api.redis.one.useQuery({ redisId });
|
const { data } = api.redis.one.useQuery({ redisId });
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { mutateAsync: changePassword } =
|
||||||
|
api.redis.changePassword.useMutation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -24,11 +29,21 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
value={data?.databasePassword}
|
value={data?.databasePassword}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
<UpdateDatabasePassword
|
||||||
|
onUpdatePassword={async (newPassword) => {
|
||||||
|
await changePassword({
|
||||||
|
redisId,
|
||||||
|
password: newPassword,
|
||||||
|
});
|
||||||
|
toast.success("Password updated successfully");
|
||||||
|
utils.redis.one.invalidate({ redisId });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -79,8 +79,11 @@ export const columns: ColumnDef<LogEntry>[] = [
|
|||||||
: log.RequestPath}
|
: log.RequestPath}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-3 w-full">
|
<div className="flex flex-row gap-3 w-full">
|
||||||
<Badge variant={getStatusColor(log.OriginStatus)}>
|
<Badge
|
||||||
Status: {formatStatusLabel(log.OriginStatus)}
|
variant={getStatusColor(log.OriginStatus || log.DownstreamStatus)}
|
||||||
|
>
|
||||||
|
Status:{" "}
|
||||||
|
{formatStatusLabel(log.OriginStatus || log.DownstreamStatus)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={"secondary"}>
|
<Badge variant={"secondary"}>
|
||||||
Exec Time: {formatDuration(log.Duration)}
|
Exec Time: {formatDuration(log.Duration)}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
|||||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||||
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Filter by name..."
|
placeholder="Filter by hostname..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
className="md:max-w-sm"
|
className="md:max-w-sm"
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const SearchCommand = () => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
|
if (e.code === "KeyJ" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen((open) => !open);
|
setOpen((open) => !open);
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ export const SearchCommand = () => {
|
|||||||
<CommandGroup heading={"Application"} hidden={true}>
|
<CommandGroup heading={"Application"} hidden={true}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/home");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { loadStripe } from "@stripe/stripe-js";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Bell,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -23,8 +25,18 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { NumberInput } from "@/components/ui/input";
|
import { NumberInput } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -89,6 +101,8 @@ export const ShowBilling = () => {
|
|||||||
api.stripe.createCustomerPortalSession.useMutation();
|
api.stripe.createCustomerPortalSession.useMutation();
|
||||||
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
|
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
|
||||||
api.stripe.upgradeSubscription.useMutation();
|
api.stripe.upgradeSubscription.useMutation();
|
||||||
|
const { mutateAsync: updateInvoiceNotifications } =
|
||||||
|
api.stripe.updateInvoiceNotifications.useMutation();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||||
@@ -141,6 +155,7 @@ export const ShowBilling = () => {
|
|||||||
return isAnnual ? interval === "year" : interval === "month";
|
return isAnnual ? interval === "year" : interval === "month";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
|
||||||
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);
|
||||||
@@ -149,14 +164,66 @@ export const ShowBilling = () => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md">
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-start justify-between">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<div>
|
||||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
Billing
|
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||||
</CardTitle>
|
Billing
|
||||||
<CardDescription>
|
</CardTitle>
|
||||||
Manage your subscription and invoices
|
<CardDescription>
|
||||||
</CardDescription>
|
Manage your subscription and invoices
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Bell className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Notification Settings</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure your billing email notifications.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="invoice-notifications">
|
||||||
|
Invoice Notifications
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Receive email notifications for payments and failed
|
||||||
|
charges.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="invoice-notifications"
|
||||||
|
checked={admin?.user.sendInvoiceNotifications ?? false}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
await updateInvoiceNotifications({
|
||||||
|
enabled: checked,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
utils.user.get.invalidate();
|
||||||
|
toast.success(
|
||||||
|
checked
|
||||||
|
? "Invoice notifications enabled"
|
||||||
|
: "Invoice notifications disabled",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(
|
||||||
|
"Failed to update invoice notifications",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 py-4 border-t">
|
<CardContent className="space-y-4 py-4 border-t">
|
||||||
<nav className="flex space-x-2 border-b">
|
<nav className="flex space-x-2 border-b">
|
||||||
@@ -182,7 +249,7 @@ 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">
|
||||||
{admin?.user.stripeSubscriptionId && (
|
{(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>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -203,8 +270,36 @@ export const ShowBilling = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isEnterpriseCloud && (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
||||||
|
<ShieldCheck className="h-6 w-6 text-primary shrink-0 mt-0.5" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
|
Enterprise Cloud Plan
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your organization is on a managed Enterprise plan. Billing
|
||||||
|
is handled separately — contact your account manager for
|
||||||
|
any changes.
|
||||||
|
</p>
|
||||||
|
{admin?.user.stripeCustomerId && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-fit mt-2"
|
||||||
|
onClick={async () => {
|
||||||
|
const session = await createCustomerPortalSession();
|
||||||
|
window.open(session.url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage Subscription
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */}
|
{/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */}
|
||||||
{useNewPricing &&
|
{!isEnterpriseCloud &&
|
||||||
|
useNewPricing &&
|
||||||
data?.currentPlan === "legacy" &&
|
data?.currentPlan === "legacy" &&
|
||||||
data?.subscriptions?.length > 0 && (
|
data?.subscriptions?.length > 0 && (
|
||||||
<div className="rounded-xl border border-border bg-primary/5 p-4 space-y-4 max-w-2xl">
|
<div className="rounded-xl border border-border bg-primary/5 p-4 space-y-4 max-w-2xl">
|
||||||
@@ -394,7 +489,8 @@ export const ShowBilling = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */}
|
{/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */}
|
||||||
{useNewPricing &&
|
{!isEnterpriseCloud &&
|
||||||
|
useNewPricing &&
|
||||||
(data?.currentPlan === "hobby" ||
|
(data?.currentPlan === "hobby" ||
|
||||||
data?.currentPlan === "startup") &&
|
data?.currentPlan === "startup") &&
|
||||||
data?.subscriptions?.length > 0 && (
|
data?.subscriptions?.length > 0 && (
|
||||||
@@ -779,17 +875,18 @@ export const ShowBilling = () => {
|
|||||||
Manage Subscription
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(data?.subscriptions?.length ?? 0) === 0 && (
|
{!isEnterpriseCloud &&
|
||||||
<Button
|
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||||
className="w-full"
|
<Button
|
||||||
onClick={() =>
|
className="w-full"
|
||||||
handleCheckout("hobby", data!.hobbyProductId!)
|
onClick={() =>
|
||||||
}
|
handleCheckout("hobby", data!.hobbyProductId!)
|
||||||
disabled={hobbyServerQuantity < 1}
|
}
|
||||||
>
|
disabled={hobbyServerQuantity < 1}
|
||||||
Get Started
|
>
|
||||||
</Button>
|
Get Started
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -923,22 +1020,24 @@ export const ShowBilling = () => {
|
|||||||
Manage Subscription
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(data?.subscriptions?.length ?? 0) === 0 && (
|
{!isEnterpriseCloud &&
|
||||||
<Button
|
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||||
className="w-full"
|
<Button
|
||||||
onClick={() =>
|
className="w-full"
|
||||||
handleCheckout(
|
onClick={() =>
|
||||||
"startup",
|
handleCheckout(
|
||||||
data!.startupProductId!,
|
"startup",
|
||||||
)
|
data!.startupProductId!,
|
||||||
}
|
)
|
||||||
disabled={
|
}
|
||||||
startupServerQuantity < STARTUP_SERVERS_INCLUDED
|
disabled={
|
||||||
}
|
startupServerQuantity <
|
||||||
>
|
STARTUP_SERVERS_INCLUDED
|
||||||
Get Started
|
}
|
||||||
</Button>
|
>
|
||||||
)}
|
Get Started
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1143,17 +1242,18 @@ export const ShowBilling = () => {
|
|||||||
Manage Subscription
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(data?.subscriptions?.length ?? 0) === 0 && (
|
{!isEnterpriseCloud &&
|
||||||
<Button
|
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||||
className="w-full"
|
<Button
|
||||||
onClick={async () => {
|
className="w-full"
|
||||||
handleCheckout("legacy", product.id);
|
onClick={async () => {
|
||||||
}}
|
handleCheckout("legacy", product.id);
|
||||||
disabled={hobbyServerQuantity < 1}
|
}}
|
||||||
>
|
disabled={hobbyServerQuantity < 1}
|
||||||
Subscribe
|
>
|
||||||
</Button>
|
Subscribe
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { HelpCircle, PlusIcon } from "lucide-react";
|
import { HelpCircle, PlusIcon, SquarePen } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -47,108 +47,157 @@ const certificateDataHolder =
|
|||||||
const privateKeyDataHolder =
|
const privateKeyDataHolder =
|
||||||
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
|
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
|
||||||
|
|
||||||
const addCertificate = z.object({
|
const handleCertificateSchema = z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
certificateData: z.string().min(1, "Certificate data is required"),
|
certificateData: z.string().min(1, "Certificate data is required"),
|
||||||
privateKey: z.string().min(1, "Private key is required"),
|
privateKey: z.string().min(1, "Private key is required"),
|
||||||
autoRenew: z.boolean().optional(),
|
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddCertificate = z.infer<typeof addCertificate>;
|
type HandleCertificateForm = z.infer<typeof handleCertificateSchema>;
|
||||||
|
|
||||||
export const AddCertificate = () => {
|
interface Props {
|
||||||
|
certificateId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandleCertificate = ({ certificateId }: Props) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { mutateAsync, isError, error, isPending } =
|
|
||||||
api.certificates.create.useMutation();
|
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
const hasServers = servers && servers.length > 0;
|
const hasServers = servers && servers.length > 0;
|
||||||
// Show dropdown logic based on cloud environment
|
const shouldShowServerDropdown = hasServers && !certificateId; // Hide on edit
|
||||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
|
||||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
|
||||||
const shouldShowServerDropdown = hasServers;
|
|
||||||
|
|
||||||
const form = useForm<AddCertificate>({
|
const { data: existingCert, refetch } = api.certificates.one.useQuery(
|
||||||
|
{ certificateId: certificateId || "" },
|
||||||
|
{ enabled: !!certificateId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMutation = api.certificates.create.useMutation();
|
||||||
|
const updateMutation = api.certificates.update.useMutation();
|
||||||
|
const mutation = certificateId ? updateMutation : createMutation;
|
||||||
|
const { mutateAsync, isError, error, isPending } = mutation;
|
||||||
|
|
||||||
|
const form = useForm<HandleCertificateForm>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
certificateData: "",
|
certificateData: "",
|
||||||
privateKey: "",
|
privateKey: "",
|
||||||
autoRenew: false,
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addCertificate),
|
resolver: zodResolver(handleCertificateSchema),
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
|
||||||
form.reset();
|
|
||||||
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: AddCertificate) => {
|
useEffect(() => {
|
||||||
await mutateAsync({
|
if (existingCert) {
|
||||||
|
form.reset({
|
||||||
|
name: existingCert.name,
|
||||||
|
certificateData: existingCert.certificateData,
|
||||||
|
privateKey: existingCert.privateKey,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.reset({
|
||||||
|
name: "",
|
||||||
|
certificateData: "",
|
||||||
|
privateKey: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [existingCert, form, open]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: HandleCertificateForm) => {
|
||||||
|
const basePayload = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
certificateData: data.certificateData,
|
certificateData: data.certificateData,
|
||||||
privateKey: data.privateKey,
|
privateKey: data.privateKey,
|
||||||
autoRenew: data.autoRenew,
|
};
|
||||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
|
||||||
organizationId: "",
|
const promise = certificateId
|
||||||
})
|
? updateMutation.mutateAsync({
|
||||||
|
certificateId,
|
||||||
|
...basePayload,
|
||||||
|
})
|
||||||
|
: createMutation.mutateAsync({
|
||||||
|
...basePayload,
|
||||||
|
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||||
|
organizationId: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await promise
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Certificate Created");
|
toast.success(
|
||||||
|
certificateId ? "Certificate Updated" : "Certificate Created",
|
||||||
|
);
|
||||||
await utils.certificates.all.invalidate();
|
await utils.certificates.all.invalidate();
|
||||||
|
if (certificateId) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error creating the Certificate");
|
toast.error(
|
||||||
|
certificateId
|
||||||
|
? "Error updating the Certificate"
|
||||||
|
: "Error creating the Certificate",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger className="" asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
{certificateId ? (
|
||||||
{" "}
|
<Button
|
||||||
<PlusIcon className="h-4 w-4" />
|
variant="ghost"
|
||||||
Add Certificate
|
size="icon"
|
||||||
</Button>
|
className="group hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<SquarePen className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
Add Certificate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add New Certificate</DialogTitle>
|
<DialogTitle>
|
||||||
|
{certificateId ? "Update" : "Add New"} Certificate
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Upload or generate a certificate to secure your application
|
{certificateId
|
||||||
|
? "Modify the certificate details"
|
||||||
|
: "Upload or generate a certificate to secure your application"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
id="hook-form-add-certificate"
|
id="hook-form-handle-certificate"
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid w-full gap-4 "
|
className="grid w-full gap-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => {
|
render={({ field }) => (
|
||||||
return (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel>Certificate Name</FormLabel>
|
||||||
<FormLabel>Certificate Name</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<Input placeholder="My Certificate" {...field} />
|
||||||
<Input placeholder={"My Certificate"} {...field} />
|
</FormControl>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
<FormMessage />
|
</FormItem>
|
||||||
</FormItem>
|
)}
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="certificateData"
|
name="certificateData"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="space-y-0.5">
|
<FormLabel>Certificate Data</FormLabel>
|
||||||
<FormLabel>Certificate Data</FormLabel>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="h-32"
|
className="h-32"
|
||||||
@@ -165,9 +214,7 @@ export const AddCertificate = () => {
|
|||||||
name="privateKey"
|
name="privateKey"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="space-y-0.5">
|
<FormLabel>Private Key</FormLabel>
|
||||||
<FormLabel>Private Key</FormLabel>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="h-32"
|
className="h-32"
|
||||||
@@ -248,10 +295,10 @@ export const AddCertificate = () => {
|
|||||||
<DialogFooter className="flex w-full flex-row !justify-end">
|
<DialogFooter className="flex w-full flex-row !justify-end">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
form="hook-form-add-certificate"
|
form="hook-form-handle-certificate"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Create
|
{certificateId ? "Update" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Link,
|
Link,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AddCertificate } from "./add-certificate";
|
import { HandleCertificate } from "./handle-certificate";
|
||||||
import {
|
import {
|
||||||
extractLeafCommonName,
|
extractLeafCommonName,
|
||||||
getCertificateChainExpirationDetails,
|
getCertificateChainExpirationDetails,
|
||||||
@@ -69,7 +70,7 @@ export const ShowCertificates = () => {
|
|||||||
<span className="text-base text-muted-foreground text-center">
|
<span className="text-base text-muted-foreground text-center">
|
||||||
You don't have any certificates created
|
You don't have any certificates created
|
||||||
</span>
|
</span>
|
||||||
{permissions?.certificate.create && <AddCertificate />}
|
{permissions?.certificate.create && <HandleCertificate />}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
@@ -121,6 +122,12 @@ export const ShowCertificates = () => {
|
|||||||
CN: {commonName}
|
CN: {commonName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Server className="size-3" />
|
||||||
|
{certificate.server
|
||||||
|
? `${certificate.server.name} (${certificate.server.ipAddress})`
|
||||||
|
: "Dokploy (Local)"}
|
||||||
|
</span>
|
||||||
{chainInfo.isChain && (
|
{chainInfo.isChain && (
|
||||||
<div className="flex flex-col gap-1.5 mt-1">
|
<div className="flex flex-col gap-1.5 mt-1">
|
||||||
<button
|
<button
|
||||||
@@ -181,8 +188,14 @@ export const ShowCertificates = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{permissions?.certificate.delete && (
|
<div className="flex flex-row gap-1">
|
||||||
<div className="flex flex-row gap-1">
|
{permissions?.certificate.update && (
|
||||||
|
<HandleCertificate
|
||||||
|
certificateId={certificate.certificateId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{permissions?.certificate.delete && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete Certificate"
|
title="Delete Certificate"
|
||||||
description="Are you sure you want to delete this certificate?"
|
description="Are you sure you want to delete this certificate?"
|
||||||
@@ -208,14 +221,14 @@ export const ShowCertificates = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-red-500/10 "
|
className="group hover:bg-red-500/10"
|
||||||
isLoading={isRemoving}
|
isLoading={isRemoving}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -224,7 +237,7 @@ export const ShowCertificates = () => {
|
|||||||
|
|
||||||
{permissions?.certificate.create && (
|
{permissions?.certificate.create && (
|
||||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||||
<AddCertificate />
|
<HandleCertificate />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip the outer certificate sequence
|
// Skip the outer certificate sequence
|
||||||
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
|
if (der[offset++] !== 0x30) return null;
|
||||||
({ offset } = readLength(offset));
|
({ offset } = readLength(offset));
|
||||||
|
|
||||||
// Skip tbsCertificate sequence
|
// Skip tbsCertificate sequence
|
||||||
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
|
if (der[offset++] !== 0x30) return null;
|
||||||
({ offset } = readLength(offset));
|
({ offset } = readLength(offset));
|
||||||
|
|
||||||
// Check for optional version field (context-specific tag [0])
|
// Check for optional version field (context-specific tag [0])
|
||||||
@@ -52,15 +52,14 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
|||||||
|
|
||||||
// Skip serialNumber, signature, issuer
|
// Skip serialNumber, signature, issuer
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
if (der[offset] !== 0x30 && der[offset] !== 0x02)
|
if (der[offset] !== 0x30 && der[offset] !== 0x02) return null;
|
||||||
throw new Error("Unexpected structure");
|
|
||||||
offset++;
|
offset++;
|
||||||
const fieldLen = readLength(offset);
|
const fieldLen = readLength(offset);
|
||||||
offset = fieldLen.offset + fieldLen.length;
|
offset = fieldLen.offset + fieldLen.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validity sequence (notBefore and notAfter)
|
// Validity sequence (notBefore and notAfter)
|
||||||
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
|
if (der[offset++] !== 0x30) return null;
|
||||||
const validityLen = readLength(offset);
|
const validityLen = readLength(offset);
|
||||||
offset = validityLen.offset;
|
offset = validityLen.offset;
|
||||||
|
|
||||||
@@ -138,11 +137,11 @@ export const extractCommonName = (certData: string): string | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip the outer certificate sequence
|
// Skip the outer certificate sequence
|
||||||
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
|
if (der[offset++] !== 0x30) return null;
|
||||||
({ offset } = readLength(offset));
|
({ offset } = readLength(offset));
|
||||||
|
|
||||||
// Skip tbsCertificate sequence
|
// Skip tbsCertificate sequence
|
||||||
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
|
if (der[offset++] !== 0x30) return null;
|
||||||
({ offset } = readLength(offset));
|
({ offset } = readLength(offset));
|
||||||
|
|
||||||
// Check for optional version field (context-specific tag [0])
|
// Check for optional version field (context-specific tag [0])
|
||||||
@@ -165,7 +164,7 @@ export const extractCommonName = (certData: string): string | null => {
|
|||||||
offset = skipField(offset);
|
offset = skipField(offset);
|
||||||
|
|
||||||
// Subject sequence - where we find the CN
|
// Subject sequence - where we find the CN
|
||||||
if (der[offset++] !== 0x30) throw new Error("Expected subject sequence");
|
if (der[offset++] !== 0x30) return null;
|
||||||
const subjectLen = readLength(offset);
|
const subjectLen = readLength(offset);
|
||||||
const subjectEnd = subjectLen.offset + subjectLen.length;
|
const subjectEnd = subjectLen.offset + subjectLen.length;
|
||||||
offset = subjectLen.offset;
|
offset = subjectLen.offset;
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Loader2,
|
||||||
|
PenBoxIcon,
|
||||||
|
Plug,
|
||||||
|
PlusIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -37,10 +44,34 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const AI_PROVIDERS = [
|
||||||
|
{ name: "OpenAI", apiUrl: "https://api.openai.com/v1" },
|
||||||
|
{ name: "Anthropic", apiUrl: "https://api.anthropic.com/v1" },
|
||||||
|
{
|
||||||
|
name: "Google Gemini",
|
||||||
|
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
},
|
||||||
|
{ name: "Mistral", apiUrl: "https://api.mistral.ai/v1" },
|
||||||
|
{ name: "Cohere", apiUrl: "https://api.cohere.ai/v2" },
|
||||||
|
{ name: "Perplexity", apiUrl: "https://api.perplexity.ai" },
|
||||||
|
{ name: "DeepInfra", apiUrl: "https://api.deepinfra.com/v1/openai" },
|
||||||
|
{ name: "Ollama", apiUrl: "http://localhost:11434" },
|
||||||
|
{ name: "OpenRouter", apiUrl: "https://openrouter.ai/api/v1" },
|
||||||
|
{ name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4" },
|
||||||
|
{ name: "MiniMax", apiUrl: "https://api.minimax.io/v1" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
name: z.string().min(1, { message: "Name is required" }),
|
name: z.string().min(1, { message: "Name is required" }),
|
||||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||||
@@ -103,7 +134,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
|
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
|
||||||
const {
|
const {
|
||||||
data: models,
|
data: models,
|
||||||
isPending: isLoadingServerModels,
|
isFetching: isLoadingServerModels,
|
||||||
error: modelsError,
|
error: modelsError,
|
||||||
} = api.ai.getModels.useQuery(
|
} = api.ai.getModels.useQuery(
|
||||||
{
|
{
|
||||||
@@ -172,6 +203,34 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
<AlertBlock type="error">{modelsError.message}</AlertBlock>
|
<AlertBlock type="error">{modelsError.message}</AlertBlock>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FormLabel>Provider</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
|
||||||
|
if (provider) {
|
||||||
|
form.setValue("name", provider.name);
|
||||||
|
form.setValue("apiUrl", provider.apiUrl);
|
||||||
|
form.setValue("model", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a provider preset..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AI_PROVIDERS.map((provider) => (
|
||||||
|
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
|
||||||
|
{provider.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
Quick-fill provider name and URL, or configure manually below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -253,101 +312,129 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoadingServerModels && !models?.length && (
|
<FormField
|
||||||
<span className="text-sm text-muted-foreground">
|
control={form.control}
|
||||||
No models available
|
name="model"
|
||||||
</span>
|
render={({ field }) => {
|
||||||
)}
|
const hasModels =
|
||||||
|
!isLoadingServerModels && models && models.length > 0;
|
||||||
|
const selectedModel = models?.find((m) => m.id === field.value);
|
||||||
|
const filteredModels = (models ?? []).filter((model) =>
|
||||||
|
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
{!isLoadingServerModels && models && models.length > 0 && (
|
const displayModels =
|
||||||
<FormField
|
field.value &&
|
||||||
control={form.control}
|
!filteredModels.find((m) => m.id === field.value) &&
|
||||||
name="model"
|
selectedModel
|
||||||
render={({ field }) => {
|
? [selectedModel, ...filteredModels]
|
||||||
const selectedModel = models.find(
|
: filteredModels;
|
||||||
(m) => m.id === field.value,
|
|
||||||
);
|
|
||||||
const filteredModels = models.filter((model) =>
|
|
||||||
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ensure selected model is always in the filtered list
|
return (
|
||||||
const displayModels =
|
<FormItem>
|
||||||
field.value &&
|
<FormLabel>Model</FormLabel>
|
||||||
!filteredModels.find((m) => m.id === field.value) &&
|
<div className="flex gap-2">
|
||||||
selectedModel
|
<div className="flex-1">
|
||||||
? [selectedModel, ...filteredModels]
|
{hasModels ? (
|
||||||
: filteredModels;
|
<Popover
|
||||||
|
open={modelPopoverOpen}
|
||||||
return (
|
onOpenChange={setModelPopoverOpen}
|
||||||
<FormItem>
|
>
|
||||||
<FormLabel>Model</FormLabel>
|
<PopoverTrigger asChild>
|
||||||
<Popover
|
<FormControl>
|
||||||
open={modelPopoverOpen}
|
<Button
|
||||||
onOpenChange={setModelPopoverOpen}
|
variant="outline"
|
||||||
>
|
className={cn(
|
||||||
<PopoverTrigger asChild>
|
"w-full justify-between",
|
||||||
<FormControl>
|
!field.value && "text-muted-foreground",
|
||||||
<Button
|
)}
|
||||||
variant="outline"
|
>
|
||||||
className={cn(
|
{field.value
|
||||||
"w-full justify-between",
|
? (selectedModel?.id ?? field.value)
|
||||||
!field.value && "text-muted-foreground",
|
: "Select a model"}
|
||||||
)}
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[400px] p-0"
|
||||||
|
align="start"
|
||||||
>
|
>
|
||||||
{field.value
|
<Command>
|
||||||
? (selectedModel?.id ?? field.value)
|
<CommandInput
|
||||||
: "Select a model"}
|
placeholder="Search or type a custom model..."
|
||||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
value={modelSearch}
|
||||||
</Button>
|
onValueChange={setModelSearch}
|
||||||
</FormControl>
|
/>
|
||||||
</PopoverTrigger>
|
<CommandList>
|
||||||
<PopoverContent className="w-[400px] p-0" align="start">
|
<CommandEmpty>
|
||||||
<Command>
|
{modelSearch ? (
|
||||||
<CommandInput
|
<button
|
||||||
placeholder="Search models..."
|
type="button"
|
||||||
value={modelSearch}
|
className="w-full cursor-pointer px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||||
onValueChange={setModelSearch}
|
onClick={() => {
|
||||||
|
field.onChange(modelSearch);
|
||||||
|
setModelPopoverOpen(false);
|
||||||
|
setModelSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use custom model: "{modelSearch}"
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
"No models found."
|
||||||
|
)}
|
||||||
|
</CommandEmpty>
|
||||||
|
{displayModels.map((model) => {
|
||||||
|
const isSelected = field.value === model.id;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={model.id}
|
||||||
|
value={model.id}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(model.id);
|
||||||
|
setModelPopoverOpen(false);
|
||||||
|
setModelSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
isSelected
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{model.id}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
isLoadingServerModels
|
||||||
|
? "Loading models..."
|
||||||
|
: "Enter model name (e.g. gpt-4o)"
|
||||||
|
}
|
||||||
|
disabled={isLoadingServerModels}
|
||||||
|
{...field}
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
</FormControl>
|
||||||
<CommandEmpty>No models found.</CommandEmpty>
|
)}
|
||||||
{displayModels.map((model) => {
|
</div>
|
||||||
const isSelected = field.value === model.id;
|
</div>
|
||||||
return (
|
<FormDescription>
|
||||||
<CommandItem
|
Select a model from the list or type a custom model name
|
||||||
key={model.id}
|
</FormDescription>
|
||||||
value={model.id}
|
<FormMessage />
|
||||||
onSelect={() => {
|
</FormItem>
|
||||||
field.onChange(model.id);
|
);
|
||||||
setModelPopoverOpen(false);
|
}}
|
||||||
setModelSearch("");
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
isSelected
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{model.id}
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormDescription>
|
|
||||||
Select an AI model to use
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -372,7 +459,12 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<TestConnectionButton
|
||||||
|
apiUrl={apiUrl}
|
||||||
|
apiKey={apiKey}
|
||||||
|
model={form.watch("model")}
|
||||||
|
/>
|
||||||
<Button type="submit" isLoading={isPending}>
|
<Button type="submit" isLoading={isPending}>
|
||||||
{aiId ? "Update" : "Create"}
|
{aiId ? "Update" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -383,3 +475,42 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function TestConnectionButton({
|
||||||
|
apiUrl,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
}: {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
}) {
|
||||||
|
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Connection successful");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Connection failed", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDisabled = !apiUrl || !model;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isDisabled || isPending}
|
||||||
|
onClick={() => mutate({ apiUrl, apiKey, model })}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plug className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,11 +25,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 {
|
import { InputOTP } from "@/components/ui/input-otp";
|
||||||
InputOTP,
|
|
||||||
InputOTPGroup,
|
|
||||||
InputOTPSlot,
|
|
||||||
} from "@/components/ui/input-otp";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -423,23 +419,14 @@ export const Enable2FA = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col justify-center items-center">
|
<div className="flex flex-col gap-2">
|
||||||
<FormLabel>Verification Code</FormLabel>
|
<FormLabel>Verification Code</FormLabel>
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={otpValue}
|
value={otpValue}
|
||||||
onChange={setOtpValue}
|
onChange={setOtpValue}
|
||||||
autoComplete="off"
|
autoFocus
|
||||||
>
|
/>
|
||||||
<InputOTPGroup>
|
|
||||||
<InputOTPSlot index={0} />
|
|
||||||
<InputOTPSlot index={1} />
|
|
||||||
<InputOTPSlot index={2} />
|
|
||||||
<InputOTPSlot index={3} />
|
|
||||||
<InputOTPSlot index={4} />
|
|
||||||
<InputOTPSlot index={5} />
|
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Enter the 6-digit code from your authenticator app
|
Enter the 6-digit code from your authenticator app
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -52,7 +59,36 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Switch checked={!!enabled} onCheckedChange={handleToggle} />
|
<Switch checked={!!enabled} onCheckedChange={handleToggle} />
|
||||||
<Label className="text-primary">Daily Docker Cleanup</Label>
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
|
||||||
|
Daily Docker Cleanup
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</Label>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-sm">
|
||||||
|
<p>
|
||||||
|
Runs a full Docker cleanup daily, pruning stopped containers,
|
||||||
|
unused images, volumes, build cache, and system resources. This
|
||||||
|
may remove images built for Compose services that run on-demand
|
||||||
|
(backup runners, cron jobs, one-off tasks).
|
||||||
|
</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
For custom cleanup strategies, use{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.dokploy.com/docs/core/schedule-jobs#example-1-automatic-docker-cleanup"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline text-primary"
|
||||||
|
>
|
||||||
|
Schedule Jobs
|
||||||
|
</a>{" "}
|
||||||
|
on your web server or remote servers.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ShowSwarmContainers } from "../../swarm/containers/show-swarm-containers";
|
||||||
import SwarmMonitorCard from "../../swarm/monitoring-card";
|
import SwarmMonitorCard from "../../swarm/monitoring-card";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -21,9 +24,24 @@ export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-7xl ">
|
<DialogContent className="sm:max-w-7xl ">
|
||||||
<div className="grid w-full gap-1">
|
<Tabs defaultValue="overview">
|
||||||
<SwarmMonitorCard serverId={serverId} />
|
<TabsList>
|
||||||
</div>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<div className="grid w-full gap-1">
|
||||||
|
<SwarmMonitorCard serverId={serverId} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="containers">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||||
|
<div className="rounded-xl bg-background shadow-md p-6">
|
||||||
|
<ShowSwarmContainers serverId={serverId} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ export const WelcomeSubscription = () => {
|
|||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
const stepper = useStepper();
|
const stepper = useStepper();
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
const { push } = useRouter();
|
const router = useRouter();
|
||||||
|
const { push } = router;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const confettiShown = localStorage.getItem("hasShownConfetti");
|
const confettiShown = localStorage.getItem("hasShownConfetti");
|
||||||
@@ -66,7 +67,22 @@ export const WelcomeSubscription = () => {
|
|||||||
}, [showConfetti]);
|
}, [showConfetti]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen}>
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
const { success, ...rest } = router.query;
|
||||||
|
router.replace(
|
||||||
|
{ pathname: router.pathname, query: rest },
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
shallow: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
||||||
{showConfetti ?? "Flaso"}
|
{showConfetti ?? "Flaso"}
|
||||||
<div className="flex justify-center items-center w-full">
|
<div className="flex justify-center items-center w-full">
|
||||||
@@ -409,7 +425,7 @@ export const WelcomeSubscription = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (stepper.isLast) {
|
if (stepper.isLast) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
push("/dashboard/projects");
|
push("/dashboard/home");
|
||||||
} else {
|
} else {
|
||||||
stepper.next();
|
stepper.next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,14 +34,63 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const addInvitation = z.object({
|
const addInvitation = z
|
||||||
email: z
|
.object({
|
||||||
.string()
|
mode: z.enum(["invitation", "credentials"]),
|
||||||
.min(1, "Email is required")
|
email: z
|
||||||
.email({ message: "Invalid email" }),
|
.string()
|
||||||
role: z.string().min(1, "Role is required"),
|
.min(1, "Email is required")
|
||||||
notificationId: z.string().optional(),
|
.email({ message: "Invalid email" }),
|
||||||
});
|
role: z.string().min(1, "Role is required"),
|
||||||
|
notificationId: z.string().optional(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
confirmPassword: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.mode !== "credentials") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.password) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Password is required",
|
||||||
|
path: ["password"],
|
||||||
|
});
|
||||||
|
} else if (value.password.length < 8) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Password must be at least 8 characters",
|
||||||
|
path: ["password"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.confirmPassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Confirm password is required",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
} else if (value.confirmPassword.length < 8) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Password must be at least 8 characters",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value.password &&
|
||||||
|
value.confirmPassword &&
|
||||||
|
value.password !== value.confirmPassword
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type AddInvitation = z.infer<typeof addInvitation>;
|
type AddInvitation = z.infer<typeof addInvitation>;
|
||||||
|
|
||||||
@@ -54,50 +103,83 @@ export const AddInvitation = () => {
|
|||||||
const { mutateAsync: inviteMember, isPending: isInviting } =
|
const { mutateAsync: inviteMember, isPending: isInviting } =
|
||||||
api.organization.inviteMember.useMutation();
|
api.organization.inviteMember.useMutation();
|
||||||
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
||||||
|
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
|
||||||
|
api.user.createUserWithCredentials.useMutation();
|
||||||
const { data: customRoles } = api.customRole.all.useQuery();
|
const { data: customRoles } = api.customRole.all.useQuery();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const form = useForm<AddInvitation>({
|
const form = useForm<AddInvitation>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
mode: "invitation",
|
||||||
email: "",
|
email: "",
|
||||||
role: "member",
|
role: "member",
|
||||||
notificationId: "",
|
notificationId: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addInvitation),
|
resolver: zodResolver(addInvitation),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mode = form.watch("mode");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset();
|
form.reset();
|
||||||
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddInvitation) => {
|
useEffect(() => {
|
||||||
try {
|
if (isCloud && form.getValues("mode") === "credentials") {
|
||||||
const result = await inviteMember({
|
form.setValue("mode", "invitation");
|
||||||
email: data.email.toLowerCase(),
|
|
||||||
role: data.role,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isCloud && data.notificationId) {
|
|
||||||
await sendInvitation({
|
|
||||||
invitationId: result!.id,
|
|
||||||
notificationId: data.notificationId || "",
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Invitation created and email sent");
|
|
||||||
})
|
|
||||||
.catch((error: any) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast.success("Invitation created");
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
setOpen(false);
|
|
||||||
} catch (error: any) {
|
|
||||||
setError(error.message || "Failed to create invitation");
|
|
||||||
}
|
}
|
||||||
|
}, [form, isCloud]);
|
||||||
|
|
||||||
utils.organization.allInvitations.invalidate();
|
const onSubmit = async (data: AddInvitation) => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (data.mode === "credentials") {
|
||||||
|
await createUserWithCredentials({
|
||||||
|
email: data.email.toLowerCase(),
|
||||||
|
password: data.password!,
|
||||||
|
role: data.role,
|
||||||
|
});
|
||||||
|
toast.success("User created with initial credentials");
|
||||||
|
setOpen(false);
|
||||||
|
} else {
|
||||||
|
const result = await inviteMember({
|
||||||
|
email: data.email.toLowerCase(),
|
||||||
|
role: data.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isCloud && data.notificationId) {
|
||||||
|
await sendInvitation({
|
||||||
|
invitationId: result!.id,
|
||||||
|
notificationId: data.notificationId || "",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Invitation created and email sent");
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success("Invitation created");
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to create user";
|
||||||
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
await Promise.all([
|
||||||
|
utils.organization.allInvitations.invalidate(),
|
||||||
|
utils.user.all.invalidate(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger className="" asChild>
|
<DialogTrigger className="" asChild>
|
||||||
@@ -108,7 +190,11 @@ export const AddInvitation = () => {
|
|||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Invitation</DialogTitle>
|
<DialogTitle>Add Invitation</DialogTitle>
|
||||||
<DialogDescription>Invite a new user</DialogDescription>
|
<DialogDescription>
|
||||||
|
{mode === "credentials"
|
||||||
|
? "Create a user with initial credentials"
|
||||||
|
: "Invite a new user"}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{error && <AlertBlock type="error">{error}</AlertBlock>}
|
{error && <AlertBlock type="error">{error}</AlertBlock>}
|
||||||
|
|
||||||
@@ -118,6 +204,43 @@ export const AddInvitation = () => {
|
|||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid w-full gap-4 "
|
className="grid w-full gap-4 "
|
||||||
>
|
>
|
||||||
|
{!isCloud && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="mode"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Invite Method</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select invite method" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="invitation">
|
||||||
|
Invitation Link
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="credentials">
|
||||||
|
Initial Credentials
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Choose between invitation link flow or direct
|
||||||
|
credentials provisioning
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
@@ -172,7 +295,7 @@ export const AddInvitation = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isCloud && (
|
{!isCloud && mode === "invitation" && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="notificationId"
|
name="notificationId"
|
||||||
@@ -212,9 +335,57 @@ export const AddInvitation = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isCloud && mode === "credentials" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter initial password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The user can sign in with this password immediately
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirmPassword"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Confirm Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm initial password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter className="flex w-full flex-row">
|
<DialogFooter className="flex w-full flex-row">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isInviting}
|
isLoading={isInviting || isCreating}
|
||||||
form="hook-form-add-invitation"
|
form="hook-form-add-invitation"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, 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 { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -26,7 +27,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
|
|
||||||
import { api, type RouterOutputs } from "@/utils/api";
|
import { api, type RouterOutputs } from "@/utils/api";
|
||||||
|
|
||||||
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
|
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
|
||||||
@@ -172,6 +172,7 @@ const addPermissions = z.object({
|
|||||||
accessedEnvironments: z.array(z.string()).optional(),
|
accessedEnvironments: z.array(z.string()).optional(),
|
||||||
accessedServices: z.array(z.string()).optional(),
|
accessedServices: z.array(z.string()).optional(),
|
||||||
accessedGitProviders: z.array(z.string()).optional(),
|
accessedGitProviders: z.array(z.string()).optional(),
|
||||||
|
accessedServers: z.array(z.string()).optional(),
|
||||||
canCreateProjects: z.boolean().optional().default(false),
|
canCreateProjects: z.boolean().optional().default(false),
|
||||||
canCreateServices: z.boolean().optional().default(false),
|
canCreateServices: z.boolean().optional().default(false),
|
||||||
canDeleteProjects: z.boolean().optional().default(false),
|
canDeleteProjects: z.boolean().optional().default(false),
|
||||||
@@ -208,6 +209,10 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: servers } = api.server.allForPermissions.useQuery(undefined, {
|
||||||
|
enabled: isOpen && !!haveValidLicense,
|
||||||
|
});
|
||||||
|
|
||||||
const { data, refetch } = api.user.one.useQuery(
|
const { data, refetch } = api.user.one.useQuery(
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
@@ -226,6 +231,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
|||||||
accessedEnvironments: [],
|
accessedEnvironments: [],
|
||||||
accessedServices: [],
|
accessedServices: [],
|
||||||
accessedGitProviders: [],
|
accessedGitProviders: [],
|
||||||
|
accessedServers: [],
|
||||||
canDeleteEnvironments: false,
|
canDeleteEnvironments: false,
|
||||||
canCreateProjects: false,
|
canCreateProjects: false,
|
||||||
canCreateServices: false,
|
canCreateServices: false,
|
||||||
@@ -248,6 +254,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
|||||||
accessedEnvironments: data.accessedEnvironments || [],
|
accessedEnvironments: data.accessedEnvironments || [],
|
||||||
accessedServices: data.accessedServices || [],
|
accessedServices: data.accessedServices || [],
|
||||||
accessedGitProviders: data.accessedGitProviders || [],
|
accessedGitProviders: data.accessedGitProviders || [],
|
||||||
|
accessedServers: data.accessedServers || [],
|
||||||
canCreateProjects: data.canCreateProjects,
|
canCreateProjects: data.canCreateProjects,
|
||||||
canCreateServices: data.canCreateServices,
|
canCreateServices: data.canCreateServices,
|
||||||
canDeleteProjects: data.canDeleteProjects,
|
canDeleteProjects: data.canDeleteProjects,
|
||||||
@@ -276,6 +283,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
|||||||
accessedEnvironments: data.accessedEnvironments || [],
|
accessedEnvironments: data.accessedEnvironments || [],
|
||||||
accessedServices: data.accessedServices || [],
|
accessedServices: data.accessedServices || [],
|
||||||
accessedGitProviders: data.accessedGitProviders || [],
|
accessedGitProviders: data.accessedGitProviders || [],
|
||||||
|
accessedServers: data.accessedServers || [],
|
||||||
canAccessToDocker: data.canAccessToDocker,
|
canAccessToDocker: data.canAccessToDocker,
|
||||||
canAccessToAPI: data.canAccessToAPI,
|
canAccessToAPI: data.canAccessToAPI,
|
||||||
canAccessToSSHKeys: data.canAccessToSSHKeys,
|
canAccessToSSHKeys: data.canAccessToSSHKeys,
|
||||||
@@ -956,6 +964,79 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{haveValidLicense ? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="accessedServers"
|
||||||
|
render={() => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<div className="mb-4">
|
||||||
|
<FormLabel className="text-base">Servers</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Select the Servers that the user can access
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
{servers?.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No servers found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid md:grid-cols-1 gap-2">
|
||||||
|
{servers?.map((s) => (
|
||||||
|
<FormField
|
||||||
|
key={s.serverId}
|
||||||
|
control={form.control}
|
||||||
|
name="accessedServers"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-lg border p-3">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value?.includes(s.serverId)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
field.onChange([
|
||||||
|
...(field.value || []),
|
||||||
|
s.serverId,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
field.onChange(
|
||||||
|
field.value?.filter(
|
||||||
|
(v) => v !== s.serverId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel className="text-sm cursor-pointer">
|
||||||
|
{s.name}
|
||||||
|
</FormLabel>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({s.ipAddress})
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground capitalize">
|
||||||
|
{s.serverType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<EnterpriseFeatureLocked
|
||||||
|
compact
|
||||||
|
title="Server Assignment"
|
||||||
|
description="Assign specific Servers to users with an Enterprise license."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2">
|
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
|
|||||||
@@ -141,14 +141,14 @@ export const WebDomain = () => {
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid w-full gap-4 md:grid-cols-2"
|
className="grid w-full gap-4 grid-cols-2"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="domain"
|
name="domain"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem className="col-span-2 md:col-span-1">
|
||||||
<FormLabel>Domain</FormLabel>
|
<FormLabel>Domain</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -168,7 +168,7 @@ export const WebDomain = () => {
|
|||||||
name="letsEncryptEmail"
|
name="letsEncryptEmail"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem className="col-span-2 md:col-span-1">
|
||||||
<FormLabel>Let's Encrypt Email</FormLabel>
|
<FormLabel>Let's Encrypt Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -209,7 +209,7 @@ export const WebDomain = () => {
|
|||||||
name="certificateType"
|
name="certificateType"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="col-span-2">
|
||||||
<FormLabel>Certificate Provider</FormLabel>
|
<FormLabel>Certificate Provider</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ServerIcon } from "lucide-react";
|
import { CopyIcon, ServerIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
|
import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
|
||||||
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
|
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
|
||||||
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";
|
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";
|
||||||
@@ -49,8 +51,17 @@ export const WebServer = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center flex-wrap justify-between gap-4">
|
<div className="flex items-center flex-wrap justify-between gap-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
|
||||||
Server IP: {webServerSettings?.serverIp}
|
Server IP: {webServerSettings?.serverIp}
|
||||||
|
{webServerSettings?.serverIp && (
|
||||||
|
<CopyIcon
|
||||||
|
className="size-3.5 cursor-pointer hover:text-foreground transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
copy(webServerSettings.serverIp ?? "");
|
||||||
|
toast.success("Copied to clipboard");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Version: {dokployVersion}
|
Version: {dokployVersion}
|
||||||
|
|||||||
@@ -87,7 +87,12 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && !canEdit) {
|
if (
|
||||||
|
(e.ctrlKey || e.metaKey) &&
|
||||||
|
e.code === "KeyS" &&
|
||||||
|
!isPending &&
|
||||||
|
!canEdit
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { AlertCircle, HardDrive, Network } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { TableCell, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { ContainerInfo, ContainerStat } from "./types";
|
||||||
|
import { formatCpu, formatIOValue, formatMemUsage } from "./utils";
|
||||||
|
|
||||||
|
interface ContainerRowProps {
|
||||||
|
container: ContainerInfo;
|
||||||
|
stat: ContainerStat | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContainerRow = ({ container, stat }: ContainerRowProps) => {
|
||||||
|
const isRunning = container.CurrentState.startsWith("Running");
|
||||||
|
const hasError = container.Error && container.Error.trim() !== "";
|
||||||
|
|
||||||
|
const stateBadge = (
|
||||||
|
<Badge
|
||||||
|
variant={hasError ? "destructive" : isRunning ? "default" : "destructive"}
|
||||||
|
>
|
||||||
|
{container.CurrentState}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium text-sm">{container.Name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[230px]">
|
||||||
|
{container.Image}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{hasError ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex items-center gap-1.5 cursor-help">
|
||||||
|
{stateBadge}
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<p className="text-xs font-medium">Error:</p>
|
||||||
|
<p className="text-xs">{container.Error}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
stateBadge
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{stat ? (
|
||||||
|
<span className="text-sm font-medium">{formatCpu(stat.CPUPerc)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">--</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{stat ? (
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{formatMemUsage(stat.MemUsage)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">--</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{stat ? (
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{formatIOValue(stat.BlockIO)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">--</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{stat ? (
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<Network className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{formatIOValue(stat.NetIO)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">--</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
ExternalLink,
|
||||||
|
Info,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ContainerInfo } from "./types";
|
||||||
|
|
||||||
|
export const DocLinks = () => (
|
||||||
|
<div className="flex flex-col gap-1 pt-2 border-t mt-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Helpful resources:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<a
|
||||||
|
href="https://docs.dokploy.com/docs/core"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Dokploy Documentation
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://docs.docker.com/engine/swarm/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Docker Swarm Guide
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/cluster"
|
||||||
|
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Cluster Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SwarmNotAvailableProps {
|
||||||
|
errorMessage?: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SwarmNotAvailable = ({
|
||||||
|
errorMessage,
|
||||||
|
onRetry,
|
||||||
|
}: SwarmNotAvailableProps) => (
|
||||||
|
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Swarm Not Available</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Could not reach Docker Swarm.{" "}
|
||||||
|
{errorMessage && (
|
||||||
|
<span className="block mt-1 text-xs opacity-80">{errorMessage}</span>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
This feature requires Docker Swarm to be initialized and active. To get
|
||||||
|
started:
|
||||||
|
</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 ml-1">
|
||||||
|
<li>
|
||||||
|
Initialize Swarm on your server:{" "}
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
|
||||||
|
docker swarm init
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Verify it's active:{" "}
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
|
||||||
|
docker info | grep Swarm
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Check the{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/cluster"
|
||||||
|
className="text-primary underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Cluster Settings
|
||||||
|
</Link>{" "}
|
||||||
|
page to manage your swarm nodes
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<DocLinks />
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="w-fit" onClick={onRetry}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ServicesErrorProps {
|
||||||
|
errorMessage?: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServicesError = ({
|
||||||
|
errorMessage,
|
||||||
|
onRetry,
|
||||||
|
}: ServicesErrorProps) => (
|
||||||
|
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Failed to Load Services</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Swarm is reachable but service listing failed.{" "}
|
||||||
|
{errorMessage && (
|
||||||
|
<span className="block mt-1 text-xs opacity-80">{errorMessage}</span>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<p>This could be caused by:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 ml-1">
|
||||||
|
<li>Permission issues running Docker commands on the server</li>
|
||||||
|
<li>Docker daemon not responding</li>
|
||||||
|
<li>
|
||||||
|
Network connectivity issues to a remote server — check{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/cluster"
|
||||||
|
className="text-primary underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Cluster Settings
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="w-fit" onClick={onRetry}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface NoServicesProps {
|
||||||
|
nodeCount: number;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => (
|
||||||
|
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>No Swarm Services Found</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Docker Swarm is active with <strong>{nodeCount} node(s)</strong>, but
|
||||||
|
there are no application services running in the swarm.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
This view shows containers deployed as <strong>Swarm services</strong>.
|
||||||
|
Standalone or Docker Compose containers won't appear here.
|
||||||
|
</p>
|
||||||
|
<p>To see containers in this view, make sure your applications are:</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 ml-1">
|
||||||
|
<li>
|
||||||
|
<strong>Deployed as Swarm services</strong> — Applications in
|
||||||
|
Dokploy deploy to Swarm by default. Docker Compose projects need to
|
||||||
|
use{" "}
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">Stack</code>{" "}
|
||||||
|
type (not{" "}
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
|
||||||
|
Docker Compose
|
||||||
|
</code>
|
||||||
|
) to run as Swarm services.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Using a registry</strong> (for multi-node setups) —
|
||||||
|
Worker nodes need to pull images from a shared registry. Configure one
|
||||||
|
in{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/cluster"
|
||||||
|
className="text-primary underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Cluster Settings
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Successfully built and deployed</strong> — Check your
|
||||||
|
project's deployment logs for errors.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<DocLinks />
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="w-fit" onClick={onRefresh}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface NoRunningContainersProps {
|
||||||
|
serviceCount: number;
|
||||||
|
containers: ContainerInfo[];
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoRunningContainers = ({
|
||||||
|
serviceCount,
|
||||||
|
containers,
|
||||||
|
onRefresh,
|
||||||
|
}: NoRunningContainersProps) => {
|
||||||
|
const hasErrors = containers.some((c) => c.Error && c.Error.trim() !== "");
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>No Running Containers</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Found <strong>{serviceCount} service(s)</strong> in the swarm, but
|
||||||
|
none have running containers.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{hasErrors && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Container Errors Detected</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="list-disc list-inside space-y-1 mt-1">
|
||||||
|
{containers
|
||||||
|
.filter((c) => c.Error && c.Error.trim() !== "")
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((c) => (
|
||||||
|
<li key={c.ID} className="text-xs">
|
||||||
|
<strong>{c.Name}</strong>: {c.Error}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<p>This can happen when:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 ml-1">
|
||||||
|
<li>Services are scaled to 0 replicas</li>
|
||||||
|
<li>
|
||||||
|
Containers are failing to start — check deployment logs for
|
||||||
|
errors
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Images can't be pulled on worker nodes — verify your{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/cluster"
|
||||||
|
className="text-primary underline underline-offset-4"
|
||||||
|
>
|
||||||
|
registry configuration
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Node constraints prevent scheduling — check placement rules in
|
||||||
|
your app's Cluster settings
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<DocLinks />
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="w-fit" onClick={onRefresh}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { ChevronDown, ChevronRight, Server } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { ContainerRow } from "./container-row";
|
||||||
|
import type { ContainerStat, NodeGroup } from "./types";
|
||||||
|
|
||||||
|
interface NodeSectionProps {
|
||||||
|
group: NodeGroup;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleNode: (nodeName: string) => void;
|
||||||
|
findStatsForContainer: (taskName: string) => ContainerStat | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NodeSection = ({
|
||||||
|
group,
|
||||||
|
isExpanded,
|
||||||
|
onToggleNode,
|
||||||
|
findStatsForContainer,
|
||||||
|
}: NodeSectionProps) => {
|
||||||
|
const runningCount = group.containers.filter((c) =>
|
||||||
|
c.CurrentState.startsWith("Running"),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const nodeDown =
|
||||||
|
group.nodeStatus &&
|
||||||
|
(group.nodeStatus.Status !== "Ready" ||
|
||||||
|
group.nodeStatus.Availability !== "Active");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
open={isExpanded}
|
||||||
|
onOpenChange={() => onToggleNode(group.nodeName)}
|
||||||
|
>
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors rounded-t-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<Server className="h-5 w-5 text-muted-foreground" />
|
||||||
|
{nodeDown && (
|
||||||
|
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-destructive" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base">{group.nodeName}</CardTitle>
|
||||||
|
{group.nodeStatus && (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
group.nodeStatus.ManagerStatus === "Leader"
|
||||||
|
? "default"
|
||||||
|
: group.nodeStatus.ManagerStatus === "Reachable"
|
||||||
|
? "secondary"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{group.nodeStatus.ManagerStatus || "Worker"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{group.containers.length} container
|
||||||
|
{group.containers.length !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
{nodeDown ? (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
{group.nodeStatus?.Status} /{" "}
|
||||||
|
{group.nodeStatus?.Availability}
|
||||||
|
</Badge>
|
||||||
|
) : runningCount === group.containers.length ? (
|
||||||
|
<Badge variant="default">All Running</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="orange">
|
||||||
|
{runningCount}/{group.containers.length} Running
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[250px]">Container</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
<TableHead className="text-right">CPU</TableHead>
|
||||||
|
<TableHead className="text-right">Memory</TableHead>
|
||||||
|
<TableHead className="text-right">Block I/O</TableHead>
|
||||||
|
<TableHead className="text-right">Network I/O</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{group.containers.map((container) => {
|
||||||
|
const stat = findStatsForContainer(container.Name);
|
||||||
|
return (
|
||||||
|
<ContainerRow
|
||||||
|
key={container.ID}
|
||||||
|
container={container}
|
||||||
|
stat={stat}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Card>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Container,
|
||||||
|
Info,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CardTitle } from "@/components/ui/card";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import {
|
||||||
|
NoRunningContainers,
|
||||||
|
NoServices,
|
||||||
|
ServicesError,
|
||||||
|
SwarmNotAvailable,
|
||||||
|
} from "./empty-states";
|
||||||
|
import { NodeSection } from "./node-section";
|
||||||
|
import { SummaryCards } from "./summary-cards";
|
||||||
|
import type { ContainerInfo, ContainerStat, SwarmNode } from "./types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowSwarmContainers = ({ serverId }: Props) => {
|
||||||
|
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: nodes,
|
||||||
|
isLoading: nodesLoading,
|
||||||
|
isError: nodesError,
|
||||||
|
error: nodesErrorDetail,
|
||||||
|
refetch: refetchNodes,
|
||||||
|
} = api.swarm.getNodes.useQuery({ serverId });
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: nodeApps,
|
||||||
|
isLoading: appsLoading,
|
||||||
|
isError: appsError,
|
||||||
|
error: appsErrorDetail,
|
||||||
|
refetch: refetchApps,
|
||||||
|
} = api.swarm.getNodeApps.useQuery(
|
||||||
|
{ serverId },
|
||||||
|
{ enabled: !nodesError && nodes !== undefined },
|
||||||
|
);
|
||||||
|
|
||||||
|
const applicationList =
|
||||||
|
nodeApps && nodeApps.length > 0
|
||||||
|
? nodeApps.map((app: { Name: string }) => app.Name)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: appDetails,
|
||||||
|
isLoading: detailsLoading,
|
||||||
|
refetch: refetchDetails,
|
||||||
|
} = api.swarm.getAppInfos.useQuery(
|
||||||
|
{ appName: applicationList, serverId },
|
||||||
|
{ enabled: applicationList.length > 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: stats, isLoading: statsLoading } =
|
||||||
|
api.swarm.getContainerStats.useQuery(
|
||||||
|
{ serverId },
|
||||||
|
{
|
||||||
|
refetchInterval: 5000,
|
||||||
|
enabled: applicationList.length > 0 && !nodesError && !appsError,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
nodesLoading ||
|
||||||
|
appsLoading ||
|
||||||
|
(applicationList.length > 0 && detailsLoading);
|
||||||
|
|
||||||
|
// Build container list
|
||||||
|
const containers: ContainerInfo[] = [];
|
||||||
|
if (nodeApps && appDetails) {
|
||||||
|
for (const app of nodeApps) {
|
||||||
|
const details =
|
||||||
|
appDetails?.filter((detail: { Name: string }) =>
|
||||||
|
detail.Name.startsWith(`${app.Name}.`),
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
if (details.length === 0) {
|
||||||
|
containers.push({
|
||||||
|
...app,
|
||||||
|
CurrentState: "N/A",
|
||||||
|
DesiredState: "N/A",
|
||||||
|
Error: "",
|
||||||
|
Node: "N/A",
|
||||||
|
ID: app.ID,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
for (const detail of details) {
|
||||||
|
containers.push({
|
||||||
|
Name: detail.Name,
|
||||||
|
Image: detail.Image || app.Image,
|
||||||
|
CurrentState: detail.CurrentState,
|
||||||
|
DesiredState: detail.DesiredState,
|
||||||
|
Error: detail.Error,
|
||||||
|
Node: detail.Node,
|
||||||
|
Ports: detail.Ports || app.Ports,
|
||||||
|
ID: detail.ID,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningContainers = containers.filter(
|
||||||
|
(c) =>
|
||||||
|
c.Node !== "N/A" &&
|
||||||
|
(c.DesiredState === "Running" || c.CurrentState.startsWith("Running")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unscheduledServices = containers.filter((c) => c.Node === "N/A");
|
||||||
|
|
||||||
|
const downNodes = (nodes ?? []).filter(
|
||||||
|
(n: SwarmNode) => n.Status !== "Ready" || n.Availability !== "Active",
|
||||||
|
);
|
||||||
|
|
||||||
|
const isMultiNode = (nodes?.length ?? 0) > 1;
|
||||||
|
|
||||||
|
const nodeStatusMap = new Map<string, SwarmNode>();
|
||||||
|
if (nodes) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
nodeStatusMap.set(node.Hostname, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statsMap = new Map<string, ContainerStat>();
|
||||||
|
if (stats) {
|
||||||
|
for (const stat of stats) {
|
||||||
|
statsMap.set(stat.Name, stat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const findStatsForContainer = (
|
||||||
|
taskName: string,
|
||||||
|
): ContainerStat | undefined => {
|
||||||
|
for (const [containerName, stat] of statsMap) {
|
||||||
|
if (containerName.startsWith(`${taskName}.`)) {
|
||||||
|
return stat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (runningContainers.length > 0 && expandedNodes.size === 0) {
|
||||||
|
const nodeNames = new Set<string>();
|
||||||
|
for (const c of runningContainers) {
|
||||||
|
if (c.Node) {
|
||||||
|
nodeNames.add(c.Node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExpandedNodes(nodeNames);
|
||||||
|
}
|
||||||
|
}, [runningContainers.length]);
|
||||||
|
|
||||||
|
const toggleNode = (nodeName: string) => {
|
||||||
|
setExpandedNodes((prev: Set<string>) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(nodeName)) {
|
||||||
|
next.delete(nodeName);
|
||||||
|
} else {
|
||||||
|
next.add(nodeName);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
refetchApps();
|
||||||
|
refetchDetails();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build node groups
|
||||||
|
const nodeMap = new Map<string, ContainerInfo[]>();
|
||||||
|
for (const c of runningContainers) {
|
||||||
|
const nodeName = c.Node || "Unknown";
|
||||||
|
if (!nodeMap.has(nodeName)) {
|
||||||
|
nodeMap.set(nodeName, []);
|
||||||
|
}
|
||||||
|
nodeMap.get(nodeName)!.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeGroups = [];
|
||||||
|
for (const [nodeName, nodeContainers] of nodeMap) {
|
||||||
|
nodeGroups.push({
|
||||||
|
nodeName,
|
||||||
|
containers: nodeContainers,
|
||||||
|
nodeStatus: nodeStatusMap.get(nodeName),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
nodeGroups.sort((a, b) => a.nodeName.localeCompare(b.nodeName));
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[40vh]">
|
||||||
|
<span>Loading containers...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodesError) {
|
||||||
|
return (
|
||||||
|
<SwarmNotAvailable
|
||||||
|
errorMessage={nodesErrorDetail?.message}
|
||||||
|
onRetry={() => refetchNodes()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodesError && nodes === undefined) {
|
||||||
|
return (
|
||||||
|
<SwarmNotAvailable
|
||||||
|
errorMessage="Docker Swarm may not be initialized — docker node ls returned no data."
|
||||||
|
onRetry={() => refetchNodes()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRealAppsError =
|
||||||
|
appsError && !appsErrorDetail?.message?.includes("data is undefined");
|
||||||
|
if (isRealAppsError) {
|
||||||
|
return (
|
||||||
|
<ServicesError
|
||||||
|
errorMessage={appsErrorDetail?.message}
|
||||||
|
onRetry={() => refetchApps()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodeApps || nodeApps.length === 0) {
|
||||||
|
return (
|
||||||
|
<NoServices
|
||||||
|
nodeCount={nodes?.length ?? 0}
|
||||||
|
onRefresh={() => refetchApps()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runningContainers.length === 0) {
|
||||||
|
return (
|
||||||
|
<NoRunningContainers
|
||||||
|
serviceCount={nodeApps.length}
|
||||||
|
containers={containers}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<header className="flex items-center flex-wrap gap-4 justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<Container className="size-6 text-muted-foreground self-center" />
|
||||||
|
Container Breakdown by Node
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing containers across {nodes?.length ?? 0} swarm node(s)
|
||||||
|
{statsLoading ? "" : " (metrics refresh every 5s)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<SummaryCards
|
||||||
|
nodeCount={nodes?.length ?? 0}
|
||||||
|
downNodeCount={downNodes.length}
|
||||||
|
serviceCount={nodeApps?.length ?? 0}
|
||||||
|
unscheduledCount={unscheduledServices.length}
|
||||||
|
runningContainerCount={runningContainers.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{downNodes.length > 0 && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>{downNodes.length} Node(s) Unavailable</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="mb-2">
|
||||||
|
The following nodes are not ready or have been drained. Containers
|
||||||
|
scheduled on these nodes may not be running.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||||
|
{downNodes.map((node: SwarmNode) => (
|
||||||
|
<li key={node.ID}>
|
||||||
|
<strong>{node.Hostname}</strong> — Status: {node.Status}
|
||||||
|
, Availability: {node.Availability}
|
||||||
|
{node.ManagerStatus && ` (${node.ManagerStatus})`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-xs">
|
||||||
|
Manage nodes in{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/cluster"
|
||||||
|
className="underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Cluster Settings
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMultiNode && (
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>Multi-Node Metrics Note</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
CPU, memory, and I/O metrics are collected from the manager node via{" "}
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded text-xs">
|
||||||
|
docker stats
|
||||||
|
</code>
|
||||||
|
. Containers running on worker nodes will show “--” for
|
||||||
|
metrics.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{nodeGroups.map((group) => (
|
||||||
|
<NodeSection
|
||||||
|
key={group.nodeName}
|
||||||
|
group={group}
|
||||||
|
isExpanded={expandedNodes.has(group.nodeName)}
|
||||||
|
onToggleNode={toggleNode}
|
||||||
|
findStatsForContainer={findStatsForContainer}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unscheduledServices.length > 0 && (
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{unscheduledServices.length} Service(s) With No Running Tasks
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="mb-2">
|
||||||
|
These services exist in the swarm but have no running containers.
|
||||||
|
They may be scaled to 0 replicas or failing to start.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||||
|
{unscheduledServices.map((svc) => (
|
||||||
|
<li key={svc.ID}>
|
||||||
|
<strong>{svc.Name}</strong>
|
||||||
|
{svc.Error && svc.Error.trim() !== "" && (
|
||||||
|
<span className="text-destructive ml-1">
|
||||||
|
— {svc.Error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Container, Cpu, Server } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface SummaryCardsProps {
|
||||||
|
nodeCount: number;
|
||||||
|
downNodeCount: number;
|
||||||
|
serviceCount: number;
|
||||||
|
unscheduledCount: number;
|
||||||
|
runningContainerCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SummaryCards = ({
|
||||||
|
nodeCount,
|
||||||
|
downNodeCount,
|
||||||
|
serviceCount,
|
||||||
|
unscheduledCount,
|
||||||
|
runningContainerCount,
|
||||||
|
}: SummaryCardsProps) => (
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Swarm Nodes</CardTitle>
|
||||||
|
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||||
|
<Server className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{nodeCount}</div>
|
||||||
|
{downNodeCount > 0 && (
|
||||||
|
<p className="text-xs text-destructive mt-1">
|
||||||
|
{downNodeCount} node(s) down or drained
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Services</CardTitle>
|
||||||
|
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||||
|
<Cpu className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{serviceCount}</div>
|
||||||
|
{unscheduledCount > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{unscheduledCount} with no running tasks
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Running Containers
|
||||||
|
</CardTitle>
|
||||||
|
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||||
|
<Container className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{runningContainerCount}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
35
apps/dokploy/components/dashboard/swarm/containers/types.ts
Normal file
35
apps/dokploy/components/dashboard/swarm/containers/types.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export interface ContainerStat {
|
||||||
|
BlockIO: string;
|
||||||
|
CPUPerc: string;
|
||||||
|
Container: string;
|
||||||
|
ID: string;
|
||||||
|
MemPerc: string;
|
||||||
|
MemUsage: string;
|
||||||
|
Name: string;
|
||||||
|
NetIO: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerInfo {
|
||||||
|
Name: string;
|
||||||
|
Image: string;
|
||||||
|
Node: string;
|
||||||
|
CurrentState: string;
|
||||||
|
DesiredState: string;
|
||||||
|
Ports: string;
|
||||||
|
Error: string;
|
||||||
|
ID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmNode {
|
||||||
|
ID: string;
|
||||||
|
Hostname: string;
|
||||||
|
Status: string;
|
||||||
|
Availability: string;
|
||||||
|
ManagerStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeGroup {
|
||||||
|
nodeName: string;
|
||||||
|
containers: ContainerInfo[];
|
||||||
|
nodeStatus?: SwarmNode;
|
||||||
|
}
|
||||||
31
apps/dokploy/components/dashboard/swarm/containers/utils.ts
Normal file
31
apps/dokploy/components/dashboard/swarm/containers/utils.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/** Round a value+unit string like "2.711MiB" → "2.7 MiB" */
|
||||||
|
export const formatSizeValue = (raw: string): string => {
|
||||||
|
const match = raw.match(/^([\d.]+)\s*([A-Za-z]+)$/);
|
||||||
|
if (!match?.[1] || !match[2]) return raw;
|
||||||
|
const num = Number.parseFloat(match[1]);
|
||||||
|
const unit = match[2];
|
||||||
|
if (Number.isNaN(num)) return raw;
|
||||||
|
const rounded = num >= 1 ? num.toFixed(1) : num.toFixed(2);
|
||||||
|
return `${rounded} ${unit}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Format "2.711MiB / 7.609GiB" → "2.7 MiB / 7.6 GiB" */
|
||||||
|
export const formatMemUsage = (raw: string): string => {
|
||||||
|
const [left, right] = raw.split("/").map((s) => s.trim());
|
||||||
|
if (!left || !right) return raw;
|
||||||
|
return `${formatSizeValue(left)} / ${formatSizeValue(right)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Format "978B / 252B" → "978 B / 252 B" */
|
||||||
|
export const formatIOValue = (raw: string): string => {
|
||||||
|
const [left, right] = raw.split("/").map((s) => s.trim());
|
||||||
|
if (!left || !right) return raw;
|
||||||
|
return `${formatSizeValue(left)} / ${formatSizeValue(right)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Format "0.00%" → "0.0%", "12.345%" → "12.3%" */
|
||||||
|
export const formatCpu = (raw: string): string => {
|
||||||
|
const num = Number.parseFloat(raw.replace("%", ""));
|
||||||
|
if (Number.isNaN(num)) return raw;
|
||||||
|
return `${num.toFixed(1)}%`;
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Forward,
|
Forward,
|
||||||
GalleryVerticalEnd,
|
GalleryVerticalEnd,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
House,
|
||||||
Key,
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -148,6 +149,12 @@ type Menu = {
|
|||||||
// The `isEnabled` function is called to determine if the item should be displayed
|
// The `isEnabled` function is called to determine if the item should be displayed
|
||||||
const MENU: Menu = {
|
const MENU: Menu = {
|
||||||
home: [
|
home: [
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "Home",
|
||||||
|
url: "/dashboard/home",
|
||||||
|
icon: House,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
title: "Projects",
|
title: "Projects",
|
||||||
@@ -861,6 +868,19 @@ function SidebarLogo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileCloser() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { setOpenMobile, isMobile } = useSidebar();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setOpenMobile(false);
|
||||||
|
}
|
||||||
|
}, [pathname, isMobile, setOpenMobile]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Page({ children }: Props) {
|
export default function Page({ children }: Props) {
|
||||||
const [defaultOpen, setDefaultOpen] = useState<boolean | undefined>(
|
const [defaultOpen, setDefaultOpen] = useState<boolean | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -926,6 +946,7 @@ export default function Page({ children }: Props) {
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<MobileCloser />
|
||||||
<Sidebar collapsible="icon" variant="floating">
|
<Sidebar collapsible="icon" variant="floating">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
{/* <SidebarMenuButton
|
{/* <SidebarMenuButton
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const UserNav = () => {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/home");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Projects
|
Projects
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
|||||||
try {
|
try {
|
||||||
const { data, error } = await authClient.signIn.sso({
|
const { data, error } = await authClient.signIn.sso({
|
||||||
email: values.email,
|
email: values.email,
|
||||||
callbackURL: "/dashboard/projects",
|
callbackURL: "/dashboard/home",
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error(error.message ?? "Failed to sign in with SSO");
|
toast.error(error.message ?? "Failed to sign in with SSO");
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||||
>
|
>
|
||||||
<FolderInput className="size-4 text-muted-foreground" />
|
<FolderInput className="size-4 text-muted-foreground" />
|
||||||
<span className="font-medium max-w-[150px] truncate">
|
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||||
{currentProject?.name || "Select Project"}
|
{currentProject?.name || "Select Project"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
<ChevronDown className="size-4 text-muted-foreground" />
|
||||||
@@ -478,7 +478,7 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
aria-expanded={environmentOpen}
|
aria-expanded={environmentOpen}
|
||||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||||
>
|
>
|
||||||
<span className="font-medium max-w-[150px] truncate">
|
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||||
{currentEnvironment?.name || "production"}
|
{currentEnvironment?.name || "production"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
<ChevronDown className="size-4 text-muted-foreground" />
|
||||||
@@ -533,7 +533,7 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{projectEnvironments && projectEnvironments.length === 1 && (
|
{projectEnvironments && projectEnvironments.length === 1 && (
|
||||||
<p className="text-sm font-normal ml-1">
|
<p className="text-sm font-normal ml-1 max-w-[50px] md:max-w-[150px] truncate">
|
||||||
{currentEnvironment?.name || "production"}
|
{currentEnvironment?.name || "production"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -551,7 +551,7 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||||
>
|
>
|
||||||
{getServiceIcon(currentService.type)}
|
{getServiceIcon(currentService.type)}
|
||||||
<span className="font-medium max-w-[150px] truncate">
|
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||||
{currentService.name}
|
{currentService.name}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
<ChevronDown className="size-4 text-muted-foreground" />
|
||||||
@@ -617,7 +617,7 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 ml-1"
|
className="size-7 ml-1 hidden md:flex"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/project/${projectId}/environment/${environmentId}`,
|
`/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user