mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 04:35:24 +02:00
Compare commits
424 Commits
dosu/doc-u
...
feat/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
385850f354 | ||
|
|
a48306a2c6 | ||
|
|
89737e7b65 | ||
|
|
00c708483e | ||
|
|
ddf570a807 | ||
|
|
f8eb2ba4ba | ||
|
|
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 | ||
|
|
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 | ||
|
|
3f1bf2b14e | ||
|
|
2683ac2a1b | ||
|
|
4e11334940 | ||
|
|
82893598e0 | ||
|
|
86905fc5bf | ||
|
|
c7814bb752 | ||
|
|
c0d6eac35d | ||
|
|
6dfa762934 | ||
|
|
0e3bc444b9 | ||
|
|
fb7b7cff66 | ||
|
|
5e999f1c3c | ||
|
|
9e52b722f0 | ||
|
|
70418dd09b | ||
|
|
df95766807 | ||
|
|
e5aae15310 | ||
|
|
964773b44c | ||
|
|
7224436610 | ||
|
|
d6885c32ea | ||
|
|
4da3c468eb | ||
|
|
38a711776b | ||
|
|
4030049ee8 | ||
|
|
06b18aca08 | ||
|
|
86ba597d67 | ||
|
|
5978c4135e | ||
|
|
e9202bfb15 | ||
|
|
365e055005 | ||
|
|
9b108480a8 | ||
|
|
450d591c1a | ||
|
|
d90722a174 | ||
|
|
f9de42610c | ||
|
|
780406f9ef | ||
|
|
f49988498f | ||
|
|
565bc16f24 | ||
|
|
c7b5e73d1c | ||
|
|
8053ee7724 | ||
|
|
d9b2b48643 | ||
|
|
148c91bf5e | ||
|
|
c4aca74aef | ||
|
|
dab13a52d6 | ||
|
|
4a7e9a200e | ||
|
|
f83ab2923d | ||
|
|
9a1bee5287 | ||
|
|
6d17f62942 | ||
|
|
815b8136fa | ||
|
|
290a03ccfb | ||
|
|
63aa60f7e2 | ||
|
|
fe9b0ebcea | ||
|
|
8ccdb66ced | ||
|
|
e38f07d286 | ||
|
|
035d39e3b7 | ||
|
|
82a908a865 | ||
|
|
4bbb2ece49 | ||
|
|
8ee374dc6b | ||
|
|
ddfcd1a671 | ||
|
|
401b177a4e | ||
|
|
88b56ca0a2 | ||
|
|
3d48b25f71 | ||
|
|
b7e30d7ec3 | ||
|
|
b1ef5dc2c6 | ||
|
|
3846e41d7f | ||
|
|
ac76f2d97a | ||
|
|
d6056972f4 | ||
|
|
58b9a0d3d0 | ||
|
|
fe78f282f8 | ||
|
|
4941a80b50 | ||
|
|
5ea2ee5dcd | ||
|
|
76d6de5337 | ||
|
|
3374737db6 | ||
|
|
27a67af190 | ||
|
|
7e6a7d2cd4 | ||
|
|
4f5f1ad841 | ||
|
|
c42a16d658 | ||
|
|
b222409129 | ||
|
|
fe8d2732fc | ||
|
|
88ad551297 | ||
|
|
f36d011286 | ||
|
|
fb5ee5d6b3 | ||
|
|
3d50cb0ac9 | ||
|
|
c752cf3f9e | ||
|
|
cf25c17c20 | ||
|
|
ae439bcd13 | ||
|
|
b8f069704c | ||
|
|
d4bf6246c3 | ||
|
|
4b6f2c84ac | ||
|
|
116e9d85b7 | ||
|
|
dce1454d4d | ||
|
|
a322ac374c | ||
|
|
49d79fcd37 | ||
|
|
fa028dcf1e | ||
|
|
a09d7d5663 | ||
|
|
b9aa275759 | ||
|
|
b61ca31981 | ||
|
|
0b08fa9a59 | ||
|
|
ffd7b80410 | ||
|
|
3854dfaade | ||
|
|
bb56a0bae8 | ||
|
|
a03ec76b6f | ||
|
|
9cc8231188 | ||
|
|
ee2240898c | ||
|
|
92975a6865 | ||
|
|
6fb4a13a18 | ||
|
|
8a8688c011 | ||
|
|
bd18461242 | ||
|
|
7f60000641 | ||
|
|
1d7509dfc2 | ||
|
|
8304513501 | ||
|
|
2809cd690a | ||
|
|
fff91157c4 | ||
|
|
aca1c6f621 | ||
|
|
e9650de794 | ||
|
|
b3579d1321 | ||
|
|
43f9c114c8 | ||
|
|
bc11e8741b | ||
|
|
837373fdc5 | ||
|
|
7d2d7fc005 | ||
|
|
72c15ac18c | ||
|
|
51d744ba45 | ||
|
|
81ecf214f1 | ||
|
|
c2d37631ba | ||
|
|
7c55eba506 | ||
|
|
7878bf29ba | ||
|
|
1b70763ba5 | ||
|
|
e47263ae5f | ||
|
|
b139d6f277 | ||
|
|
cddb06f515 | ||
|
|
4ef8c94340 | ||
|
|
ff369c9d3a | ||
|
|
d0c92d84ef | ||
|
|
72974e00a6 | ||
|
|
d96e2bbeb7 | ||
|
|
a45d8ee8f4 | ||
|
|
9067452a38 | ||
|
|
1fa4d5b2ba | ||
|
|
bade36ea9d | ||
|
|
0c22041623 | ||
|
|
cccee05173 | ||
|
|
9f9c8fccf2 | ||
|
|
ad2e53a67a | ||
|
|
00f3853bd7 | ||
|
|
2880327e94 | ||
|
|
827b84f57e | ||
|
|
11aa8fe0c5 | ||
|
|
b9ac720d99 | ||
|
|
77b0ff7bbf | ||
|
|
e7af2c0ebd | ||
|
|
6a1bedb90f | ||
|
|
a2f142174b | ||
|
|
f4ce304a04 | ||
|
|
bb521f3e7e | ||
|
|
baaa470234 | ||
|
|
4871520dbb | ||
|
|
dad49ec96f | ||
|
|
ce4e37c75b | ||
|
|
c317ec39cb | ||
|
|
a4e9c6e890 | ||
|
|
72fb85f616 | ||
|
|
1e7a6f2071 | ||
|
|
5ffd664570 | ||
|
|
947100c041 | ||
|
|
5410a56638 | ||
|
|
8127dc4536 | ||
|
|
ee42a393aa | ||
|
|
2f37235aea | ||
|
|
290267bca4 | ||
|
|
8eace173b9 | ||
|
|
c9a9ed8164 | ||
|
|
30428053e8 | ||
|
|
1c0dbbcfd6 | ||
|
|
178f4fbdf7 | ||
|
|
2c07a4b2e3 | ||
|
|
75a797097b | ||
|
|
5e6e5ba9d8 | ||
|
|
de201d0b0a | ||
|
|
6866e2b63a | ||
|
|
3e4a1b92eb | ||
|
|
b9ca6ea9db | ||
|
|
f1d4543d5e | ||
|
|
d8c7c1eaf4 | ||
|
|
4330d7bd99 | ||
|
|
2a2acbfe9a | ||
|
|
fc8a5153f1 | ||
|
|
1203d0589b | ||
|
|
2da45d3ca9 | ||
|
|
653e5fa3a0 | ||
|
|
66931fe24f | ||
|
|
7feb4061f8 | ||
|
|
bf9d2615c2 | ||
|
|
40d07357bc | ||
|
|
1e5e361094 | ||
|
|
abc7014b61 | ||
|
|
a8648607b8 | ||
|
|
453a7b12b6 | ||
|
|
c75cfa2d69 | ||
|
|
1c5b92729a | ||
|
|
86feda1679 | ||
|
|
f95b29a450 | ||
|
|
a1cf5520a9 | ||
|
|
fadc7fede5 | ||
|
|
cbbf7f3a6d | ||
|
|
ebf5f486bc | ||
|
|
b1b1dbc1ce | ||
|
|
d7886fb7c9 | ||
|
|
939ff810a2 | ||
|
|
1926417458 | ||
|
|
864e2299ee | ||
|
|
355d46948b | ||
|
|
938b0b4ed3 | ||
|
|
ebbbd39065 | ||
|
|
1f3936fcad | ||
|
|
e4d9fd37b9 | ||
|
|
0df6cc5395 | ||
|
|
2b4604dc0c | ||
|
|
1da9ef8e69 | ||
|
|
e049352f6d | ||
|
|
1cb1b5083f | ||
|
|
affd17d788 | ||
|
|
be3d7825e1 | ||
|
|
c6efe6f35b | ||
|
|
84fb82ea99 | ||
|
|
1d96c4d534 | ||
|
|
bb02de690b | ||
|
|
c8fd999044 | ||
|
|
0a4becb614 | ||
|
|
e85adaedbd | ||
|
|
32657499ab | ||
|
|
67899c762c | ||
|
|
68f6d4a558 | ||
|
|
ab69d782c7 | ||
|
|
405fc69df4 | ||
|
|
ce5ad35981 | ||
|
|
c66902fb96 | ||
|
|
70776ba8ca | ||
|
|
2df1b42540 | ||
|
|
48902c488f | ||
|
|
e575e50979 | ||
|
|
efedec70d6 | ||
|
|
8d11fb4ee8 | ||
|
|
b7f7027280 | ||
|
|
5d078f1d9f | ||
|
|
ac27aa1bba | ||
|
|
6a79ce8ff1 | ||
|
|
bf226f1af1 | ||
|
|
6b117551ae | ||
|
|
8c1153370c | ||
|
|
09dd7cc938 | ||
|
|
eae83674b0 | ||
|
|
d1ebc133aa | ||
|
|
d29fe437b9 | ||
|
|
27ad851d45 | ||
|
|
42f8773c05 | ||
|
|
5eef844e5f | ||
|
|
21fa21e9c0 | ||
|
|
14dafa9a8a | ||
|
|
c5eb31ab90 | ||
|
|
d6704dbd27 | ||
|
|
dcdbed047b | ||
|
|
c362b2c558 | ||
|
|
970905198b | ||
|
|
a0c87358eb | ||
|
|
91a385c302 | ||
|
|
9627af9cda | ||
|
|
90bd276ad4 | ||
|
|
84d311802f | ||
|
|
8ee38a1463 | ||
|
|
e726bf31f6 | ||
|
|
f4248760a8 | ||
|
|
b715e21236 | ||
|
|
71d3a43fd7 | ||
|
|
02f0b0b1a4 | ||
|
|
2dffdffaf3 | ||
|
|
096235f8a1 | ||
|
|
c3b79c115d | ||
|
|
1fb8445165 | ||
|
|
53a11b81d6 | ||
|
|
307916a49a | ||
|
|
293160eb55 | ||
|
|
95999df13e | ||
|
|
803577a403 | ||
|
|
4b1f359cb6 |
1
.github/workflows/pr-quality.yml
vendored
1
.github/workflows/pr-quality.yml
vendored
@@ -16,7 +16,6 @@ jobs:
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
max-failures: 4
|
||||
blocked-commit-authors: "claude,copilot"
|
||||
require-description: true
|
||||
min-account-age: 5
|
||||
|
||||
@@ -62,16 +62,6 @@ pnpm install
|
||||
cp apps/dokploy/.env.example apps/dokploy/.env
|
||||
```
|
||||
|
||||
### Optional Docker Configuration
|
||||
|
||||
The following environment variables can be added to your `.env` file if you need custom Docker daemon configuration:
|
||||
|
||||
- **DOCKER_API_VERSION**: Specify which Docker API version to use (optional)
|
||||
- **DOKPLOY_DOCKER_HOST**: Specify a custom Docker daemon host (optional)
|
||||
- **DOKPLOY_DOCKER_PORT**: Specify a custom Docker daemon port (optional)
|
||||
|
||||
These variables are typically not needed for standard local development but can be useful if you need to connect to a remote Docker daemon or require a specific Docker API version.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Docker](/GUIDES.md#docker)
|
||||
@@ -109,7 +99,14 @@ pnpm run dokploy:build
|
||||
|
||||
## Docker
|
||||
|
||||
To build the docker image
|
||||
To build the docker image first run commands to copy .env files
|
||||
|
||||
```bash
|
||||
cp apps/dokploy/.env.production.example .env.production
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
```
|
||||
|
||||
then run build command
|
||||
|
||||
```bash
|
||||
pnpm run docker:build
|
||||
@@ -181,11 +178,6 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
|
||||
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
|
||||
- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing.
|
||||
|
||||
### Pull Request Guidelines
|
||||
|
||||
- **Keep PRs small and focused.** Avoid very large PRs; prefer several smaller PRs (e.g., one template or one logical change per PR). This speeds up review and keeps the history clear.
|
||||
- **Test before submitting.** Any PR that has not been tested by the contributor will be closed. This keeps the PR queue tidy and ensures that only contributions that have been verified by their authors are considered.
|
||||
|
||||
Thank you for your contribution!
|
||||
|
||||
## Templates
|
||||
|
||||
@@ -19,7 +19,7 @@ Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies th
|
||||
Dokploy includes multiple features to make your life easier.
|
||||
|
||||
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
|
||||
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis.
|
||||
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.
|
||||
- **Backups**: Automate backups for databases to an external storage destination.
|
||||
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
|
||||
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
|
||||
@@ -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).
|
||||
|
||||
```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).
|
||||
|
||||
@@ -6,346 +6,3 @@ npm run dev
|
||||
```
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The API server requires the following environment variables for configuration:
|
||||
|
||||
### Inngest Configuration
|
||||
|
||||
Required for the GET /jobs endpoint to list deployment jobs:
|
||||
|
||||
- **INNGEST_BASE_URL** - The base URL for the Inngest instance
|
||||
- Self-hosted: `http://localhost:8288`
|
||||
- Production: `https://dev-inngest.dokploy.com`
|
||||
|
||||
- **INNGEST_SIGNING_KEY** - The signing key for authenticating with Inngest
|
||||
|
||||
Optional configuration for filtering and pagination:
|
||||
|
||||
- **INNGEST_EVENTS_RECEIVED_AFTER** (optional) - An RFC3339 timestamp to filter events received after a specific date (e.g., `2024-01-01T00:00:00Z`). If unset, no date filter is applied.
|
||||
|
||||
- **INNGEST_JOBS_MAX_EVENTS** (optional) - Maximum number of events to fetch when listing jobs. Default is 100, maximum is 10000. Used for pagination with cursor.
|
||||
|
||||
### Lemon Squeezy Integration
|
||||
|
||||
- **LEMON_SQUEEZY_API_KEY** - API key for Lemon Squeezy integration
|
||||
- **LEMON_SQUEEZY_STORE_ID** - Store ID for Lemon Squeezy integration
|
||||
|
||||
### Docker Configuration
|
||||
|
||||
Optional configuration for customizing Docker daemon connections:
|
||||
|
||||
- **DOCKER_API_VERSION** (optional) - Specifies which Docker API version to use when connecting to the Docker daemon. If not set, the Docker client uses the default API version.
|
||||
|
||||
- **DOKPLOY_DOCKER_HOST** (optional) - Specifies the Docker daemon host to connect to. If not set, uses the default Docker socket connection.
|
||||
|
||||
- **DOKPLOY_DOCKER_PORT** (optional) - Specifies the port for connecting to the Docker daemon. If not set, uses the default port.
|
||||
|
||||
These variables allow advanced users to customize how the Dokploy API server connects to Docker, which can be useful for connecting to remote Docker daemons or using specific API versions.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET /jobs
|
||||
|
||||
Lists deployment jobs (Inngest runs) for a specified server.
|
||||
|
||||
**Query Parameters:**
|
||||
- `serverId` (required) - The ID of the server to list deployment jobs for
|
||||
|
||||
**Response:**
|
||||
Returns an array of deployment job objects with the same shape as BullMQ queue jobs:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"data": {},
|
||||
"timestamp": 0,
|
||||
"processedOn": 0,
|
||||
"finishedOn": 0,
|
||||
"failedReason": "string",
|
||||
"state": "string"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400` - serverId is not provided
|
||||
- `503` - INNGEST_BASE_URL is not configured
|
||||
- `200` - Empty array on other errors
|
||||
|
||||
This endpoint is used by the UI to display deployment queue information in the dashboard.
|
||||
|
||||
## Search Endpoints
|
||||
|
||||
The following search endpoints provide flexible querying capabilities with pagination support. All search endpoints respect member permissions, returning only resources the user has access to.
|
||||
|
||||
### application.search
|
||||
|
||||
Search applications across name, appName, description, repository, owner, and dockerImage fields.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term that searches across name, appName, description, repository, owner, and dockerImage
|
||||
- `name` (optional string) - Filter by application name
|
||||
- `appName` (optional string) - Filter by app name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `repository` (optional string) - Filter by repository
|
||||
- `owner` (optional string) - Filter by owner
|
||||
- `dockerImage` (optional string) - Filter by Docker image
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `environmentId` (optional string) - Filter by environment ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum number of results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"applicationId": "string",
|
||||
"name": "string",
|
||||
"appName": "string",
|
||||
"description": "string",
|
||||
"environmentId": "string",
|
||||
"applicationStatus": "string",
|
||||
"sourceType": "string",
|
||||
"createdAt": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### compose.search
|
||||
|
||||
Search compose services with filtering by name, appName, and description.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name, appName, description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `appName` (optional string) - Filter by app name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `environmentId` (optional string) - Filter by environment ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"composeId": "string",
|
||||
"name": "string",
|
||||
"appName": "string",
|
||||
"description": "string",
|
||||
"environmentId": "string",
|
||||
"composeStatus": "string",
|
||||
"sourceType": "string",
|
||||
"createdAt": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### environment.search
|
||||
|
||||
Search environments by name and description.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name and description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"environmentId": "string",
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"createdAt": "string",
|
||||
"env": "string",
|
||||
"projectId": "string",
|
||||
"isDefault": true
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### project.search
|
||||
|
||||
Search projects by name and description.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name and description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"projectId": "string",
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"createdAt": "string",
|
||||
"organizationId": "string",
|
||||
"env": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Database Service Search Endpoints
|
||||
|
||||
The following database services all share the same search interface:
|
||||
- **postgres.search**
|
||||
- **mysql.search**
|
||||
- **mariadb.search**
|
||||
- **mongo.search**
|
||||
- **redis.search**
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name, appName, description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `appName` (optional string) - Filter by app name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `environmentId` (optional string) - Filter by environment ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"postgresId": "string",
|
||||
"name": "string",
|
||||
"appName": "string",
|
||||
"description": "string",
|
||||
"environmentId": "string",
|
||||
"applicationStatus": "string",
|
||||
"createdAt": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
*Note: The response shape is similar across all database services, with the ID field varying (e.g., `mysqlId`, `mariadbId`, `mongoId`, `redisId`).*
|
||||
|
||||
**Search Behavior:**
|
||||
- All searches use case-insensitive pattern matching with wildcards
|
||||
- Results are ordered by creation date (descending)
|
||||
- Members only see services they have access to
|
||||
- Returns total count for pagination UI
|
||||
|
||||
## Whitelabeling Endpoints
|
||||
|
||||
The whitelabeling endpoints allow enterprise/self-hosted Dokploy instances to customize branding, logos, colors, and UI appearance. These endpoints are only available in self-hosted mode (not cloud).
|
||||
|
||||
### whitelabeling.get
|
||||
|
||||
Get the current whitelabeling configuration.
|
||||
|
||||
**Requirements:**
|
||||
- Enterprise license required
|
||||
- Only available for self-hosted (not cloud)
|
||||
|
||||
**Response:**
|
||||
Returns the whitelabeling configuration object or null if not configured.
|
||||
|
||||
```json
|
||||
{
|
||||
"appName": "string | null",
|
||||
"appDescription": "string | null",
|
||||
"logoUrl": "string | null",
|
||||
"faviconUrl": "string | null",
|
||||
"primaryColor": "string | null",
|
||||
"customCss": "string | null",
|
||||
"loginLogoUrl": "string | null",
|
||||
"supportUrl": "string | null",
|
||||
"docsUrl": "string | null",
|
||||
"errorPageTitle": "string | null",
|
||||
"errorPageDescription": "string | null",
|
||||
"metaTitle": "string | null",
|
||||
"footerText": "string | null"
|
||||
}
|
||||
```
|
||||
|
||||
### whitelabeling.update
|
||||
|
||||
Update the whitelabeling configuration.
|
||||
|
||||
**Requirements:**
|
||||
- Enterprise license required
|
||||
- Owner role required
|
||||
- Only available for self-hosted (not cloud)
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"whitelabelingConfig": {
|
||||
"appName": "string | null",
|
||||
"appDescription": "string | null",
|
||||
"logoUrl": "string | null",
|
||||
"faviconUrl": "string | null",
|
||||
"primaryColor": "string | null",
|
||||
"customCss": "string | null",
|
||||
"loginLogoUrl": "string | null",
|
||||
"supportUrl": "string | null",
|
||||
"docsUrl": "string | null",
|
||||
"errorPageTitle": "string | null",
|
||||
"errorPageDescription": "string | null",
|
||||
"metaTitle": "string | null",
|
||||
"footerText": "string | null"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### whitelabeling.reset
|
||||
|
||||
Reset whitelabeling configuration to default values (all fields set to null).
|
||||
|
||||
**Requirements:**
|
||||
- Enterprise license required
|
||||
- Owner role required
|
||||
- Only available for self-hosted (not cloud)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### whitelabeling.getPublic
|
||||
|
||||
Public endpoint to fetch whitelabeling configuration. This endpoint can be accessed without authentication, allowing the whitelabeling settings to be applied globally (including on the login page before auth).
|
||||
|
||||
**Requirements:**
|
||||
- No authentication required
|
||||
- Only available for self-hosted (not cloud)
|
||||
|
||||
**Response:**
|
||||
Returns the whitelabeling configuration object or null if not configured. Response shape is identical to `whitelabeling.get`.
|
||||
@@ -32,6 +32,8 @@ describe("Host rule format regression tests", () => {
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ describe("createDomainLabels", () => {
|
||||
const baseDomain: Domain = {
|
||||
host: "example.com",
|
||||
port: 8080,
|
||||
customEntrypoint: null,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
@@ -21,6 +22,7 @@ describe("createDomainLabels", () => {
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
@@ -171,12 +173,12 @@ describe("createDomainLabels", () => {
|
||||
"websecure",
|
||||
);
|
||||
|
||||
// Web entrypoint should have both middlewares with redirect first
|
||||
// Web entrypoint with HTTPS should only have redirect
|
||||
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(
|
||||
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
|
||||
);
|
||||
@@ -208,9 +210,9 @@ describe("createDomainLabels", () => {
|
||||
"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(
|
||||
"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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -240,4 +242,259 @@ describe("createDomainLabels", () => {
|
||||
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{ ...baseDomain, customEntrypoint: "custom" },
|
||||
"custom",
|
||||
);
|
||||
expect(labels).toEqual([
|
||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should create https labels for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
expect(labels).toEqual([
|
||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
||||
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should add stripPath middleware for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add internalPath middleware for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
internalPath: "/hello",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add path prefix in rule for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
|
||||
);
|
||||
});
|
||||
|
||||
it("should combine all middlewares for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
internalPath: "/hello",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
|
||||
// Should not contain redirect-to-https since there's only one router
|
||||
expect(middlewareLabel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,7 +292,7 @@ networks:
|
||||
dokploy-network:
|
||||
`;
|
||||
|
||||
test("It shoudn't add suffix to dokploy-network", () => {
|
||||
test("It shouldn't add suffix to dokploy-network", () => {
|
||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -195,7 +195,7 @@ services:
|
||||
- dokploy-network
|
||||
`;
|
||||
|
||||
test("It shoudn't add suffix to dokploy-network in services", () => {
|
||||
test("It shouldn't add suffix to dokploy-network in services", () => {
|
||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
@@ -241,10 +241,10 @@ services:
|
||||
dokploy-network:
|
||||
aliases:
|
||||
- apid
|
||||
|
||||
|
||||
`;
|
||||
|
||||
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
|
||||
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
|
||||
const composeData = parse(composeFile8) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -415,5 +415,24 @@ describe("Docker Image Name and Tag Extraction", () => {
|
||||
expect(extractImageTag("my-image:123")).toBe("123");
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,6 +120,7 @@ const baseApp: ApplicationNested = {
|
||||
environmentId: "",
|
||||
enabled: null,
|
||||
env: null,
|
||||
icon: null,
|
||||
healthCheckSwarm: null,
|
||||
labelsSwarm: null,
|
||||
memoryLimit: null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getEnviromentVariablesObject } from "@dokploy/server/index";
|
||||
import { getEnvironmentVariablesObject } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
|
||||
SECRET_KEY=env-secret-123
|
||||
`;
|
||||
|
||||
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
|
||||
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => {
|
||||
it("resolves environment variables correctly for Stack compose", () => {
|
||||
const serviceEnv = `
|
||||
FOO=\${{environment.NODE_ENV}}
|
||||
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
|
||||
BAZ=test
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
|
||||
SERVICE_PORT=4000
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
@@ -72,7 +72,7 @@ PASSWORD=secret123
|
||||
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||
|
||||
expect(result).toEqual({
|
||||
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
||||
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
|
||||
`;
|
||||
|
||||
expect(() =>
|
||||
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ NODE_ENV=production
|
||||
API_URL=\${{environment.API_URL}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
serviceOverrideEnv,
|
||||
"",
|
||||
environmentEnv,
|
||||
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
|
||||
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
complexServiceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
|
||||
DB_NAME=\${{environment.DATABASE_NAME}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
serviceWithConflicts,
|
||||
conflictingProjectEnv,
|
||||
conflictingEnvironmentEnv,
|
||||
@@ -170,7 +170,7 @@ SERVICE_VAR=test
|
||||
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
serviceWithEmpty,
|
||||
projectEnv,
|
||||
"",
|
||||
|
||||
144
apps/dokploy/__test__/permissions/check-permission.test.ts
Normal file
144
apps/dokploy/__test__/permissions/check-permission.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
overrides: Record<string, boolean> = {},
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
||||
canCreateServices: overrides.canCreateServices ?? false,
|
||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { checkPermission } = await import("@dokploy/server/services/permission");
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("static roles bypass enterprise resources", () => {
|
||||
it("owner bypasses deployment.read", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { deployment: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses backup.create", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
await expect(
|
||||
checkPermission(ctx, { backup: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses schedule.delete", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { schedule: ["delete"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, {
|
||||
deployment: ["read"],
|
||||
backup: ["create"],
|
||||
domain: ["delete"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static roles validate free-tier resources", () => {
|
||||
it("owner passes project.create", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails project.create (no legacy override)", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member passes service.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { service: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails service.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { service: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy boolean overrides for member", () => {
|
||||
it("member passes project.create with canCreateProjects=true", async () => {
|
||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member passes docker.read with canAccessToDocker=true", async () => {
|
||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
||||
await expect(
|
||||
checkPermission(ctx, { docker: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails docker.read with canAccessToDocker=false", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
enterpriseOnlyResources,
|
||||
statements,
|
||||
} from "@dokploy/server/lib/access-control";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const FREE_TIER_RESOURCES = [
|
||||
"organization",
|
||||
"member",
|
||||
"invitation",
|
||||
"team",
|
||||
"ac",
|
||||
"project",
|
||||
"service",
|
||||
"environment",
|
||||
"docker",
|
||||
"sshKeys",
|
||||
"gitProviders",
|
||||
"traefikFiles",
|
||||
"api",
|
||||
];
|
||||
|
||||
const ENTERPRISE_RESOURCES = [
|
||||
"volume",
|
||||
"deployment",
|
||||
"envVars",
|
||||
"projectEnvVars",
|
||||
"environmentEnvVars",
|
||||
"server",
|
||||
"registry",
|
||||
"certificate",
|
||||
"backup",
|
||||
"volumeBackup",
|
||||
"schedule",
|
||||
"domain",
|
||||
"destination",
|
||||
"notification",
|
||||
"tag",
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
it("contains all enterprise resources", () => {
|
||||
for (const resource of ENTERPRISE_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT contain free-tier resources", () => {
|
||||
for (const resource of FREE_TIER_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("every resource in statements is either free or enterprise", () => {
|
||||
const allResources = Object.keys(statements);
|
||||
for (const resource of allResources) {
|
||||
const isFree = FREE_TIER_RESOURCES.includes(resource);
|
||||
const isEnterprise = enterpriseOnlyResources.has(resource);
|
||||
expect(isFree || isEnterprise).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("free and enterprise sets don't overlap", () => {
|
||||
for (const resource of FREE_TIER_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("all statement resources are accounted for", () => {
|
||||
const allResources = Object.keys(statements);
|
||||
const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES];
|
||||
for (const resource of allResources) {
|
||||
expect(categorized).toContain(resource);
|
||||
}
|
||||
});
|
||||
});
|
||||
161
apps/dokploy/__test__/permissions/resolve-permissions.test.ts
Normal file
161
apps/dokploy/__test__/permissions/resolve-permissions.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
overrides: Record<string, boolean> = {},
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
||||
canCreateServices: overrides.canCreateServices ?? false,
|
||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { resolvePermissions } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
const { enterpriseOnlyResources, statements } = await import(
|
||||
"@dokploy/server/lib/access-control"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("enterprise resources for static roles", () => {
|
||||
it("owner gets true for all enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
for (const resource of enterpriseOnlyResources) {
|
||||
const actions = statements[resource as keyof typeof statements];
|
||||
for (const action of actions) {
|
||||
expect((perms as any)[resource][action]).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("admin gets true for all enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
for (const resource of enterpriseOnlyResources) {
|
||||
const actions = statements[resource as keyof typeof statements];
|
||||
for (const action of actions) {
|
||||
expect((perms as any)[resource][action]).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("member gets true for service-level enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.deployment.read).toBe(true);
|
||||
expect(perms.deployment.create).toBe(true);
|
||||
expect(perms.domain.read).toBe(true);
|
||||
expect(perms.backup.read).toBe(true);
|
||||
expect(perms.logs.read).toBe(true);
|
||||
expect(perms.monitoring.read).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets false for org-level enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.server.read).toBe(false);
|
||||
expect(perms.registry.read).toBe(false);
|
||||
expect(perms.certificate.read).toBe(false);
|
||||
expect(perms.destination.read).toBe(false);
|
||||
expect(perms.notification.read).toBe(false);
|
||||
expect(perms.auditLog.read).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("free-tier resources for member", () => {
|
||||
it("member gets service.read=true", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.service.read).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets project.create=false without legacy override", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(false);
|
||||
});
|
||||
|
||||
it("member gets project.create=true with canCreateProjects", async () => {
|
||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets docker.read=false without legacy override", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.docker.read).toBe(false);
|
||||
});
|
||||
|
||||
it("member gets docker.read=true with canAccessToDocker", async () => {
|
||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.docker.read).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("free-tier resources for owner", () => {
|
||||
it("owner gets all free-tier permissions as true", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(true);
|
||||
expect(perms.project.delete).toBe(true);
|
||||
expect(perms.service.create).toBe(true);
|
||||
expect(perms.service.read).toBe(true);
|
||||
expect(perms.service.delete).toBe(true);
|
||||
expect(perms.docker.read).toBe(true);
|
||||
expect(perms.traefikFiles.read).toBe(true);
|
||||
expect(perms.traefikFiles.write).toBe(true);
|
||||
});
|
||||
});
|
||||
132
apps/dokploy/__test__/permissions/service-access.test.ts
Normal file
132
apps/dokploy/__test__/permissions/service-access.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
accessedServices: string[] = [],
|
||||
accessedProjects: string[] = [],
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects,
|
||||
accessedServices,
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: false,
|
||||
canDeleteProjects: false,
|
||||
canCreateServices: false,
|
||||
canDeleteServices: false,
|
||||
canCreateEnvironments: false,
|
||||
canDeleteEnvironments: false,
|
||||
canAccessToTraefikFiles: false,
|
||||
canAccessToDocker: false,
|
||||
canAccessToAPI: false,
|
||||
canAccessToSSHKeys: false,
|
||||
canAccessToGitProviders: false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { checkServicePermissionAndAccess, checkServiceAccess } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("checkServicePermissionAndAccess", () => {
|
||||
it("owner bypasses accessedServices check", async () => {
|
||||
memberToReturn = mockMemberData("owner", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses accessedServices check", async () => {
|
||||
memberToReturn = mockMemberData("admin", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
backup: ["create"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member with access to service passes", async () => {
|
||||
memberToReturn = mockMemberData("member", ["service-123"]);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member WITHOUT access to service fails", async () => {
|
||||
memberToReturn = mockMemberData("member", ["other-service"]);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).rejects.toThrow("You don't have access to this service");
|
||||
});
|
||||
|
||||
it("member with empty accessedServices fails", async () => {
|
||||
memberToReturn = mockMemberData("member", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
domain: ["delete"],
|
||||
}),
|
||||
).rejects.toThrow("You don't have access to this service");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkServiceAccess", () => {
|
||||
it("member with service access passes read check", async () => {
|
||||
memberToReturn = mockMemberData("member", ["app-1"]);
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "app-1", "read"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member without service access fails read check", async () => {
|
||||
memberToReturn = mockMemberData("member", []);
|
||||
await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow(
|
||||
"You don't have access to this service",
|
||||
);
|
||||
});
|
||||
|
||||
it("owner bypasses all access checks", async () => {
|
||||
memberToReturn = mockMemberData("owner", [], []);
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "project-1", "create"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,7 @@ const createApplication = (
|
||||
env: null,
|
||||
},
|
||||
replicas: 1,
|
||||
stopGracePeriodSwarm: 0n,
|
||||
stopGracePeriodSwarm: 0,
|
||||
ulimitsSwarm: null,
|
||||
serverId: "server-id",
|
||||
...overrides,
|
||||
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
|
||||
const application = createApplication({ stopGracePeriodSwarm: 0n });
|
||||
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
|
||||
const application = createApplication({ stopGracePeriodSwarm: 0 });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ const baseApp: ApplicationNested = {
|
||||
dropBuildPath: null,
|
||||
enabled: null,
|
||||
env: null,
|
||||
icon: null,
|
||||
healthCheckSwarm: null,
|
||||
labelsSwarm: null,
|
||||
memoryLimit: null,
|
||||
@@ -137,6 +138,7 @@ const baseDomain: Domain = {
|
||||
https: false,
|
||||
path: null,
|
||||
port: null,
|
||||
customEntrypoint: null,
|
||||
serviceName: "",
|
||||
composeId: "",
|
||||
customCertResolver: null,
|
||||
@@ -145,6 +147,7 @@ const baseDomain: Domain = {
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
@@ -264,6 +267,80 @@ test("Websecure entrypoint on https domain with redirect", async () => {
|
||||
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 */
|
||||
|
||||
test("CertificateType on websecure entrypoint", async () => {
|
||||
@@ -276,6 +353,130 @@ test("CertificateType on websecure entrypoint", async () => {
|
||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||
});
|
||||
|
||||
test("Custom entrypoint on http domain", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: false, customEntrypoint: "custom" },
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
expect(router.tls).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Custom entrypoint on https domain", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||
});
|
||||
|
||||
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.rule).toContain("PathPrefix(`/api`)");
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("stripprefix--1");
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
internalPath: "/hello",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("addprefix--1");
|
||||
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 () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "custom",
|
||||
customCertResolver: "myresolver",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.tls?.certResolver).toBe("myresolver");
|
||||
});
|
||||
|
||||
test("Custom entrypoint without https should not have tls", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
https: false,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.tls).toBeUndefined();
|
||||
});
|
||||
|
||||
/** IDN/Punycode */
|
||||
|
||||
test("Internationalized domain name is converted to punycode", async () => {
|
||||
|
||||
@@ -112,14 +112,21 @@ const menuItems: MenuItem[] = [
|
||||
|
||||
const hasStopGracePeriodSwarm = (
|
||||
value: unknown,
|
||||
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||
): value is { stopGracePeriodSwarm: number | string | null } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"stopGracePeriodSwarm" in value;
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "application"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
}
|
||||
|
||||
export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
|
||||
@@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
||||
}
|
||||
|
||||
const AddRedirectchema = z.object({
|
||||
const AddRedirectSchema = z.object({
|
||||
replicas: z.number().min(1, "Replicas must be at least 1"),
|
||||
registryId: z.string().optional(),
|
||||
});
|
||||
|
||||
type AddCommand = z.infer<typeof AddRedirectchema>;
|
||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||
|
||||
export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
const queryMap = {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -65,12 +65,13 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const mutationMap = {
|
||||
application: () => api.application.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
@@ -86,7 +87,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
: {}),
|
||||
replicas: data?.replicas || 1,
|
||||
},
|
||||
resolver: zodResolver(AddRedirectchema),
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,11 +106,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
const onSubmit = async (data: AddCommand) => {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
mysqlId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
...(type === "application"
|
||||
? {
|
||||
registryId:
|
||||
|
||||
@@ -28,7 +28,14 @@ export const endpointSpecFormSchema = z.object({
|
||||
|
||||
interface EndpointSpecFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
@@ -44,6 +51,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -56,6 +64,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -94,6 +103,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
endpointSpecSwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,14 @@ export const healthCheckFormSchema = z.object({
|
||||
|
||||
interface HealthCheckFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
@@ -42,6 +49,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -54,6 +62,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -104,6 +113,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
healthCheckSwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,14 @@ export const labelsFormSchema = z.object({
|
||||
|
||||
interface LabelsFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
@@ -45,6 +52,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -57,6 +65,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -112,6 +121,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
labelsSwarm: labelsToSend,
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,14 @@ import { api } from "@/utils/api";
|
||||
|
||||
interface ModeFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
@@ -39,6 +46,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -51,6 +59,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -95,6 +104,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
modeSwarm: null,
|
||||
});
|
||||
toast.success("Mode updated successfully");
|
||||
@@ -122,6 +132,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
modeSwarm: modeData,
|
||||
});
|
||||
|
||||
|
||||
@@ -35,7 +35,14 @@ export const networkFormSchema = z.object({
|
||||
|
||||
interface NetworkFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
@@ -51,6 +58,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -63,6 +71,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -132,6 +141,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
networkSwarm: networksToSend,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,14 @@ export const placementFormSchema = z.object({
|
||||
|
||||
interface PlacementFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
@@ -50,6 +57,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -62,6 +70,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -114,6 +123,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
placementSwarm: hasAnyValue
|
||||
? {
|
||||
...formData,
|
||||
|
||||
@@ -32,7 +32,14 @@ export const restartPolicyFormSchema = z.object({
|
||||
|
||||
interface RestartPolicyFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
@@ -48,6 +55,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -60,6 +68,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -104,6 +113,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
restartPolicySwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,14 @@ export const rollbackConfigFormSchema = z.object({
|
||||
|
||||
interface RollbackConfigFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
@@ -50,6 +57,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -62,6 +70,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -103,6 +112,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||
});
|
||||
|
||||
|
||||
@@ -16,14 +16,21 @@ import { api } from "@/utils/api";
|
||||
|
||||
const hasStopGracePeriodSwarm = (
|
||||
value: unknown,
|
||||
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||
): value is { stopGracePeriodSwarm: number | string | null } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"stopGracePeriodSwarm" in value;
|
||||
|
||||
interface StopGracePeriodFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
@@ -39,6 +46,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -51,6 +59,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -59,7 +68,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
|
||||
const form = useForm<any>({
|
||||
defaultValues: {
|
||||
value: null as bigint | null,
|
||||
value: null as number | null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,11 +76,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
if (hasStopGracePeriodSwarm(data)) {
|
||||
const value = data.stopGracePeriodSwarm;
|
||||
const normalizedValue =
|
||||
value === null || value === undefined
|
||||
? null
|
||||
: typeof value === "bigint"
|
||||
? value
|
||||
: BigInt(value);
|
||||
value === null || value === undefined ? null : Number(value);
|
||||
form.reset({
|
||||
value: normalizedValue,
|
||||
});
|
||||
@@ -88,6 +93,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
stopGracePeriodSwarm: formData.value,
|
||||
});
|
||||
|
||||
@@ -126,7 +132,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value ? BigInt(e.target.value) : null,
|
||||
e.target.value ? Number(e.target.value) : null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -34,7 +34,14 @@ export const updateConfigFormSchema = z.object({
|
||||
|
||||
interface UpdateConfigFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
@@ -50,6 +57,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -62,6 +70,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -109,6 +118,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||
});
|
||||
|
||||
|
||||
@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const AddRedirectchema = z.object({
|
||||
const AddRedirectSchema = z.object({
|
||||
regex: z.string().min(1, "Regex required"),
|
||||
permanent: z.boolean().default(false),
|
||||
replacement: z.string().min(1, "Replacement required"),
|
||||
});
|
||||
|
||||
type AddRedirect = z.infer<typeof AddRedirectchema>;
|
||||
type AddRedirect = z.infer<typeof AddRedirectSchema>;
|
||||
|
||||
// Default presets
|
||||
const redirectPresets = [
|
||||
@@ -110,7 +110,7 @@ export const HandleRedirect = ({
|
||||
regex: "",
|
||||
replacement: "",
|
||||
},
|
||||
resolver: zodResolver(AddRedirectchema),
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
|
||||
|
||||
const onDialogToggle = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
||||
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
||||
// setPresetSelected("");
|
||||
// form.reset();
|
||||
};
|
||||
|
||||
@@ -89,12 +89,13 @@ const ULIMIT_PRESETS = [
|
||||
];
|
||||
|
||||
export type ServiceType =
|
||||
| "postgres"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "application"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "application";
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -105,27 +106,29 @@ type AddResources = z.infer<typeof addResourcesSchema>;
|
||||
|
||||
export const ShowResources = ({ id, type }: Props) => {
|
||||
const queryMap = {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
application: () => api.application.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
@@ -155,19 +158,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: data?.cpuReservation || undefined,
|
||||
memoryLimit: data?.memoryLimit || undefined,
|
||||
memoryReservation: data?.memoryReservation || undefined,
|
||||
ulimitsSwarm: data?.ulimitsSwarm || [],
|
||||
ulimitsSwarm: (data as any)?.ulimitsSwarm || [],
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: AddResources) => {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
libsqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
mysqlId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
applicationId: id || "",
|
||||
cpuLimit: formData.cpuLimit || null,
|
||||
cpuReservation: formData.cpuReservation || null,
|
||||
memoryLimit: formData.memoryLimit || null,
|
||||
|
||||
@@ -15,13 +15,17 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canRead = permissions?.traefikFiles.read ?? false;
|
||||
const { data, isPending } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
{ enabled: !!applicationId && canRead },
|
||||
);
|
||||
|
||||
if (!canRead) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between">
|
||||
|
||||
@@ -60,6 +60,8 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
||||
};
|
||||
|
||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.traefikFiles.write ?? false;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||
@@ -125,9 +127,11 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button isLoading={isPending}>Modify</Button>
|
||||
</DialogTrigger>
|
||||
{canWrite && (
|
||||
<DialogTrigger asChild>
|
||||
<Button isLoading={isPending}>Modify</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update traefik config</DialogTitle>
|
||||
|
||||
@@ -34,13 +34,13 @@ interface Props {
|
||||
serviceId: string;
|
||||
serviceType:
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "compose"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "compose";
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
refetch: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -21,24 +21,33 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowVolumes = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canRead = permissions?.volume.read ?? false;
|
||||
const canCreate = permissions?.volume.create ?? false;
|
||||
const canDelete = permissions?.volume.delete ?? false;
|
||||
|
||||
if (!canRead) return null;
|
||||
|
||||
const queryMap = {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
const { mutateAsync: deleteVolume, isPending: isRemoving } =
|
||||
api.mounts.remove.useMutation();
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
||||
@@ -50,7 +59,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && data?.mounts.length > 0 && (
|
||||
{canCreate && data && data?.mounts.length > 0 && (
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
@@ -63,9 +72,11 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
<span className="text-base text-muted-foreground">
|
||||
No volumes/mounts configured
|
||||
</span>
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
{canCreate && (
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2 gap-4">
|
||||
@@ -130,38 +141,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-1">
|
||||
<UpdateVolume
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType={type}
|
||||
/>
|
||||
<DialogAction
|
||||
title="Delete Volume"
|
||||
description="Are you sure you want to delete this volume?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteVolume({
|
||||
mountId: mount.mountId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Volume deleted successfully");
|
||||
{canCreate && (
|
||||
<UpdateVolume
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType={type}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Volume"
|
||||
description="Are you sure you want to delete this volume?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteVolume({
|
||||
mountId: mount.mountId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting volume");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Volume deleted successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting volume");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,13 +67,13 @@ interface Props {
|
||||
refetch: () => void;
|
||||
serviceType:
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "compose"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "compose";
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
}
|
||||
|
||||
export const UpdateVolume = ({
|
||||
@@ -253,7 +253,7 @@ export const UpdateVolume = ({
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem className="max-w-full max-w-[45rem]">
|
||||
<FormItem className="w-full max-w-[45rem]">
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -165,6 +166,7 @@ export const ShowDeployment = ({
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<AnalyzeLogs logs={filteredLogs} context="build" />
|
||||
|
||||
{serverId && (
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Copy,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
RocketIcon,
|
||||
@@ -97,6 +99,12 @@ export const ShowDeployments = ({
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const webhookUrl = useMemo(
|
||||
() =>
|
||||
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
|
||||
[url, refreshToken, type],
|
||||
);
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 200;
|
||||
|
||||
const truncateDescription = (description: string): string => {
|
||||
@@ -224,11 +232,27 @@ export const ShowDeployments = ({
|
||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||
<span>Webhook URL: </span>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="break-all text-muted-foreground">
|
||||
{`${url}/api/deploy${
|
||||
type === "compose" ? "/compose" : ""
|
||||
}/${refreshToken}`}
|
||||
</span>
|
||||
<Badge
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Copy webhook URL to clipboard"
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
|
||||
variant="outline"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
copy(webhookUrl);
|
||||
toast.success("Copied to clipboard.");
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
copy(webhookUrl);
|
||||
toast.success("Copied to clipboard.");
|
||||
}}
|
||||
>
|
||||
{webhookUrl}
|
||||
<Copy className="h-4 w-4 ml-2" />
|
||||
</Badge>
|
||||
{(type === "application" || type === "compose") && (
|
||||
<RefreshToken id={id} type={type} />
|
||||
)}
|
||||
|
||||
@@ -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 type { ValidationStates } from "./show-domains";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
|
||||
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("traefik.me") && (
|
||||
<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("traefik.me") && (
|
||||
<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 { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -61,11 +62,14 @@ export const domain = z
|
||||
.min(1, { message: "Port must be at least 1" })
|
||||
.max(65535, { message: "Port must be 65535 or below" })
|
||||
.optional(),
|
||||
useCustomEntrypoint: z.boolean(),
|
||||
customEntrypoint: z.string().optional(),
|
||||
https: z.boolean().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||
customCertResolver: z.string().optional(),
|
||||
serviceName: z.string().optional(),
|
||||
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
||||
middlewares: z.array(z.string()).optional(),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (input.https && !input.certificateType) {
|
||||
@@ -114,6 +118,14 @@ export const domain = z
|
||||
message: "Internal path must start with '/'",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.useCustomEntrypoint && !input.customEntrypoint) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["customEntrypoint"],
|
||||
message: "Custom entry point must be specified",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type Domain = z.infer<typeof domain>;
|
||||
@@ -196,16 +208,20 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
internalPath: undefined,
|
||||
stripPath: false,
|
||||
port: undefined,
|
||||
useCustomEntrypoint: false,
|
||||
customEntrypoint: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
serviceName: undefined,
|
||||
domainType: type,
|
||||
middlewares: [],
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const certificateType = form.watch("certificateType");
|
||||
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
|
||||
const https = form.watch("https");
|
||||
const domainType = form.watch("domainType");
|
||||
const host = form.watch("host");
|
||||
@@ -220,10 +236,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
internalPath: data?.internalPath || undefined,
|
||||
stripPath: data?.stripPath || false,
|
||||
port: data?.port || undefined,
|
||||
useCustomEntrypoint: !!data.customEntrypoint,
|
||||
customEntrypoint: data.customEntrypoint || undefined,
|
||||
certificateType: data?.certificateType || undefined,
|
||||
customCertResolver: data?.customCertResolver || undefined,
|
||||
serviceName: data?.serviceName || undefined,
|
||||
domainType: data?.domainType || type,
|
||||
middlewares: data?.middlewares || [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,10 +253,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
internalPath: undefined,
|
||||
stripPath: false,
|
||||
port: undefined,
|
||||
useCustomEntrypoint: false,
|
||||
customEntrypoint: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
domainType: type,
|
||||
middlewares: [],
|
||||
});
|
||||
}
|
||||
}, [form, data, isPending, domainId]);
|
||||
@@ -268,6 +290,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
composeId: id,
|
||||
}),
|
||||
...data,
|
||||
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(dictionary.success);
|
||||
@@ -635,6 +658,55 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useCustomEntrypoint"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Custom Entrypoint</FormLabel>
|
||||
<FormDescription>
|
||||
Use custom entrypoint for domina
|
||||
<br />
|
||||
"web" and/or "websecure" is used by default.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
form.setValue("customEntrypoint", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{useCustomEntrypoint && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customEntrypoint"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Entrypoint Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter entrypoint name manually"
|
||||
{...field}
|
||||
className="w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="https"
|
||||
@@ -725,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>
|
||||
</form>
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
Loader2,
|
||||
PenBoxIcon,
|
||||
RefreshCw,
|
||||
@@ -23,6 +37,21 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -30,6 +59,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { createColumns } from "./columns";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
|
||||
@@ -50,6 +80,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowDomains = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canCreateDomain = permissions?.domain.create ?? false;
|
||||
const canDeleteDomain = permissions?.domain.delete ?? false;
|
||||
const { data: application } =
|
||||
type === "application"
|
||||
? api.application.one.useQuery(
|
||||
@@ -71,6 +104,19 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
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 {
|
||||
@@ -100,6 +146,16 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||
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) => {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
@@ -137,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 (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
@@ -148,13 +235,32 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<div className="flex flex-row gap-2 flex-wrap">
|
||||
{data && data?.length > 0 && (
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
<>
|
||||
<Button
|
||||
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>
|
||||
</AddDomain>
|
||||
{canCreateDomain && (
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -173,13 +279,131 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
To access the application it is required to set at least 1
|
||||
domain
|
||||
</span>
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
{canCreateDomain && (
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
</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] ">
|
||||
@@ -214,47 +438,51 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<AddDomain
|
||||
id={id}
|
||||
type={type}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
{canCreateDomain && (
|
||||
<AddDomain
|
||||
id={id}
|
||||
type={type}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</AddDomain>
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
);
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<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 deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full break-all">
|
||||
@@ -332,6 +560,22 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -36,16 +36,19 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.envVars.write ?? false;
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -53,16 +56,17 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
compose: () => api.compose.update.useMutation(),
|
||||
compose: () => api.compose.saveEnvironment.useMutation(),
|
||||
libsql: () => api.libsql.saveEnvironment.useMutation(),
|
||||
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
|
||||
mongo: () => api.mongo.saveEnvironment.useMutation(),
|
||||
mysql: () => api.mysql.saveEnvironment.useMutation(),
|
||||
postgres: () => api.postgres.saveEnvironment.useMutation(),
|
||||
redis: () => api.redis.saveEnvironment.useMutation(),
|
||||
};
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
: api.mongo.saveEnvironment.useMutation();
|
||||
|
||||
const form = useForm<EnvironmentSchema>({
|
||||
defaultValues: {
|
||||
@@ -85,12 +89,13 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
|
||||
const onSubmit = async (formData: EnvironmentSchema) => {
|
||||
mutateAsync({
|
||||
composeId: id || "",
|
||||
libsqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
mysqlId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
composeId: id || "",
|
||||
env: formData.environment,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -111,7 +116,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
@@ -185,25 +190,27 @@ PORT=3000
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
{canWrite && (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Cancel
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
@@ -31,6 +31,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.envVars.write ?? false;
|
||||
const { mutateAsync, isPending } =
|
||||
api.application.saveEnvironment.useMutation();
|
||||
|
||||
@@ -104,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
@@ -201,27 +203,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{canWrite && (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -416,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
@@ -55,7 +55,7 @@ interface Props {
|
||||
|
||||
export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
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 { mutateAsync, isPending } =
|
||||
@@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
|
||||
@@ -30,6 +30,9 @@ interface Props {
|
||||
|
||||
export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
const router = useRouter();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const canUpdateService = permissions?.service.create ?? false;
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||
<DialogAction
|
||||
title="Deploy Application"
|
||||
description="Are you sure you want to deploy this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Deploy Application"
|
||||
description="Are you sure you want to deploy this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Downloads the source code and performs a complete build
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Application"
|
||||
description="Are you sure you want to reload this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
applicationId: applicationId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application reloaded successfully");
|
||||
refetch();
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Downloads the source code and performs a complete
|
||||
build
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload Application"
|
||||
description="Are you sure you want to reload this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
applicationId: applicationId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Reload the application without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Rebuild Application"
|
||||
description="Are you sure you want to rebuild this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application rebuilt successfully");
|
||||
refetch();
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Reload the application without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Rebuild Application"
|
||||
description="Are you sure you want to rebuild this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error rebuilding application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application rebuilt successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error rebuilding application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Hammer className="size-4 mr-1" />
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Only rebuilds the application without downloading new
|
||||
code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Hammer className="size-4 mr-1" />
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Only rebuilds the application without downloading new
|
||||
code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Application"
|
||||
description="Are you sure you want to start this application?"
|
||||
@@ -219,7 +229,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
) : canDeploy ? (
|
||||
<DialogAction
|
||||
title="Stop Application"
|
||||
description="Are you sure you want to stop this application?"
|
||||
@@ -256,7 +266,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
) : null}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
@@ -270,49 +280,53 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Clean Cache</span>
|
||||
<Switch
|
||||
aria-label="Toggle clean cache"
|
||||
checked={data?.cleanCache || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
cleanCache: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Clean Cache Updated");
|
||||
await refetch();
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Clean Cache</span>
|
||||
<Switch
|
||||
aria-label="Toggle clean cache"
|
||||
checked={data?.cleanCache || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
cleanCache: enabled,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Clean Cache");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
.then(async () => {
|
||||
toast.success("Clean Cache Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Clean Cache");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ShowProviderForm applicationId={applicationId} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -91,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
}, [option, services, containers]);
|
||||
|
||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||
const containersLenght =
|
||||
const containersLength =
|
||||
option === "native" ? containers?.length : services?.length;
|
||||
|
||||
return (
|
||||
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<SelectLabel>Containers ({containersLenght})</SelectLabel>
|
||||
<SelectLabel>Containers ({containersLength})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./show-patches";
|
||||
export * from "./patch-editor";
|
||||
export * from "./show-patches";
|
||||
|
||||
@@ -71,6 +71,7 @@ const formSchema = z
|
||||
"mongo",
|
||||
"mysql",
|
||||
"redis",
|
||||
"libsql",
|
||||
]),
|
||||
serviceName: z.string(),
|
||||
destinationId: z.string().min(1, "Destination required"),
|
||||
@@ -482,7 +483,7 @@ export const HandleVolumeBackups = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the volume to backup, if you dont see the
|
||||
Choose the volume to backup. If you do not see the
|
||||
volume here, you can type the volume name manually
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -517,7 +518,7 @@ export const HandleVolumeBackups = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the volume to backup, if you dont see the volume
|
||||
Choose the volume to backup. If you do not see the volume
|
||||
here, you can type the volume name manually
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -46,6 +46,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const DeleteService = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDelete = permissions?.service.delete ?? false;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
@@ -55,6 +57,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
@@ -70,6 +73,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
redis: () => api.redis.remove.useMutation(),
|
||||
mysql: () => api.mysql.remove.useMutation(),
|
||||
mariadb: () => api.mariadb.remove.useMutation(),
|
||||
libsql: () => api.libsql.remove.useMutation(),
|
||||
application: () => api.application.delete.useMutation(),
|
||||
mongo: () => api.mongo.remove.useMutation(),
|
||||
compose: () => api.compose.delete.useMutation(),
|
||||
@@ -96,6 +100,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
libsqlId: id || "",
|
||||
applicationId: id || "",
|
||||
composeId: id || "",
|
||||
deleteVolumes,
|
||||
@@ -123,6 +128,8 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
data?.applicationStatus === "running") ||
|
||||
(data && "composeStatus" in data && data?.composeStatus === "running");
|
||||
|
||||
if (!canDelete) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
|
||||
@@ -19,6 +19,9 @@ interface Props {
|
||||
}
|
||||
export const ComposeActions = ({ composeId }: Props) => {
|
||||
const router = useRouter();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const canUpdateService = permissions?.service.create ?? false;
|
||||
const { data, refetch } = api.compose.one.useQuery(
|
||||
{
|
||||
composeId,
|
||||
@@ -35,162 +38,169 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||
<DialogAction
|
||||
title="Deploy Compose"
|
||||
description="Are you sure you want to deploy this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads the source code and performs a complete build</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Compose"
|
||||
description="Are you sure you want to reload this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Reload the compose without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.composeType === "docker-compose" &&
|
||||
data?.composeStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Start Compose"
|
||||
description="Are you sure you want to start this compose?"
|
||||
title="Deploy Compose"
|
||||
description="Are you sure you want to deploy this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
await deploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose started successfully");
|
||||
toast.success("Compose deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting compose");
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the compose (requires a previous successful build)
|
||||
Downloads the source code and performs a complete build
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Stop Compose"
|
||||
description="Are you sure you want to stop this compose?"
|
||||
title="Reload Compose"
|
||||
description="Are you sure you want to reload this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
await redeploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose stopped successfully");
|
||||
toast.success("Compose reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping compose");
|
||||
toast.error("Error reloading compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
variant="secondary"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running compose</p>
|
||||
<p>Reload the compose without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.composeType === "docker-compose" &&
|
||||
data?.composeStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Compose"
|
||||
description="Are you sure you want to start this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the compose (requires a previous successful build)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Compose"
|
||||
description="Are you sure you want to stop this compose?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running compose</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
@@ -205,27 +215,29 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
composeId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
composeId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ const AddComposeFile = z.object({
|
||||
type AddComposeFile = z.infer<typeof AddComposeFile>;
|
||||
|
||||
export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canUpdate = permissions?.service.create ?? false;
|
||||
const utils = api.useUtils();
|
||||
const { data, refetch } = api.compose.one.useQuery(
|
||||
{
|
||||
@@ -93,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
@@ -164,14 +166,16 @@ services:
|
||||
</Form>
|
||||
<div className="flex justify-between flex-col lg:flex-row gap-2">
|
||||
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
|
||||
<Button
|
||||
type="submit"
|
||||
form="hook-form-save-compose-file"
|
||||
isLoading={isPending}
|
||||
className="lg:w-fit w-full"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="hook-form-save-compose-file"
|
||||
isLoading={isPending}
|
||||
className="lg:w-fit w-full"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
@@ -55,7 +55,7 @@ interface Props {
|
||||
|
||||
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
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 { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||
@@ -230,10 +230,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -409,10 +409,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
|
||||
@@ -77,7 +77,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
}, [option, services, containers]);
|
||||
|
||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||
const containersLenght =
|
||||
const containersLength =
|
||||
option === "native" ? containers?.length : services?.length;
|
||||
|
||||
return (
|
||||
@@ -152,7 +152,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<SelectLabel>Containers ({containersLenght})</SelectLabel>
|
||||
<SelectLabel>Containers ({containersLength})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -65,7 +65,13 @@ import { ScheduleFormField } from "../../application/schedules/handle-schedules"
|
||||
|
||||
type CacheType = "cache" | "fetch";
|
||||
|
||||
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
|
||||
type DatabaseType =
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mysql"
|
||||
| "mongo"
|
||||
| "web-server"
|
||||
| "libsql";
|
||||
|
||||
const Schema = z
|
||||
.object({
|
||||
@@ -77,7 +83,7 @@ const Schema = z
|
||||
keepLatestCount: z.coerce.number().optional(),
|
||||
serviceName: z.string().nullable(),
|
||||
databaseType: z
|
||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
|
||||
.optional(),
|
||||
backupType: z.enum(["database", "compose"]),
|
||||
metadata: z
|
||||
@@ -209,7 +215,12 @@ export const HandleBackup = ({
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
database: databaseType === "web-server" ? "dokploy" : "",
|
||||
database:
|
||||
databaseType === "web-server"
|
||||
? "dokploy"
|
||||
: databaseType === "libsql"
|
||||
? "iku.db"
|
||||
: "",
|
||||
destinationId: "",
|
||||
enabled: true,
|
||||
prefix: "/",
|
||||
@@ -246,7 +257,9 @@ export const HandleBackup = ({
|
||||
? backup?.database
|
||||
: databaseType === "web-server"
|
||||
? "dokploy"
|
||||
: "",
|
||||
: databaseType === "libsql"
|
||||
? "iku.db"
|
||||
: "",
|
||||
destinationId: backup?.destinationId ?? "",
|
||||
enabled: backup?.enabled ?? true,
|
||||
prefix: backup?.prefix ?? "/",
|
||||
@@ -281,11 +294,15 @@ export const HandleBackup = ({
|
||||
? {
|
||||
mongoId: id,
|
||||
}
|
||||
: databaseType === "web-server"
|
||||
: databaseType === "libsql"
|
||||
? {
|
||||
userId: id,
|
||||
libsqlId: id,
|
||||
}
|
||||
: undefined;
|
||||
: databaseType === "web-server"
|
||||
? {
|
||||
userId: id,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await createBackup({
|
||||
destinationId: data.destinationId,
|
||||
@@ -568,7 +585,10 @@ export const HandleBackup = ({
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={databaseType === "web-server"}
|
||||
disabled={
|
||||
databaseType === "web-server" ||
|
||||
databaseType === "libsql"
|
||||
}
|
||||
placeholder={"dokploy"}
|
||||
{...field}
|
||||
/>
|
||||
|
||||
@@ -88,7 +88,7 @@ const RestoreBackupSchema = z
|
||||
message: "Database name is required",
|
||||
}),
|
||||
databaseType: z
|
||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
|
||||
.optional(),
|
||||
backupType: z.enum(["database", "compose"]).default("database"),
|
||||
metadata: z
|
||||
@@ -211,7 +211,12 @@ export const RestoreBackup = ({
|
||||
defaultValues: {
|
||||
destinationId: "",
|
||||
backupFile: "",
|
||||
databaseName: databaseType === "web-server" ? "dokploy" : "",
|
||||
databaseName:
|
||||
databaseType === "web-server"
|
||||
? "dokploy"
|
||||
: databaseType === "libsql"
|
||||
? "iku.db"
|
||||
: "",
|
||||
databaseType:
|
||||
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
|
||||
backupType: backupType,
|
||||
@@ -220,7 +225,7 @@ export const RestoreBackup = ({
|
||||
resolver: zodResolver(RestoreBackupSchema),
|
||||
});
|
||||
|
||||
const destionationId = form.watch("destinationId");
|
||||
const destinationId = form.watch("destinationId");
|
||||
const currentDatabaseType = form.watch("databaseType");
|
||||
const metadata = form.watch("metadata");
|
||||
|
||||
@@ -235,12 +240,12 @@ export const RestoreBackup = ({
|
||||
|
||||
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
|
||||
{
|
||||
destinationId: destionationId,
|
||||
destinationId: destinationId,
|
||||
search: debouncedSearchTerm,
|
||||
serverId: serverId ?? "",
|
||||
},
|
||||
{
|
||||
enabled: isOpen && !!destionationId,
|
||||
enabled: isOpen && !!destinationId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -523,7 +528,10 @@ export const RestoreBackup = ({
|
||||
<Input
|
||||
placeholder="Enter database name"
|
||||
{...field}
|
||||
disabled={databaseType === "web-server"}
|
||||
disabled={
|
||||
databaseType === "web-server" ||
|
||||
databaseType === "libsql"
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -53,14 +53,16 @@ export const ShowBackups = ({
|
||||
const queryMap =
|
||||
backupType === "database"
|
||||
? {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
mysql: () =>
|
||||
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () =>
|
||||
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
mysql: () =>
|
||||
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
libsql: () =>
|
||||
api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
"web-server": () => api.user.getBackups.useQuery(),
|
||||
}
|
||||
: {
|
||||
@@ -77,10 +79,11 @@ export const ShowBackups = ({
|
||||
const mutationMap =
|
||||
backupType === "database"
|
||||
? {
|
||||
postgres: api.backup.manualBackupPostgres.useMutation(),
|
||||
mysql: api.backup.manualBackupMySql.useMutation(),
|
||||
mariadb: api.backup.manualBackupMariadb.useMutation(),
|
||||
mongo: api.backup.manualBackupMongo.useMutation(),
|
||||
mysql: api.backup.manualBackupMySql.useMutation(),
|
||||
postgres: api.backup.manualBackupPostgres.useMutation(),
|
||||
libsql: api.backup.manualBackupLibsql.useMutation(),
|
||||
"web-server": api.backup.manualBackupWebServer.useMutation(),
|
||||
}
|
||||
: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
||||
189
apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx
Normal file
189
apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
import { Bot, 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 { 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 });
|
||||
};
|
||||
|
||||
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 h-4 w-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="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 { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { AnalyzeLogs } from "./analyze-logs";
|
||||
import { LineCountFilter } from "./line-count-filter";
|
||||
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
||||
import { StatusLogsFilter } from "./status-logs-filter";
|
||||
@@ -377,6 +378,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
Download logs
|
||||
</Button>
|
||||
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
||||
</div>
|
||||
</div>
|
||||
{isPaused && (
|
||||
|
||||
@@ -103,7 +103,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
>
|
||||
{" "}
|
||||
<div className="flex items-start gap-x-2">
|
||||
{/* Icon to expand the log item maybe implement a colapsible later */}
|
||||
{/* Icon to expand the log item maybe implement a collapsible later */}
|
||||
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
|
||||
{tooltip(color, rawTimestamp)}
|
||||
{!noTimestamp && (
|
||||
|
||||
@@ -74,6 +74,18 @@ export function parseLogs(logString: string): LogLine[] {
|
||||
|
||||
// Detect log type based on message content
|
||||
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();
|
||||
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const RemoveContainerDialog = ({ containerId, serverId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, isPending } = api.docker.removeContainer.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Remove Container
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove the container{" "}
|
||||
<span className="font-semibold">{containerId}</span>. If the
|
||||
container is running, it will be forcefully stopped and removed.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isPending}
|
||||
onClick={async () => {
|
||||
await mutateAsync({ containerId, serverId })
|
||||
.then(async () => {
|
||||
toast.success("Container removed successfully");
|
||||
await utils.docker.getContainers.invalidate();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ShowContainerConfig } from "../config/show-container-config";
|
||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||
import { RemoveContainerDialog } from "../remove/remove-container";
|
||||
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
||||
import { UploadFileModal } from "../upload/upload-file-modal";
|
||||
import type { Container } from "./show-containers";
|
||||
|
||||
export const columns: ColumnDef<Container>[] = [
|
||||
@@ -127,6 +129,16 @@ export const columns: ColumnDef<Container>[] = [
|
||||
>
|
||||
Terminal
|
||||
</DockerTerminalModal>
|
||||
<UploadFileModal
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || undefined}
|
||||
>
|
||||
Upload File
|
||||
</UploadFileModal>
|
||||
<RemoveContainerDialog
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId ?? undefined}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
import { columns } from "./colums";
|
||||
import { columns } from "./columns";
|
||||
export type Container = NonNullable<
|
||||
RouterOutputs["docker"]["getContainers"]
|
||||
>[0];
|
||||
|
||||
@@ -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 {
|
||||
uploadFileToContainerSchema,
|
||||
type UploadFileToContainer,
|
||||
} 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import Link from "next/link";
|
||||
import { useEffect, 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 { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a === null || a === undefined || a === "") return null;
|
||||
const parsed = Number.parseInt(String(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
externalGRPCPort: z.preprocess((a) => {
|
||||
if (a === null || a === undefined || a === "") return null;
|
||||
const parsed = Number.parseInt(String(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
externalAdminPort: z.preprocess((a) => {
|
||||
if (a === null || a === undefined || a === "") return null;
|
||||
const parsed = Number.parseInt(String(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
libsqlId: string;
|
||||
}
|
||||
export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.libsql.one.useQuery({ libsqlId });
|
||||
const { mutateAsync, isPending } = api.libsql.saveExternalPorts.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const [connectionGRPCUrl, setGRPCConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
externalGRPCPort: data.externalGRPCPort,
|
||||
externalAdminPort: data.externalAdminPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
externalGRPCPort: values.externalGRPCPort,
|
||||
externalAdminPort: values.externalAdminPort,
|
||||
libsqlId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External port/ports updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port/ports");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
setConnectionUrl(
|
||||
`http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`,
|
||||
);
|
||||
|
||||
if (data?.sqldNode !== "replica") {
|
||||
const grpcPort = form.watch("externalGRPCPort") || data?.externalGRPCPort;
|
||||
setGRPCConnectionUrl(
|
||||
`http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${grpcPort}`,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
data?.externalGRPCPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseUser,
|
||||
getIp,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable through the internet, you
|
||||
must set a port and ensure that the port is not being used by
|
||||
another application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link href="/dashboard/settings/server" className="text-primary">
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="8080"
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalAdminPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>External Admin Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="5000"
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data?.sqldNode !== "replica" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalGRPCPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>External GRPC Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="5001"
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalGRPCPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External GRPC Host</Label>
|
||||
<ToggleVisibilityInput
|
||||
value={connectionGRPCUrl}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
|
||||
interface Props {
|
||||
libsqlId: string;
|
||||
}
|
||||
|
||||
export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
|
||||
const { data, refetch } = api.libsql.one.useQuery(
|
||||
{
|
||||
libsqlId,
|
||||
},
|
||||
{ enabled: !!libsqlId },
|
||||
);
|
||||
|
||||
const { mutateAsync: reload, isPending: isReloading } =
|
||||
api.libsql.reload.useMutation();
|
||||
|
||||
const { mutateAsync: start, isPending: isStarting } =
|
||||
api.libsql.start.useMutation();
|
||||
|
||||
const { mutateAsync: stop, isPending: isStopping } =
|
||||
api.libsql.stop.useMutation();
|
||||
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
api.libsql.deployWithLogs.useSubscription(
|
||||
{
|
||||
libsqlId: libsqlId,
|
||||
},
|
||||
{
|
||||
enabled: isDeploying,
|
||||
onData(log) {
|
||||
if (!isDrawerOpen) {
|
||||
setIsDrawerOpen(true);
|
||||
}
|
||||
|
||||
if (log === "Deployment completed successfully!") {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
const parsedLogs = parseLogs(log);
|
||||
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
|
||||
},
|
||||
onError(error) {
|
||||
console.error("Deployment logs error:", error);
|
||||
setIsDeploying(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Deploy Libsql"
|
||||
description="Are you sure you want to deploy this Libsql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the Libsql database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Reload Libsql"
|
||||
description="Are you sure you want to reload this libsql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
libsqlId: libsqlId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Libsql reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Libsql");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the Libsql service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Start Libsql"
|
||||
description="Are you sure you want to start this Libsql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
libsqlId: libsqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Libsql started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Libsql");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the Libsql database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Stop Libsql"
|
||||
description="Are you sure you want to stop this Libsql?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
libsqlId: libsqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Libsql stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Libsql");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running Libsql database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Terminal className="size-4 mr-1" />
|
||||
Open Terminal
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Open a terminal to the Libsql container</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
setFilteredLogs([]);
|
||||
setIsDeploying(false);
|
||||
refetch();
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { SelectGroup } from "@radix-ui/react-select";
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
libsqlId: string;
|
||||
}
|
||||
export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
||||
const { data } = api.libsql.one.useQuery({ libsqlId });
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Internal Credentials</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-row gap-4">
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Sqld Node</Label>
|
||||
<Select value={data?.sqldNode} disabled>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Node type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["primary", "replica"].map((node) => (
|
||||
<SelectItem key={node} value={node}>
|
||||
{node.charAt(0).toUpperCase() + node.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={data?.databasePassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label>Internal Port (Container)</Label>
|
||||
<Input disabled value="8080" />
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label>Internal GRPC Port (Container)</Label>
|
||||
<Input disabled value="5001" />
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label>Internal Admin Port (Container)</Label>
|
||||
<Input disabled value="5000" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Enable Namespaces</Label>
|
||||
<Select
|
||||
disabled
|
||||
defaultValue={
|
||||
data?.enableNamespaces
|
||||
? String(data?.enableNamespaces)
|
||||
: "false"
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={"false"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{["false", "true"].map((node) => (
|
||||
<SelectItem key={node} value={node}>
|
||||
{node.charAt(0).toUpperCase() + node.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
<Label>Internal Connection URL </Label>
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={`http://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:8080`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
<Label>Internal Replication Connection URL </Label>
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={`http://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5001`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
163
apps/dokploy/components/dashboard/libsql/update-libsql.tsx
Normal file
163
apps/dokploy/components/dashboard/libsql/update-libsql.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const updateLibsqlSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type UpdateLibsql = z.infer<typeof updateLibsqlSchema>;
|
||||
|
||||
interface Props {
|
||||
libsqlId: string;
|
||||
}
|
||||
|
||||
export const UpdateLibsql = ({ libsqlId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isPending } =
|
||||
api.libsql.update.useMutation();
|
||||
const { data } = api.libsql.one.useQuery(
|
||||
{
|
||||
libsqlId,
|
||||
},
|
||||
{
|
||||
enabled: !!libsqlId,
|
||||
},
|
||||
);
|
||||
const form = useForm<UpdateLibsql>({
|
||||
defaultValues: {
|
||||
description: data?.description ?? "",
|
||||
name: data?.name ?? "",
|
||||
},
|
||||
resolver: zodResolver(updateLibsqlSchema),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
description: data.description ?? "",
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: UpdateLibsql) => {
|
||||
await mutateAsync({
|
||||
name: formData.name,
|
||||
libsqlId: libsqlId,
|
||||
description: formData.description || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Libsql updated successfully");
|
||||
utils.libsql.one.invalidate({
|
||||
libsqlId: libsqlId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the Libsql");
|
||||
})
|
||||
.finally(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 "
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modify Libsql</DialogTitle>
|
||||
<DialogDescription>Update the Libsql data</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid items-center gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-update-libsql"
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description about your project..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
form="hook-form-update-libsql"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.mariadb.one.useQuery(
|
||||
{
|
||||
mariadbId,
|
||||
@@ -72,154 +74,33 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Deploy Mariadb"
|
||||
description="Are you sure you want to deploy this mariadb?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the MariaDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Reload Mariadb"
|
||||
description="Are you sure you want to reload this mariadb?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mariadbId: mariadbId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Mariadb");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MariaDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Start Mariadb"
|
||||
description="Are you sure you want to start this mariadb?"
|
||||
title="Deploy Mariadb"
|
||||
description="Are you sure you want to deploy this mariadb?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mariadbId: mariadbId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Mariadb");
|
||||
});
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MariaDB database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Stop Mariadb"
|
||||
description="Are you sure you want to stop this mariadb?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mariadbId: mariadbId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mariadb");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MariaDB database</p>
|
||||
<p>Downloads and sets up the MariaDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
@@ -227,6 +108,132 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Reload Mariadb"
|
||||
description="Are you sure you want to reload this mariadb?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mariadbId: mariadbId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Mariadb");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MariaDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.applicationStatus === "idle" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Start Mariadb"
|
||||
description="Are you sure you want to start this mariadb?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mariadbId: mariadbId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Mariadb");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MariaDB database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Stop Mariadb"
|
||||
description="Are you sure you want to stop this mariadb?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mariadbId: mariadbId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mariadb");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MariaDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
mariadbId: string;
|
||||
}
|
||||
export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
const { data } = api.mariadb.one.useQuery({ mariadbId });
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: changePassword } =
|
||||
api.mariadb.changePassword.useMutation();
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
@@ -28,20 +33,43 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
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 className="flex flex-col gap-2">
|
||||
<Label>Root Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
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 className="flex flex-col gap-2">
|
||||
|
||||
@@ -82,7 +82,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
const buildConnectionUrl = () => {
|
||||
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());
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowGeneralMongo = ({ mongoId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.mongo.one.useQuery(
|
||||
{
|
||||
mongoId,
|
||||
@@ -73,153 +75,158 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Deploy Mongo"
|
||||
description="Are you sure you want to deploy this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the MongoDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Mongo"
|
||||
description="Are you sure you want to reload this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mongoId: mongoId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MongoDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Start Mongo"
|
||||
description="Are you sure you want to start this mongo?"
|
||||
title="Deploy Mongo"
|
||||
description="Are you sure you want to deploy this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Mongo");
|
||||
});
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MongoDB database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Mongo"
|
||||
description="Are you sure you want to stop this mongo?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MongoDB database</p>
|
||||
<p>Downloads and sets up the MongoDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload Mongo"
|
||||
description="Are you sure you want to reload this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mongoId: mongoId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MongoDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Mongo"
|
||||
description="Are you sure you want to start this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MongoDB database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Mongo"
|
||||
description="Are you sure you want to stop this mongo?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MongoDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
mongoId: string;
|
||||
}
|
||||
export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
const { data } = api.mongo.one.useQuery({ mongoId });
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: changePassword } =
|
||||
api.mongo.changePassword.useMutation();
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={data?.databasePassword}
|
||||
/>
|
||||
<UpdateDatabasePassword
|
||||
onUpdatePassword={async (newPassword) => {
|
||||
await changePassword({
|
||||
mongoId,
|
||||
password: newPassword,
|
||||
});
|
||||
toast.success("Password updated successfully");
|
||||
utils.mongo.one.invalidate({ mongoId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +62,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
<Label>Internal Connection URL </Label>
|
||||
<ToggleVisibilityInput
|
||||
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>
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { format } from "date-fns";
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
acummulativeData: DockerStatsJSON["block"];
|
||||
accumulativeData: DockerStatsJSON["block"];
|
||||
}
|
||||
|
||||
export const DockerBlockChart = ({ acummulativeData }: Props) => {
|
||||
const transformedData = acummulativeData.map((item, index) => {
|
||||
return {
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
readMb: item.value.readMb,
|
||||
writeMb: item.value.writeMb,
|
||||
};
|
||||
});
|
||||
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) => {
|
||||
const transformedData = accumulativeData.map((item, index) => ({
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
readMb: item.value.readMb,
|
||||
writeMb: item.value.writeMb,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mt-6 w-full h-[10rem]">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorWrite" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis stroke="#A1A1AA" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="readMb"
|
||||
stroke="#27272A"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
name="Read Mb"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="writeMb"
|
||||
stroke="#82ca9d"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorWrite)"
|
||||
name="Write Mb"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillBlockRead" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-readMb)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-readMb)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillBlockWrite" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-writeMb)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-writeMb)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const time = payload?.[0]?.payload?.time;
|
||||
return time ? format(new Date(time), "PPpp") : "";
|
||||
}}
|
||||
formatter={(value, name) => {
|
||||
const label = name === "readMb" ? "Read" : "Write";
|
||||
return [`${value} MB`, label];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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 { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
acummulativeData: DockerStatsJSON["cpu"];
|
||||
accumulativeData: DockerStatsJSON["cpu"];
|
||||
}
|
||||
|
||||
export const DockerCpuChart = ({ acummulativeData }: Props) => {
|
||||
const transformedData = acummulativeData.map((item, index) => {
|
||||
return {
|
||||
name: `Point ${index + 1}`,
|
||||
time: item.time,
|
||||
usage: item.value.toString().split("%")[0],
|
||||
};
|
||||
});
|
||||
const chartConfig = {
|
||||
usage: {
|
||||
label: "CPU Usage",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const DockerCpuChart = ({ accumulativeData }: Props) => {
|
||||
const transformedData = accumulativeData.map((item, index) => ({
|
||||
name: `Point ${index + 1}`,
|
||||
time: item.time,
|
||||
usage: item.value.toString().split("%")[0],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mt-6 w-full h-[10rem]">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis stroke="#A1A1AA" domain={[0, 100]} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="usage"
|
||||
stroke="#27272A"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillCpu" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-usage)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-usage)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
domain={[0, 100]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
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,105 +1,82 @@
|
||||
import { format } from "date-fns";
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
acummulativeData: DockerStatsJSON["disk"];
|
||||
accumulativeData: DockerStatsJSON["disk"];
|
||||
diskTotal: number;
|
||||
}
|
||||
|
||||
export const DockerDiskChart = ({ acummulativeData, diskTotal }: Props) => {
|
||||
const transformedData = acummulativeData.map((item, index) => {
|
||||
return {
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
usedGb: +item.value.diskUsage,
|
||||
totalGb: +item.value.diskTotal,
|
||||
freeGb: item.value.diskFree,
|
||||
};
|
||||
});
|
||||
const chartConfig = {
|
||||
usedGb: {
|
||||
label: "Used (GB)",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
|
||||
const transformedData = accumulativeData.map((item, index) => ({
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
usedGb: +item.value.diskUsage,
|
||||
totalGb: +item.value.diskTotal,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mt-6 w-full h-[10rem]">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUsed" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorFree" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis stroke="#A1A1AA" domain={[0, diskTotal]} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="usedGb"
|
||||
stroke="#6C28D9"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUsed)"
|
||||
name="Used GB"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="freeGb"
|
||||
stroke="#8884d8"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorFree)"
|
||||
name="Free GB"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillDiskUsed" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-usedGb)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-usedGb)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
domain={[0, diskTotal]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value} GB`}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const time = payload?.[0]?.payload?.time;
|
||||
return time ? format(new Date(time), "PPpp") : "";
|
||||
}}
|
||||
formatter={(value) => {
|
||||
return [`${value} GB`, "Used"];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="usedGb"
|
||||
stroke="var(--color-usedGb)"
|
||||
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,93 +1,87 @@
|
||||
import { format } from "date-fns";
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
import { convertMemoryToBytes } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
acummulativeData: DockerStatsJSON["memory"];
|
||||
accumulativeData: DockerStatsJSON["memory"];
|
||||
memoryLimitGB: number;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
usage: {
|
||||
label: "Memory (GB)",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const DockerMemoryChart = ({
|
||||
acummulativeData,
|
||||
accumulativeData,
|
||||
memoryLimitGB,
|
||||
}: Props) => {
|
||||
const transformedData = acummulativeData.map((item, index) => {
|
||||
return {
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
// @ts-ignore
|
||||
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
|
||||
};
|
||||
});
|
||||
const transformedData = accumulativeData.map((item, index) => ({
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
// @ts-ignore
|
||||
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mt-6 w-full h-[10rem]">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis stroke="#A1A1AA" domain={[0, +memoryLimitGB.toFixed(2)]} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="usage"
|
||||
stroke="#27272A"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-usage)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-usage)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
tickFormatter={(value) => `${value} GB`}
|
||||
domain={[0, +memoryLimitGB.toFixed(2)]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
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 { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
acummulativeData: DockerStatsJSON["network"];
|
||||
accumulativeData: DockerStatsJSON["network"];
|
||||
}
|
||||
|
||||
export const DockerNetworkChart = ({ acummulativeData }: Props) => {
|
||||
const transformedData = acummulativeData.map((item, index) => {
|
||||
return {
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
inMB: item.value.inputMb,
|
||||
outMB: item.value.outputMb,
|
||||
};
|
||||
});
|
||||
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) => {
|
||||
const transformedData = accumulativeData.map((item, index) => ({
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
inMB: item.value.inputMb,
|
||||
outMB: item.value.outputMb,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mt-6 w-full h-[10rem]">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis stroke="#A1A1AA" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="inMB"
|
||||
stroke="#8884d8"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
name="In MB"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="outMB"
|
||||
stroke="#82ca9d"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
name="Out MB"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillNetIn" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-inMB)" stopOpacity={0.8} />
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-inMB)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillNetOut" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-outMB)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-outMB)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const time = payload?.[0]?.payload?.time;
|
||||
return time ? format(new Date(time), "PPpp") : "";
|
||||
}}
|
||||
formatter={(value, name) => {
|
||||
const label = name === "inMB" ? "In" : "Out";
|
||||
return [`${value} MB`, label];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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 { DockerCpuChart } from "./docker-cpu-chart";
|
||||
import { DockerDiskChart } from "./docker-disk-chart";
|
||||
import { DockerDiskUsageChart } from "./docker-disk-usage-chart";
|
||||
import { DockerMemoryChart } from "./docker-memory-chart";
|
||||
import { DockerNetworkChart } from "./docker-network-chart";
|
||||
|
||||
@@ -124,7 +125,7 @@ export const ContainerFreeMonitoring = ({
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
const [acummulativeData, setAcummulativeData] = useState<DockerStatsJSON>({
|
||||
const [accumulativeData, setAccumulativeData] = useState<DockerStatsJSON>({
|
||||
cpu: [],
|
||||
memory: [],
|
||||
block: [],
|
||||
@@ -136,7 +137,7 @@ export const ContainerFreeMonitoring = ({
|
||||
useEffect(() => {
|
||||
setCurrentData(defaultData);
|
||||
|
||||
setAcummulativeData({
|
||||
setAccumulativeData({
|
||||
cpu: [],
|
||||
memory: [],
|
||||
block: [],
|
||||
@@ -155,7 +156,7 @@ export const ContainerFreeMonitoring = ({
|
||||
network: data.network[data.network.length - 1] ?? currentData.network,
|
||||
disk: data.disk[data.disk.length - 1] ?? currentData.disk,
|
||||
});
|
||||
setAcummulativeData({
|
||||
setAccumulativeData({
|
||||
block: data?.block || [],
|
||||
cpu: data?.cpu || [],
|
||||
disk: data?.disk || [],
|
||||
@@ -184,7 +185,7 @@ export const ContainerFreeMonitoring = ({
|
||||
setCurrentData(data);
|
||||
|
||||
const MAX_DATA_POINTS = 300;
|
||||
setAcummulativeData((prevData) => ({
|
||||
setAccumulativeData((prevData) => ({
|
||||
cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS),
|
||||
memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS),
|
||||
block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS),
|
||||
@@ -219,16 +220,16 @@ export const ContainerFreeMonitoring = ({
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Used: {currentData.cpu.value}
|
||||
Used: {String(currentData.cpu.value ?? "0%")}
|
||||
</span>
|
||||
<Progress
|
||||
value={Number.parseInt(
|
||||
currentData.cpu.value.replace("%", ""),
|
||||
String(currentData.cpu.value ?? "0%").replace("%", ""),
|
||||
10,
|
||||
)}
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||
<DockerCpuChart accumulativeData={accumulativeData.cpu} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -252,7 +253,7 @@ export const ContainerFreeMonitoring = ({
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerMemoryChart
|
||||
acummulativeData={acummulativeData.memory}
|
||||
accumulativeData={accumulativeData.memory}
|
||||
memoryLimitGB={
|
||||
// @ts-ignore
|
||||
convertMemoryToBytes(currentData.memory.value.total) /
|
||||
@@ -277,13 +278,25 @@ export const ContainerFreeMonitoring = ({
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerDiskChart
|
||||
acummulativeData={acummulativeData.disk}
|
||||
accumulativeData={accumulativeData.disk}
|
||||
diskTotal={currentData.disk.value.diskTotal}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -294,7 +307,7 @@ export const ContainerFreeMonitoring = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
|
||||
</span>
|
||||
<DockerBlockChart acummulativeData={acummulativeData.block} />
|
||||
<DockerBlockChart accumulativeData={accumulativeData.block} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -307,7 +320,7 @@ export const ContainerFreeMonitoring = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
|
||||
</span>
|
||||
<DockerNetworkChart acummulativeData={acummulativeData.network} />
|
||||
<DockerNetworkChart accumulativeData={accumulativeData.network} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowGeneralMysql = ({ mysqlId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.mysql.one.useQuery(
|
||||
{
|
||||
mysqlId,
|
||||
@@ -71,153 +73,158 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Deploy MySQL"
|
||||
description="Are you sure you want to deploy this mysql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the MySQL database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload MySQL"
|
||||
description="Are you sure you want to reload this mysql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mysqlId: mysqlId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading MySQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MySQL service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Start MySQL"
|
||||
description="Are you sure you want to start this mysql?"
|
||||
title="Deploy MySQL"
|
||||
description="Are you sure you want to deploy this mysql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mysqlId: mysqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting MySQL");
|
||||
});
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MySQL database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop MySQL"
|
||||
description="Are you sure you want to stop this mysql?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mysqlId: mysqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping MySQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MySQL database</p>
|
||||
<p>Downloads and sets up the MySQL database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload MySQL"
|
||||
description="Are you sure you want to reload this mysql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mysqlId: mysqlId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading MySQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MySQL service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start MySQL"
|
||||
description="Are you sure you want to start this mysql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mysqlId: mysqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting MySQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MySQL database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop MySQL"
|
||||
description="Are you sure you want to stop this mysql?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mysqlId: mysqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping MySQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MySQL database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
mysqlId: string;
|
||||
}
|
||||
export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
const { data } = api.mysql.one.useQuery({ mysqlId });
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: changePassword } =
|
||||
api.mysql.changePassword.useMutation();
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
@@ -28,20 +33,43 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
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 className="flex flex-col gap-2">
|
||||
<Label>Root Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
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 className="flex flex-col gap-2">
|
||||
|
||||
@@ -42,6 +42,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
@@ -56,6 +57,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
@@ -84,7 +86,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
form.reset({
|
||||
dockerImage: data.dockerImage,
|
||||
command: data.command || "",
|
||||
args: data.args?.map((arg) => ({ value: arg })) || [],
|
||||
args: (data as any).args?.map((arg: string) => ({ value: arg })) || [],
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
@@ -95,6 +97,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
libsqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
dockerImage: formData?.dockerImage,
|
||||
command: formData?.command,
|
||||
@@ -144,7 +147,14 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/bin/sh" {...field} />
|
||||
<Input
|
||||
placeholder={
|
||||
type === "libsql"
|
||||
? "sqld --db-path iku.db --http-listen-addr 0.0.0.0:8080 --grpc-listen-addr 0.0.0.0:5001 --admin-listen-addr 0.0.0.0:5000"
|
||||
: "Custom command"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowGeneralPostgres = ({ postgresId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.postgres.one.useQuery(
|
||||
{
|
||||
postgresId: postgresId,
|
||||
@@ -73,153 +75,162 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider disableHoverableContent={false}>
|
||||
<DialogAction
|
||||
title="Deploy PostgreSQL"
|
||||
description="Are you sure you want to deploy this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the PostgreSQL database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload PostgreSQL"
|
||||
description="Are you sure you want to reload this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
postgresId: postgresId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading PostgreSQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the PostgreSQL service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Start PostgreSQL"
|
||||
description="Are you sure you want to start this postgres?"
|
||||
title="Deploy PostgreSQL"
|
||||
description="Are you sure you want to deploy this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting PostgreSQL");
|
||||
});
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the PostgreSQL database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop PostgreSQL"
|
||||
description="Are you sure you want to stop this postgres?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping PostgreSQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running PostgreSQL database</p>
|
||||
<p>Downloads and sets up the PostgreSQL database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload PostgreSQL"
|
||||
description="Are you sure you want to reload this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
postgresId: postgresId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading PostgreSQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Restart the PostgreSQL service without rebuilding
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start PostgreSQL"
|
||||
description="Are you sure you want to start this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting PostgreSQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the PostgreSQL database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop PostgreSQL"
|
||||
description="Are you sure you want to stop this postgres?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping PostgreSQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Stop the currently running PostgreSQL database
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
postgresId: string;
|
||||
}
|
||||
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
const { data } = api.postgres.one.useQuery({ postgresId });
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: changePassword } =
|
||||
api.postgres.changePassword.useMutation();
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
@@ -28,11 +33,21 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<ToggleVisibilityInput
|
||||
value={data?.databasePassword}
|
||||
disabled
|
||||
/>
|
||||
<UpdateDatabasePassword
|
||||
onUpdatePassword={async (newPassword) => {
|
||||
await changePassword({
|
||||
postgresId,
|
||||
password: newPassword,
|
||||
});
|
||||
toast.success("Password updated successfully");
|
||||
utils.postgres.one.invalidate({ postgresId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} 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 AddTemplateSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
@@ -53,9 +54,8 @@ const AddTemplateSchema = z.object({
|
||||
.min(1, {
|
||||
message: "App name is required",
|
||||
})
|
||||
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
||||
message:
|
||||
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
||||
.regex(APP_NAME_REGEX, {
|
||||
message: APP_NAME_MESSAGE,
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} 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 AddComposeSchema = z.object({
|
||||
composeType: z.enum(["docker-compose", "stack"]).optional(),
|
||||
@@ -54,9 +55,8 @@ const AddComposeSchema = z.object({
|
||||
.min(1, {
|
||||
message: "App name is required",
|
||||
})
|
||||
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
||||
message:
|
||||
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
||||
.regex(APP_NAME_REGEX, {
|
||||
message: APP_NAME_MESSAGE,
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
@@ -78,9 +78,6 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
const { mutateAsync, isPending, error, isError } =
|
||||
api.compose.create.useMutation();
|
||||
|
||||
// Get environment data to extract projectId
|
||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
@@ -117,6 +114,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
});
|
||||
// Invalidate the project query to refresh the project data for the advance-breadcrumb
|
||||
await utils.project.all.invalidate();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error creating the compose");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
LibsqlIcon,
|
||||
MariadbIcon,
|
||||
MongodbIcon,
|
||||
MysqlIcon,
|
||||
@@ -51,11 +52,13 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { api } from "@/utils/api";
|
||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
||||
|
||||
type DbType = z.infer<typeof mySchema>["type"];
|
||||
|
||||
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||
mongo: "mongo:7",
|
||||
mongo: "mongo:8",
|
||||
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
|
||||
mariadb: "mariadb:11",
|
||||
mysql: "mysql:8",
|
||||
postgres: "postgres:18",
|
||||
@@ -66,8 +69,9 @@ const databasesUserDefaultPlaceholder: Record<
|
||||
Exclude<DbType, "redis">,
|
||||
string
|
||||
> = {
|
||||
mongo: "mongo",
|
||||
libsql: "libsql",
|
||||
mariadb: "mariadb",
|
||||
mongo: "mongo",
|
||||
mysql: "mysql",
|
||||
postgres: "postgres",
|
||||
};
|
||||
@@ -79,9 +83,8 @@ const baseDatabaseSchema = z.object({
|
||||
.min(1, {
|
||||
message: "App name is required",
|
||||
})
|
||||
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
||||
message:
|
||||
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
||||
.regex(APP_NAME_REGEX, {
|
||||
message: APP_NAME_MESSAGE,
|
||||
}),
|
||||
databasePassword: z
|
||||
.string()
|
||||
@@ -94,56 +97,88 @@ const baseDatabaseSchema = z.object({
|
||||
serverId: z.string().nullable(),
|
||||
});
|
||||
|
||||
const mySchema = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("postgres"),
|
||||
databaseName: z.string().default("postgres"),
|
||||
databaseUser: z.string().default("postgres"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mongo"),
|
||||
databaseUser: z.string().default("mongo"),
|
||||
replicaSets: z.boolean().default(false),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("redis"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mysql"),
|
||||
databaseRootPassword: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
const mySchema = z
|
||||
.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("libsql"),
|
||||
dockerImage: z
|
||||
.string()
|
||||
.default("ghcr.io/tursodatabase/libsql-server:v0.24.32"),
|
||||
databaseUser: z.string().default("libsql"),
|
||||
sqldNode: z.enum(["primary", "replica"]).default("primary"),
|
||||
sqldPrimaryUrl: z.string().optional(),
|
||||
enableNamespaces: z.boolean().default(false),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mariadb"),
|
||||
dockerImage: z.string().default("mariadb:4"),
|
||||
databaseRootPassword: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
})
|
||||
.optional(),
|
||||
databaseUser: z.string().default("mariadb"),
|
||||
databaseName: z.string().default("mariadb"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mongo"),
|
||||
databaseUser: z.string().default("mongo"),
|
||||
replicaSets: z.boolean().default(false),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mysql"),
|
||||
databaseRootPassword: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
})
|
||||
.optional(),
|
||||
databaseUser: z.string().default("mysql"),
|
||||
databaseName: z.string().default("mysql"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("postgres"),
|
||||
databaseName: z.string().default("postgres"),
|
||||
databaseUser: z.string().default("postgres"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("redis"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.type === "libsql") {
|
||||
if (data.sqldNode === "replica" && !data.sqldPrimaryUrl) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sqldPrimaryUrl"],
|
||||
message: "sqldPrimaryUrl is required when sqldNode is 'replica'.",
|
||||
});
|
||||
}
|
||||
if (data.sqldNode !== "replica" && data.sqldPrimaryUrl) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sqldPrimaryUrl"],
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
})
|
||||
.optional(),
|
||||
databaseUser: z.string().default("mysql"),
|
||||
databaseName: z.string().default("mysql"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mariadb"),
|
||||
dockerImage: z.string().default("mariadb:4"),
|
||||
databaseRootPassword: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
})
|
||||
.optional(),
|
||||
databaseUser: z.string().default("mariadb"),
|
||||
databaseName: z.string().default("mariadb"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
]);
|
||||
"sqldPrimaryUrl should not be provided when sqldNode is not 'replica'.",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const databasesMap = {
|
||||
postgres: {
|
||||
@@ -166,6 +201,10 @@ const databasesMap = {
|
||||
icon: <RedisIcon />,
|
||||
label: "Redis",
|
||||
},
|
||||
libsql: {
|
||||
icon: <LibsqlIcon className="size-10" />,
|
||||
label: "libSQL",
|
||||
},
|
||||
};
|
||||
|
||||
type AddDatabase = z.infer<typeof mySchema>;
|
||||
@@ -181,11 +220,12 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
const slug = slugify(projectName);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const postgresMutation = api.postgres.create.useMutation();
|
||||
const mongoMutation = api.mongo.create.useMutation();
|
||||
const redisMutation = api.redis.create.useMutation();
|
||||
const libsqlMutation = api.libsql.create.useMutation();
|
||||
const mariadbMutation = api.mariadb.create.useMutation();
|
||||
const mongoMutation = api.mongo.create.useMutation();
|
||||
const mysqlMutation = api.mysql.create.useMutation();
|
||||
const postgresMutation = api.postgres.create.useMutation();
|
||||
const redisMutation = api.redis.create.useMutation();
|
||||
|
||||
// Get environment data to extract projectId
|
||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
@@ -210,13 +250,15 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
},
|
||||
resolver: zodResolver(mySchema),
|
||||
});
|
||||
const sqldNode = form.watch("sqldNode");
|
||||
const type = form.watch("type");
|
||||
const activeMutation = {
|
||||
postgres: postgresMutation,
|
||||
mongo: mongoMutation,
|
||||
redis: redisMutation,
|
||||
libsql: libsqlMutation,
|
||||
mariadb: mariadbMutation,
|
||||
mongo: mongoMutation,
|
||||
mysql: mysqlMutation,
|
||||
postgres: postgresMutation,
|
||||
redis: redisMutation,
|
||||
};
|
||||
|
||||
const onSubmit = async (data: AddDatabase) => {
|
||||
@@ -233,12 +275,23 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
description: data.description,
|
||||
};
|
||||
|
||||
if (data.type === "postgres") {
|
||||
promise = postgresMutation.mutateAsync({
|
||||
if (data.type === "libsql") {
|
||||
promise = libsqlMutation.mutateAsync({
|
||||
...commonParams,
|
||||
sqldNode: data.sqldNode,
|
||||
sqldPrimaryUrl: data.sqldPrimaryUrl ?? null,
|
||||
enableNamespaces: data.enableNamespaces,
|
||||
databasePassword: data.databasePassword,
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
} else if (data.type === "mariadb") {
|
||||
promise = mariadbMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
databaseName: data.databaseName || "postgres",
|
||||
|
||||
databaseRootPassword: data.databaseRootPassword || "",
|
||||
databaseName: data.databaseName || "mariadb",
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
@@ -252,22 +305,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
replicaSets: data.replicaSets,
|
||||
});
|
||||
} else if (data.type === "redis") {
|
||||
promise = redisMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
} else if (data.type === "mariadb") {
|
||||
promise = mariadbMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
databaseRootPassword: data.databaseRootPassword || "",
|
||||
databaseName: data.databaseName || "mariadb",
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
} else if (data.type === "mysql") {
|
||||
promise = mysqlMutation.mutateAsync({
|
||||
...commonParams,
|
||||
@@ -278,6 +315,21 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
databaseRootPassword: data.databaseRootPassword || "",
|
||||
});
|
||||
} else if (data.type === "postgres") {
|
||||
promise = postgresMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
databaseName: data.databaseName || "postgres",
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
} else if (data.type === "redis") {
|
||||
promise = redisMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
@@ -305,6 +357,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={setVisible}>
|
||||
<DialogTrigger className="w-full">
|
||||
@@ -506,8 +559,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{(type === "mysql" ||
|
||||
type === "mariadb" ||
|
||||
{(type === "mariadb" ||
|
||||
type === "mysql" ||
|
||||
type === "postgres") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -524,10 +577,101 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(type === "mysql" ||
|
||||
|
||||
{type === "libsql" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sqldNode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sqld Node</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || "primary"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={"primary"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{["primary", "replica"].map((node) => (
|
||||
<SelectItem key={node} value={node}>
|
||||
{node.charAt(0).toUpperCase() + node.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{type === "libsql" && sqldNode === "replica" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sqldPrimaryUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sqld Primary URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"https://<host>:<port>"}
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{type === "libsql" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableNamespaces"
|
||||
render={({ field }) => {
|
||||
console.log(field.value);
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Enable Namespaces</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Boolean(value))
|
||||
}
|
||||
defaultValue={
|
||||
field.value ? String(field.value) : "false"
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={"false"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{["false", "true"].map((node) => (
|
||||
<SelectItem key={node} value={node}>
|
||||
{node.charAt(0).toUpperCase() +
|
||||
node.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(type === "libsql" ||
|
||||
type === "mariadb" ||
|
||||
type === "postgres" ||
|
||||
type === "mongo") && (
|
||||
type === "mongo" ||
|
||||
type === "mysql" ||
|
||||
type === "postgres") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="databaseUser"
|
||||
@@ -568,7 +712,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{(type === "mysql" || type === "mariadb") && (
|
||||
{(type === "mariadb" || type === "mysql") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="databaseRootPassword"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BookText,
|
||||
Bookmark,
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
Globe,
|
||||
@@ -82,6 +83,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [showBookmarksOnly, setShowBookmarksOnly] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
|
||||
// Try to get from props first, then localStorage
|
||||
if (baseUrl) return baseUrl;
|
||||
@@ -122,8 +124,45 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: bookmarkIds = [], isLoading: isLoadingBookmarks } =
|
||||
api.user.getBookmarkedTemplates.useQuery(undefined, {
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
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 { mutateAsync, isPending, error, isError } =
|
||||
api.compose.deployTemplate.useMutation();
|
||||
@@ -137,7 +176,9 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
query === "" ||
|
||||
template.name.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;
|
||||
@@ -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)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const handleToggleBookmark = async (
|
||||
e: React.MouseEvent,
|
||||
templateId: string,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
await toggleBookmark({ templateId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger className="w-full">
|
||||
@@ -243,6 +292,20 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</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
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
@@ -299,11 +362,19 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
) : 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" />
|
||||
<div className="text-xl font-medium text-muted-foreground">
|
||||
No templates found
|
||||
{showBookmarksOnly
|
||||
? "No bookmarked templates found"
|
||||
: "No templates found"}
|
||||
</div>
|
||||
{showBookmarksOnly && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click the bookmark icon on templates to add them to
|
||||
bookmarks
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -323,15 +394,32 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
viewMode === "detailed" && "h-[400px]",
|
||||
)}
|
||||
>
|
||||
<Badge className="absolute top-2 right-2" variant="blue">
|
||||
{template?.version}
|
||||
</Badge>
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<Button
|
||||
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
|
||||
className={cn(
|
||||
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
|
||||
viewMode === "detailed" && "border-b",
|
||||
)}
|
||||
>
|
||||
{/** biome-ignore lint/performance/noImgElement: this is a valid use for img tag */}
|
||||
<img
|
||||
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
|
||||
className={cn(
|
||||
|
||||
@@ -57,19 +57,13 @@ export const AdvancedEnvironmentSelector = ({
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// Get current user's permissions
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
// Check if user can create environments
|
||||
const canCreateEnvironments =
|
||||
currentUser?.role === "owner" ||
|
||||
currentUser?.role === "admin" ||
|
||||
currentUser?.canCreateEnvironments === true;
|
||||
const canCreateEnvironments = !!permissions?.environment.create;
|
||||
|
||||
// Check if user can delete environments
|
||||
const canDeleteEnvironments =
|
||||
currentUser?.role === "owner" ||
|
||||
currentUser?.role === "admin" ||
|
||||
currentUser?.canDeleteEnvironments === true;
|
||||
const canDeleteEnvironments = !!permissions?.environment.delete;
|
||||
|
||||
const haveServices =
|
||||
selectedEnvironment &&
|
||||
@@ -98,6 +92,8 @@ export const AdvancedEnvironmentSelector = ({
|
||||
|
||||
toast.success("Environment created successfully");
|
||||
utils.environment.byProjectId.invalidate({ projectId });
|
||||
// Invalidate the project query to refresh the project data for the advance-breadcrumb
|
||||
utils.project.all.invalidate();
|
||||
setIsCreateDialogOpen(false);
|
||||
setName("");
|
||||
setDescription("");
|
||||
|
||||
@@ -298,7 +298,19 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2 w-full justify-end">
|
||||
<Button
|
||||
onClick={stepper.prev}
|
||||
onClick={() => {
|
||||
if (
|
||||
stepper.current.id === "variant" &&
|
||||
templateInfo.details
|
||||
) {
|
||||
setTemplateInfo((prev) => ({
|
||||
...prev,
|
||||
details: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
stepper.prev();
|
||||
}}
|
||||
disabled={stepper.isFirst}
|
||||
variant="secondary"
|
||||
>
|
||||
|
||||
@@ -28,13 +28,14 @@ export type Services = {
|
||||
serverId?: string | null;
|
||||
name: string;
|
||||
type:
|
||||
| "mariadb"
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "compose"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose";
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
description?: string | null;
|
||||
id: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -39,6 +39,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canRead = permissions?.environmentEnvVars.read ?? false;
|
||||
const canWrite = permissions?.environmentEnvVars.write ?? false;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isPending } =
|
||||
@@ -85,7 +88,12 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
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();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
@@ -97,6 +105,10 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
};
|
||||
}, [form, onSubmit, isPending, isOpen]);
|
||||
|
||||
if (!canRead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -141,6 +153,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
<CodeEditor
|
||||
lineWrapping
|
||||
language="properties"
|
||||
readOnly={!canWrite}
|
||||
wrapperClassName="h-[35rem] font-mono"
|
||||
placeholder={`NODE_ENV=development
|
||||
DATABASE_URL=postgresql://localhost:5432/mydb
|
||||
@@ -157,11 +170,13 @@ API_KEY=your-api-key-here
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{canWrite && (
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { TagSelector } from "@/components/shared/tag-selector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -62,6 +63,7 @@ interface Props {
|
||||
export const HandleProject = ({ projectId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
|
||||
const { mutateAsync, error, isError } = projectId
|
||||
? api.project.update.useMutation()
|
||||
@@ -75,6 +77,10 @@ export const HandleProject = ({ projectId }: Props) => {
|
||||
enabled: !!projectId,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: availableTags = [] } = api.tag.all.useQuery();
|
||||
const bulkAssignMutation = api.tag.bulkAssign.useMutation();
|
||||
|
||||
const router = useRouter();
|
||||
const form = useForm<AddProject>({
|
||||
defaultValues: {
|
||||
@@ -89,6 +95,13 @@ export const HandleProject = ({ projectId }: Props) => {
|
||||
description: data?.description ?? "",
|
||||
name: data?.name ?? "",
|
||||
});
|
||||
// Load existing tags when editing a project
|
||||
if (data?.projectTags) {
|
||||
const tagIds = data.projectTags.map((pt) => pt.tagId);
|
||||
setSelectedTagIds(tagIds);
|
||||
} else {
|
||||
setSelectedTagIds([]);
|
||||
}
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||
|
||||
const onSubmit = async (data: AddProject) => {
|
||||
@@ -98,12 +111,26 @@ export const HandleProject = ({ projectId }: Props) => {
|
||||
projectId: projectId || "",
|
||||
})
|
||||
.then(async (data) => {
|
||||
// Assign tags to the project (both create and update)
|
||||
const projectIdToUse =
|
||||
projectId ||
|
||||
(data && "project" in data ? data.project.projectId : undefined);
|
||||
|
||||
if (projectIdToUse) {
|
||||
try {
|
||||
await bulkAssignMutation.mutateAsync({
|
||||
projectId: projectIdToUse,
|
||||
tagIds: selectedTagIds,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Failed to assign tags to project");
|
||||
}
|
||||
}
|
||||
|
||||
await utils.project.all.invalidate();
|
||||
toast.success(projectId ? "Project Updated" : "Project Created");
|
||||
setIsOpen(false);
|
||||
if (!projectId) {
|
||||
const projectIdToUse =
|
||||
data && "project" in data ? data.project.projectId : undefined;
|
||||
const environmentIdToUse =
|
||||
data && "environment" in data
|
||||
? data.environment.environmentId
|
||||
@@ -190,6 +217,20 @@ export const HandleProject = ({ projectId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Tags</FormLabel>
|
||||
<TagSelector
|
||||
tags={availableTags.map((tag) => ({
|
||||
id: tag.tagId,
|
||||
name: tag.name,
|
||||
color: tag.color ?? undefined,
|
||||
}))}
|
||||
selectedTags={selectedTagIds}
|
||||
onTagsChange={setSelectedTagIds}
|
||||
placeholder="Select tags..."
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user