mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Compare commits
230 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60c03e1ca7 | ||
|
|
d42fa738ea | ||
|
|
160742c2cf | ||
|
|
4c5bc541d6 | ||
|
|
d13871cd08 | ||
|
|
a12beb6748 | ||
|
|
4c90f4754f | ||
|
|
69fdda505d | ||
|
|
16e84e431a | ||
|
|
5d4db4d0f3 | ||
|
|
10d2493bcc | ||
|
|
ce97bc6c27 | ||
|
|
c2e05e86d9 | ||
|
|
5cd743eb10 | ||
|
|
cd32c55031 | ||
|
|
7f2ebab66c | ||
|
|
0bc2734925 | ||
|
|
f74d02381f | ||
|
|
d46afbef2d | ||
|
|
be64a1554d | ||
|
|
8d9d00d0c6 | ||
|
|
31164c9798 | ||
|
|
4d4de1424e | ||
|
|
fa954c3bbd | ||
|
|
005f73d665 | ||
|
|
bbe7d5bdc5 | ||
|
|
6f7a5609a3 | ||
|
|
c3a5e2a8d6 | ||
|
|
1ca965268e | ||
|
|
e323ade29e | ||
|
|
8c916bc431 | ||
|
|
0670f9b910 | ||
|
|
44f002d8d0 | ||
|
|
27f6c945e0 | ||
|
|
e61c216ea0 | ||
|
|
9f9492af79 | ||
|
|
68f608bdc9 | ||
|
|
8f671d1691 | ||
|
|
7afbe8b208 | ||
|
|
8c05214e78 | ||
|
|
07769e69d6 | ||
|
|
2ace36f035 | ||
|
|
b7196a3494 | ||
|
|
3b737ca55b | ||
|
|
581e590f65 | ||
|
|
d66a5d55a3 | ||
|
|
47db6831b4 | ||
|
|
56cbd1abb3 | ||
|
|
cb40ac5c6b | ||
|
|
7218b3f79b | ||
|
|
19ea4d3fcd | ||
|
|
6edfd1e547 | ||
|
|
666a8ede97 | ||
|
|
08e4b8fe33 | ||
|
|
5fc265d14f | ||
|
|
c3887af5d1 | ||
|
|
a6684af57e | ||
|
|
8df2b20c3b | ||
|
|
f159dc11eb | ||
|
|
fce22ec1d0 | ||
|
|
e63eed57dd | ||
|
|
acc8ce80ad | ||
|
|
e317772367 | ||
|
|
a15d9234be | ||
|
|
bd65f566fa | ||
|
|
7c8594aadb | ||
|
|
b8c1a9164a | ||
|
|
698118074a | ||
|
|
2fa691c5bd | ||
|
|
87b007201a | ||
|
|
b3b9b1956c | ||
|
|
d42a859679 | ||
|
|
3a1fa95d17 | ||
|
|
a45af37b5d | ||
|
|
53312f6fa7 | ||
|
|
cd8b6145f6 | ||
|
|
d4a98eb85e | ||
|
|
152b2e1a5d | ||
|
|
19827fce84 | ||
|
|
58f4d3561e | ||
|
|
791a6c6f35 | ||
|
|
7580a5dcd6 | ||
|
|
6def84d456 | ||
|
|
6e7e7b3f9a | ||
|
|
466fdf20b8 | ||
|
|
991141460b | ||
|
|
1a060d4204 | ||
|
|
64643c11aa | ||
|
|
b73bb0db5f | ||
|
|
6287f3be4a | ||
|
|
978cd61592 | ||
|
|
6467ce0a24 | ||
|
|
f9f70efd2f | ||
|
|
6df0878ed4 | ||
|
|
a1bbfaebf4 | ||
|
|
ed89f5aa8a | ||
|
|
888e904d75 | ||
|
|
3e522b9cae | ||
|
|
7903ddba89 | ||
|
|
3a0dbc26d1 | ||
|
|
6df680e9da | ||
|
|
2bced3e9b6 | ||
|
|
911a7730f9 | ||
|
|
2902648188 | ||
|
|
688601107c | ||
|
|
6b4ec55e64 | ||
|
|
b7f63fdad4 | ||
|
|
404579b434 | ||
|
|
b98d57e99a | ||
|
|
dc5d79085c | ||
|
|
b95c90e6d8 | ||
|
|
988e5cb23e | ||
|
|
19f574e168 | ||
|
|
c462ad6144 | ||
|
|
3acf80cec1 | ||
|
|
0372372ae3 | ||
|
|
492d51337c | ||
|
|
467bca3efb | ||
|
|
9d50f384d1 | ||
|
|
4371e7e033 | ||
|
|
c1aeb828d8 | ||
|
|
1ad25ca6d1 | ||
|
|
1884a3d041 | ||
|
|
de48c81192 | ||
|
|
e4197d6565 | ||
|
|
0c6625fff7 | ||
|
|
cc8ffca4d4 | ||
|
|
c0b5f9e51a | ||
|
|
4730845a40 | ||
|
|
00fc1a9c96 | ||
|
|
624eedd74d | ||
|
|
c5272aa915 | ||
|
|
2fdb7c6757 | ||
|
|
777aa3e4be | ||
|
|
55bab4bba4 | ||
|
|
6afd1bf531 | ||
|
|
62bd8e3c95 | ||
|
|
85734c0a24 | ||
|
|
8d18aeda45 | ||
|
|
45923d3a1f | ||
|
|
043843f714 | ||
|
|
7dda252b7c | ||
|
|
bf0668c319 | ||
|
|
fc1dbcf51a | ||
|
|
b34987530e | ||
|
|
ff8d922f2b | ||
|
|
01c33ad98b | ||
|
|
9816ecaea1 | ||
|
|
832fa526dd | ||
|
|
2a5eceb555 | ||
|
|
08d7c4e1c3 | ||
|
|
c89f957133 | ||
|
|
8ba3a42c1e | ||
|
|
a96af6536b | ||
|
|
2c3ff5794d | ||
|
|
673e0a6880 | ||
|
|
b64ddf1119 | ||
|
|
2f074ac734 | ||
|
|
96e3721b4b | ||
|
|
b8e5cae88f | ||
|
|
fa20444a14 | ||
|
|
668ccabec8 | ||
|
|
aa07a0c574 | ||
|
|
0b64b43376 | ||
|
|
5c65dc9a21 | ||
|
|
58262606d4 | ||
|
|
f73959db41 | ||
|
|
e6c664e65f | ||
|
|
36cc157566 | ||
|
|
7e070623cc | ||
|
|
b2c0a685f8 | ||
|
|
c14528886d | ||
|
|
29eb490e2d | ||
|
|
6166963b00 | ||
|
|
f544efed35 | ||
|
|
598d095241 | ||
|
|
457a8e05fd | ||
|
|
3ca057c44a | ||
|
|
ad3a0198e9 | ||
|
|
ab5f62604c | ||
|
|
bf9e886b9a | ||
|
|
f5cd0fbdd8 | ||
|
|
8859cc97b4 | ||
|
|
3bdd5e4dd0 | ||
|
|
b0c710aa92 | ||
|
|
c83d0a95b7 | ||
|
|
71ca5babfd | ||
|
|
f342613503 | ||
|
|
cf4d6539e4 | ||
|
|
401f8d9be4 | ||
|
|
1d2da0ac35 | ||
|
|
d1391d7ddb | ||
|
|
b35bd9b719 | ||
|
|
faab80bee1 | ||
|
|
54a3c6efff | ||
|
|
efd176451f | ||
|
|
a7fd64e019 | ||
|
|
21c8b98f9c | ||
|
|
69dd704e1c | ||
|
|
6846e0e5a3 | ||
|
|
a27e523b0d | ||
|
|
49d4cea06f | ||
|
|
8063673a7c | ||
|
|
bf04dfa757 | ||
|
|
d2e0536355 | ||
|
|
f75d802749 | ||
|
|
2ae14c65cf | ||
|
|
7f8f6ac64c | ||
|
|
3f45eb467b | ||
|
|
9aff4bc10b | ||
|
|
49b37d531a | ||
|
|
29c1e4691e | ||
|
|
203da1a8fe | ||
|
|
b35a8a1ecc | ||
|
|
498a8523da | ||
|
|
9e4efaeca6 | ||
|
|
0db9cb4418 | ||
|
|
52e34b64a3 | ||
|
|
bc8f54a2b9 | ||
|
|
8b3e643ce7 | ||
|
|
068dd33033 | ||
|
|
0f99ca9c67 | ||
|
|
54b9f7b699 | ||
|
|
cbc74b1c5e | ||
|
|
ea910db9d1 | ||
|
|
bfec980e45 | ||
|
|
c94f03804b | ||
|
|
0fde5a74cc | ||
|
|
c91f5dfc68 | ||
|
|
e2275100a9 |
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Bug Report
|
||||
description: Create a bug report
|
||||
labels: ["bug"]
|
||||
labels: ["needs-triage🔍"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -62,6 +62,7 @@ body:
|
||||
- "Docker"
|
||||
- "Remote server"
|
||||
- "Local Development"
|
||||
- "Cloud Version"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -165,86 +165,8 @@ Thank you for your contribution!
|
||||
|
||||
## Templates
|
||||
|
||||
To add a new template, go to `templates` folder and create a new folder with the name of the template.
|
||||
To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file.
|
||||
|
||||
Let's take the example of `plausible` template.
|
||||
|
||||
1. create a folder in `templates/plausible`
|
||||
2. create a `docker-compose.yml` file inside the folder with the content of compose.
|
||||
3. create a `index.ts` file inside the folder with the following code as base:
|
||||
4. When creating a pull request, please provide a video of the template working in action.
|
||||
|
||||
```typescript
|
||||
// EXAMPLE
|
||||
import {
|
||||
generateBase64,
|
||||
generateHash,
|
||||
generateRandomDomain,
|
||||
type Template,
|
||||
type Schema,
|
||||
type DomainSchema,
|
||||
} from "../utils";
|
||||
|
||||
export function generate(schema: Schema): Template {
|
||||
// do your stuff here, like create a new domain, generate random passwords, mounts.
|
||||
const mainServiceHash = generateHash(schema.projectName);
|
||||
const mainDomain = generateRandomDomain(schema);
|
||||
const secretBase = generateBase64(64);
|
||||
const toptKeyBase = generateBase64(32);
|
||||
|
||||
const domains: DomainSchema[] = [
|
||||
{
|
||||
host: mainDomain,
|
||||
port: 8000,
|
||||
serviceName: "plausible",
|
||||
},
|
||||
];
|
||||
|
||||
const envs = [
|
||||
`BASE_URL=http://${mainDomain}`,
|
||||
`SECRET_KEY_BASE=${secretBase}`,
|
||||
`TOTP_VAULT_KEY=${toptKeyBase}`,
|
||||
`HASH=${mainServiceHash}`,
|
||||
];
|
||||
|
||||
const mounts: Template["mounts"] = [
|
||||
{
|
||||
filePath: "./clickhouse/clickhouse-config.xml",
|
||||
content: "some content......",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
envs,
|
||||
mounts,
|
||||
domains,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
4. Now you need to add the information about the template to the `templates/templates.ts` is a object with the following properties:
|
||||
|
||||
**Make sure the id of the template is the same as the folder name and don't have any spaces, only slugified names and lowercase.**
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "plausible",
|
||||
name: "Plausible",
|
||||
version: "v2.1.0",
|
||||
description:
|
||||
"Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.",
|
||||
logo: "plausible.svg", // we defined the name and the extension of the logo
|
||||
links: {
|
||||
github: "https://github.com/plausible/plausible",
|
||||
website: "https://plausible.io/",
|
||||
docs: "https://plausible.io/docs",
|
||||
},
|
||||
tags: ["analytics"],
|
||||
load: () => import("./plausible/index").then((m) => m.generate),
|
||||
},
|
||||
```
|
||||
|
||||
5. Add the logo or image of the template to `public/templates/plausible.svg`
|
||||
|
||||
### Recommendations
|
||||
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
|
||||
|
||||
# Contributing
|
||||
|
||||
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
|
||||
|
||||
|
||||
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
|
||||
|
||||
We have a few guidelines to follow when contributing to this project:
|
||||
|
||||
- [Commit Convention](#commit-convention)
|
||||
- [Setup](#setup)
|
||||
- [Development](#development)
|
||||
- [Build](#build)
|
||||
- [Pull Request](#pull-request)
|
||||
|
||||
## Commit Convention
|
||||
|
||||
Before you craete a Pull Request, please make sure your commit message follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
|
||||
|
||||
### Commit Message Format
|
||||
```
|
||||
<type>[optional scope]: <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
#### Type
|
||||
Must be one of the following:
|
||||
|
||||
* **feat**: A new feature
|
||||
* **fix**: A bug fix
|
||||
* **docs**: Documentation only changes
|
||||
* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
|
||||
* **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
* **perf**: A code change that improves performance
|
||||
* **test**: Adding missing tests or correcting existing tests
|
||||
* **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
* **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
|
||||
* **chore**: Other changes that don't modify `src` or `test` files
|
||||
* **revert**: Reverts a previous commit
|
||||
|
||||
Example:
|
||||
```
|
||||
feat: add new feature
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
## Setup
|
||||
|
||||
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dokploy/dokploy.git
|
||||
cd dokploy
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Is required to have **Docker** installed on your machine.
|
||||
|
||||
|
||||
### Setup
|
||||
|
||||
Run the command that will spin up all the required services and files.
|
||||
|
||||
```bash
|
||||
pnpm run setup
|
||||
```
|
||||
|
||||
Now run the development server.
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
|
||||
Go to http://localhost:3000 to see the development server
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
To build the docker image
|
||||
```bash
|
||||
pnpm run docker:build
|
||||
```
|
||||
|
||||
To push the docker image
|
||||
```bash
|
||||
pnpm run docker:push
|
||||
```
|
||||
|
||||
## Password Reset
|
||||
|
||||
In the case you lost your password, you can reset it using the following command
|
||||
|
||||
```bash
|
||||
pnpm run reset-password
|
||||
```
|
||||
|
||||
If you want to test the webhooks on development mode using localtunnel, make sure to install `localtunnel`
|
||||
|
||||
```bash
|
||||
bunx lt --port 3000
|
||||
```
|
||||
|
||||
If you run into permission issues of docker run the following command
|
||||
|
||||
```bash
|
||||
sudo chown -R USERNAME dokploy or sudo chown -R $(whoami) ~/.docker
|
||||
```
|
||||
|
||||
## Application deploy
|
||||
|
||||
In case you want to deploy the application on your machine and you selected nixpacks or buildpacks, you need to install first.
|
||||
|
||||
```bash
|
||||
# Install Nixpacks
|
||||
curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||
&& chmod +x install.sh \
|
||||
&& ./install.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
# Install Buildpacks
|
||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||
```
|
||||
|
||||
|
||||
## Pull Request
|
||||
|
||||
- The `main` branch is the source of truth and should always reflect the latest stable release.
|
||||
- Create a new branch for each feature or bug fix.
|
||||
- Make sure to add tests for your changes.
|
||||
- Make sure to update the documentation for any changes Go to the [docs.dokploy.com](https://docs.dokploy.com) website to see the changes.
|
||||
- When creating a pull request, please provide a clear and concise description of the changes made.
|
||||
- If you include a video or screenshot, would be awesome so we can see the changes in action.
|
||||
- If your pull request fixes an open issue, please reference the issue in the pull request description.
|
||||
- Once your pull request is merged, you will be automatically added as a contributor to the project.
|
||||
|
||||
Thank you for your contribution!
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Templates
|
||||
|
||||
To add a new template, go to `templates` folder and create a new folder with the name of the template.
|
||||
|
||||
Let's take the example of `plausible` template.
|
||||
|
||||
1. create a folder in `templates/plausible`
|
||||
2. create a `docker-compose.yml` file inside the folder with the content of compose.
|
||||
3. create a `index.ts` file inside the folder with the following code as base:
|
||||
4. When creating a pull request, please provide a video of the template working in action.
|
||||
|
||||
```typescript
|
||||
// EXAMPLE
|
||||
import {
|
||||
generateHash,
|
||||
generateRandomDomain,
|
||||
type Template,
|
||||
type Schema,
|
||||
} from "../utils";
|
||||
|
||||
|
||||
export function generate(schema: Schema): Template {
|
||||
|
||||
// do your stuff here, like create a new domain, generate random passwords, mounts.
|
||||
const mainServiceHash = generateHash(schema.projectName);
|
||||
const randomDomain = generateRandomDomain(schema);
|
||||
const secretBase = generateBase64(64);
|
||||
const toptKeyBase = generateBase64(32);
|
||||
|
||||
const envs = [
|
||||
// If you want to show a domain in the UI, please add the prefix _HOST at the end of the variable name.
|
||||
`PLAUSIBLE_HOST=${randomDomain}`,
|
||||
"PLAUSIBLE_PORT=8000",
|
||||
`BASE_URL=http://${randomDomain}`,
|
||||
`SECRET_KEY_BASE=${secretBase}`,
|
||||
`TOTP_VAULT_KEY=${toptKeyBase}`,
|
||||
`HASH=${mainServiceHash}`,
|
||||
];
|
||||
|
||||
const mounts: Template["mounts"] = [
|
||||
{
|
||||
mountPath: "./clickhouse/clickhouse-config.xml",
|
||||
content: `some content......`,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
envs,
|
||||
mounts,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
4. Now you need to add the information about the template to the `templates/templates.ts` is a object with the following properties:
|
||||
|
||||
**Make sure the id of the template is the same as the folder name and don't have any spaces, only slugified names and lowercase.**
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "plausible",
|
||||
name: "Plausible",
|
||||
version: "v2.1.0",
|
||||
description:
|
||||
"Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.",
|
||||
logo: "plausible.svg", // we defined the name and the extension of the logo
|
||||
links: {
|
||||
github: "https://github.com/plausible/plausible",
|
||||
website: "https://plausible.io/",
|
||||
docs: "https://plausible.io/docs",
|
||||
},
|
||||
tags: ["analytics"],
|
||||
load: () => import("./plausible/index").then((m) => m.generate),
|
||||
},
|
||||
```
|
||||
|
||||
5. Add the logo or image of the template to `public/templates/plausible.svg`
|
||||
|
||||
|
||||
### Recomendations
|
||||
- Use the same name of the folder as the id of the template.
|
||||
- The logo should be in the public folder.
|
||||
- If you want to show a domain in the UI, please add the prefix _HOST at the end of the variable name.
|
||||
- Test first on a vps or a server to make sure the template works.
|
||||
|
||||
@@ -9,6 +9,7 @@ describe("createDomainLabels", () => {
|
||||
port: 8080,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
certificateType: "none",
|
||||
applicationId: "",
|
||||
composeId: "",
|
||||
|
||||
@@ -27,6 +27,7 @@ if (typeof window === "undefined") {
|
||||
const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
herokuVersion: "",
|
||||
watchPaths: [],
|
||||
applicationStatus: "done",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
@@ -37,6 +38,7 @@ const baseApp: ApplicationNested = {
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
previewCertificateType: "none",
|
||||
previewCustomCertResolver: null,
|
||||
previewEnv: null,
|
||||
previewHttps: false,
|
||||
previewPath: "/",
|
||||
|
||||
425
apps/dokploy/__test__/templates/config.template.test.ts
Normal file
425
apps/dokploy/__test__/templates/config.template.test.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CompleteTemplate } from "@dokploy/server/templates/processors";
|
||||
import { processTemplate } from "@dokploy/server/templates/processors";
|
||||
import type { Schema } from "@dokploy/server/templates";
|
||||
|
||||
describe("processTemplate", () => {
|
||||
// Mock schema for testing
|
||||
const mockSchema: Schema = {
|
||||
projectName: "test",
|
||||
serverIp: "127.0.0.1",
|
||||
};
|
||||
|
||||
describe("variables processing", () => {
|
||||
it("should process basic variables with utility functions", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {
|
||||
main_domain: "${domain}",
|
||||
secret_base: "${base64:64}",
|
||||
totp_key: "${base64:32}",
|
||||
password: "${password:32}",
|
||||
hash: "${hash:16}",
|
||||
},
|
||||
config: {
|
||||
domains: [],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.envs).toHaveLength(0);
|
||||
expect(result.domains).toHaveLength(0);
|
||||
expect(result.mounts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should allow referencing variables in other variables", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {
|
||||
main_domain: "${domain}",
|
||||
api_domain: "api.${main_domain}",
|
||||
},
|
||||
config: {
|
||||
domains: [],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.envs).toHaveLength(0);
|
||||
expect(result.domains).toHaveLength(0);
|
||||
expect(result.mounts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("domains processing", () => {
|
||||
it("should process domains with explicit host", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {
|
||||
main_domain: "${domain}",
|
||||
},
|
||||
config: {
|
||||
domains: [
|
||||
{
|
||||
serviceName: "plausible",
|
||||
port: 8000,
|
||||
host: "${main_domain}",
|
||||
},
|
||||
],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.domains).toHaveLength(1);
|
||||
const domain = result.domains[0];
|
||||
expect(domain).toBeDefined();
|
||||
if (!domain) return;
|
||||
expect(domain).toMatchObject({
|
||||
serviceName: "plausible",
|
||||
port: 8000,
|
||||
});
|
||||
expect(domain.host).toBeDefined();
|
||||
expect(domain.host).toContain(mockSchema.projectName);
|
||||
});
|
||||
|
||||
it("should generate random domain if host is not specified", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
domains: [
|
||||
{
|
||||
serviceName: "plausible",
|
||||
port: 8000,
|
||||
},
|
||||
],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.domains).toHaveLength(1);
|
||||
const domain = result.domains[0];
|
||||
expect(domain).toBeDefined();
|
||||
if (!domain || !domain.host) return;
|
||||
expect(domain.host).toBeDefined();
|
||||
expect(domain.host).toContain(mockSchema.projectName);
|
||||
});
|
||||
|
||||
it("should allow using ${domain} directly in host", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
domains: [
|
||||
{
|
||||
serviceName: "plausible",
|
||||
port: 8000,
|
||||
host: "${domain}",
|
||||
},
|
||||
],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.domains).toHaveLength(1);
|
||||
const domain = result.domains[0];
|
||||
expect(domain).toBeDefined();
|
||||
if (!domain || !domain.host) return;
|
||||
expect(domain.host).toBeDefined();
|
||||
expect(domain.host).toContain(mockSchema.projectName);
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment variables processing", () => {
|
||||
it("should process env vars with variable references", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {
|
||||
main_domain: "${domain}",
|
||||
secret_base: "${base64:64}",
|
||||
},
|
||||
config: {
|
||||
domains: [],
|
||||
env: {
|
||||
BASE_URL: "http://${main_domain}",
|
||||
SECRET_KEY_BASE: "${secret_base}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.envs).toHaveLength(2);
|
||||
const baseUrl = result.envs.find((env: string) =>
|
||||
env.startsWith("BASE_URL="),
|
||||
);
|
||||
const secretKey = result.envs.find((env: string) =>
|
||||
env.startsWith("SECRET_KEY_BASE="),
|
||||
);
|
||||
|
||||
expect(baseUrl).toBeDefined();
|
||||
expect(secretKey).toBeDefined();
|
||||
if (!baseUrl || !secretKey) return;
|
||||
|
||||
expect(baseUrl).toContain(mockSchema.projectName);
|
||||
const base64Value = secretKey.split("=")[1];
|
||||
expect(base64Value).toBeDefined();
|
||||
if (!base64Value) return;
|
||||
expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
|
||||
expect(base64Value.length).toBeGreaterThanOrEqual(86);
|
||||
expect(base64Value.length).toBeLessThanOrEqual(88);
|
||||
});
|
||||
|
||||
it("should process env vars when provided as an array", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
domains: [],
|
||||
env: [
|
||||
'CLOUDFLARE_TUNNEL_TOKEN="<INSERT TOKEN>"',
|
||||
'ANOTHER_VAR="some value"',
|
||||
"DOMAIN=${domain}",
|
||||
],
|
||||
mounts: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.envs).toHaveLength(3);
|
||||
|
||||
// Should preserve exact format for static values
|
||||
expect(result.envs[0]).toBe('CLOUDFLARE_TUNNEL_TOKEN="<INSERT TOKEN>"');
|
||||
expect(result.envs[1]).toBe('ANOTHER_VAR="some value"');
|
||||
|
||||
// Should process variables in array items
|
||||
expect(result.envs[2]).toContain(mockSchema.projectName);
|
||||
});
|
||||
|
||||
it("should allow using utility functions directly in env vars", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
domains: [],
|
||||
env: {
|
||||
RANDOM_DOMAIN: "${domain}",
|
||||
SECRET_KEY: "${base64:32}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.envs).toHaveLength(2);
|
||||
const randomDomainEnv = result.envs.find((env: string) =>
|
||||
env.startsWith("RANDOM_DOMAIN="),
|
||||
);
|
||||
const secretKeyEnv = result.envs.find((env: string) =>
|
||||
env.startsWith("SECRET_KEY="),
|
||||
);
|
||||
expect(randomDomainEnv).toBeDefined();
|
||||
expect(secretKeyEnv).toBeDefined();
|
||||
if (!randomDomainEnv || !secretKeyEnv) return;
|
||||
|
||||
expect(randomDomainEnv).toContain(mockSchema.projectName);
|
||||
const base64Value = secretKeyEnv.split("=")[1];
|
||||
expect(base64Value).toBeDefined();
|
||||
if (!base64Value) return;
|
||||
expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
|
||||
expect(base64Value.length).toBeGreaterThanOrEqual(42);
|
||||
expect(base64Value.length).toBeLessThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mounts processing", () => {
|
||||
it("should process mounts with variable references", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {
|
||||
config_path: "/etc/config",
|
||||
secret_key: "${base64:32}",
|
||||
},
|
||||
config: {
|
||||
domains: [],
|
||||
env: {},
|
||||
mounts: [
|
||||
{
|
||||
filePath: "${config_path}/config.xml",
|
||||
content: "secret_key=${secret_key}",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.mounts).toHaveLength(1);
|
||||
const mount = result.mounts[0];
|
||||
expect(mount).toBeDefined();
|
||||
if (!mount) return;
|
||||
expect(mount.filePath).toContain("/etc/config");
|
||||
expect(mount.content).toMatch(/secret_key=[A-Za-z0-9+/]{32}/);
|
||||
});
|
||||
|
||||
it("should allow using utility functions directly in mount content", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
domains: [],
|
||||
env: {},
|
||||
mounts: [
|
||||
{
|
||||
filePath: "/config/secrets.txt",
|
||||
content: "random_domain=${domain}\nsecret=${base64:32}",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.mounts).toHaveLength(1);
|
||||
const mount = result.mounts[0];
|
||||
expect(mount).toBeDefined();
|
||||
if (!mount) return;
|
||||
expect(mount.content).toContain(mockSchema.projectName);
|
||||
expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{32}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex template processing", () => {
|
||||
it("should process a complete template with all features", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {
|
||||
main_domain: "${domain}",
|
||||
secret_base: "${base64:64}",
|
||||
totp_key: "${base64:32}",
|
||||
},
|
||||
config: {
|
||||
domains: [
|
||||
{
|
||||
serviceName: "plausible",
|
||||
port: 8000,
|
||||
host: "${main_domain}",
|
||||
},
|
||||
{
|
||||
serviceName: "api",
|
||||
port: 3000,
|
||||
host: "api.${main_domain}",
|
||||
},
|
||||
],
|
||||
env: {
|
||||
BASE_URL: "http://${main_domain}",
|
||||
SECRET_KEY_BASE: "${secret_base}",
|
||||
TOTP_VAULT_KEY: "${totp_key}",
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
filePath: "/config/app.conf",
|
||||
content: `
|
||||
domain=\${main_domain}
|
||||
secret=\${secret_base}
|
||||
totp=\${totp_key}
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
|
||||
// Check domains
|
||||
expect(result.domains).toHaveLength(2);
|
||||
const [domain1, domain2] = result.domains;
|
||||
expect(domain1).toBeDefined();
|
||||
expect(domain2).toBeDefined();
|
||||
if (!domain1 || !domain2) return;
|
||||
expect(domain1.host).toBeDefined();
|
||||
expect(domain1.host).toContain(mockSchema.projectName);
|
||||
expect(domain2.host).toContain("api.");
|
||||
expect(domain2.host).toContain(mockSchema.projectName);
|
||||
|
||||
// Check env vars
|
||||
expect(result.envs).toHaveLength(3);
|
||||
const baseUrl = result.envs.find((env: string) =>
|
||||
env.startsWith("BASE_URL="),
|
||||
);
|
||||
const secretKey = result.envs.find((env: string) =>
|
||||
env.startsWith("SECRET_KEY_BASE="),
|
||||
);
|
||||
const totpKey = result.envs.find((env: string) =>
|
||||
env.startsWith("TOTP_VAULT_KEY="),
|
||||
);
|
||||
|
||||
expect(baseUrl).toBeDefined();
|
||||
expect(secretKey).toBeDefined();
|
||||
expect(totpKey).toBeDefined();
|
||||
if (!baseUrl || !secretKey || !totpKey) return;
|
||||
|
||||
expect(baseUrl).toContain(mockSchema.projectName);
|
||||
|
||||
// Check base64 lengths and format
|
||||
const secretKeyValue = secretKey.split("=")[1];
|
||||
const totpKeyValue = totpKey.split("=")[1];
|
||||
|
||||
expect(secretKeyValue).toBeDefined();
|
||||
expect(totpKeyValue).toBeDefined();
|
||||
if (!secretKeyValue || !totpKeyValue) return;
|
||||
|
||||
expect(secretKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
|
||||
expect(secretKeyValue.length).toBeGreaterThanOrEqual(86);
|
||||
expect(secretKeyValue.length).toBeLessThanOrEqual(88);
|
||||
|
||||
expect(totpKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
|
||||
expect(totpKeyValue.length).toBeGreaterThanOrEqual(42);
|
||||
expect(totpKeyValue.length).toBeLessThanOrEqual(44);
|
||||
|
||||
// Check mounts
|
||||
expect(result.mounts).toHaveLength(1);
|
||||
const mount = result.mounts[0];
|
||||
expect(mount).toBeDefined();
|
||||
if (!mount) return;
|
||||
expect(mount.content).toContain(mockSchema.projectName);
|
||||
expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{86,88}/);
|
||||
expect(mount.content).toMatch(/totp=[A-Za-z0-9+/]{42,44}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Should populate envs, domains and mounts in the case we didn't used any variable", () => {
|
||||
it("should populate envs, domains and mounts in the case we didn't used any variable", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
domains: [
|
||||
{
|
||||
serviceName: "plausible",
|
||||
port: 8000,
|
||||
host: "${hash}",
|
||||
},
|
||||
],
|
||||
env: {
|
||||
BASE_URL: "http://${domain}",
|
||||
SECRET_KEY_BASE: "${password:32}",
|
||||
TOTP_VAULT_KEY: "${base64:128}",
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
filePath: "/config/secrets.txt",
|
||||
content: "random_domain=${domain}\nsecret=${password:32}",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.envs).toHaveLength(3);
|
||||
expect(result.domains).toHaveLength(1);
|
||||
expect(result.mounts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ const baseAdmin: User = {
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
enableLogRotation: false,
|
||||
logCleanupCron: null,
|
||||
serversQuantity: 0,
|
||||
stripeCustomerId: "",
|
||||
stripeSubscriptionId: "",
|
||||
|
||||
@@ -14,6 +14,7 @@ const baseApp: ApplicationNested = {
|
||||
branch: null,
|
||||
dockerBuildStage: "",
|
||||
registryUrl: "",
|
||||
watchPaths: [],
|
||||
buildArgs: null,
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
@@ -23,6 +24,7 @@ const baseApp: ApplicationNested = {
|
||||
previewPath: "/",
|
||||
previewPort: 3000,
|
||||
previewLimit: 0,
|
||||
previewCustomCertResolver: null,
|
||||
previewWildcard: "",
|
||||
project: {
|
||||
env: "",
|
||||
@@ -103,6 +105,7 @@ const baseDomain: Domain = {
|
||||
port: null,
|
||||
serviceName: "",
|
||||
composeId: "",
|
||||
customCertResolver: null,
|
||||
domainType: "application",
|
||||
uniqueConfigKey: 1,
|
||||
previewDeploymentId: "",
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Code2, Globe2, HardDrive } from "lucide-react";
|
||||
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";
|
||||
|
||||
const ImportSchema = z.object({
|
||||
base64: z.string(),
|
||||
});
|
||||
|
||||
type ImportType = z.infer<typeof ImportSchema>;
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const ShowImport = ({ composeId }: Props) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showMountContent, setShowMountContent] = useState(false);
|
||||
const [selectedMount, setSelectedMount] = useState<{
|
||||
filePath: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
const [templateInfo, setTemplateInfo] = useState<{
|
||||
compose: string;
|
||||
template: {
|
||||
domains: Array<{
|
||||
serviceName: string;
|
||||
port: number;
|
||||
path?: string;
|
||||
host?: string;
|
||||
}>;
|
||||
envs: string[];
|
||||
mounts: Array<{
|
||||
filePath: string;
|
||||
content: string;
|
||||
}>;
|
||||
};
|
||||
} | null>(null);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } =
|
||||
api.compose.processTemplate.useMutation();
|
||||
const {
|
||||
mutateAsync: importTemplate,
|
||||
isLoading: isImporting,
|
||||
isSuccess: isImportSuccess,
|
||||
} = api.compose.import.useMutation();
|
||||
|
||||
const form = useForm<ImportType>({
|
||||
defaultValues: {
|
||||
base64: "",
|
||||
},
|
||||
resolver: zodResolver(ImportSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
base64: "",
|
||||
});
|
||||
}, [isImportSuccess]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const base64 = form.getValues("base64");
|
||||
if (!base64) {
|
||||
toast.error("Please enter a base64 template");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await importTemplate({
|
||||
composeId,
|
||||
base64,
|
||||
});
|
||||
toast.success("Template imported successfully");
|
||||
await utils.compose.one.invalidate({
|
||||
composeId,
|
||||
});
|
||||
setShowModal(false);
|
||||
} catch (_error) {
|
||||
toast.error("Error importing template");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadTemplate = async () => {
|
||||
const base64 = form.getValues("base64");
|
||||
if (!base64) {
|
||||
toast.error("Please enter a base64 template");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processTemplate({
|
||||
composeId,
|
||||
base64,
|
||||
});
|
||||
setTemplateInfo(result);
|
||||
setShowModal(true);
|
||||
} catch (_error) {
|
||||
toast.error("Error processing template");
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowMountContent = (mount: {
|
||||
filePath: string;
|
||||
content: string;
|
||||
}) => {
|
||||
setSelectedMount(mount);
|
||||
setShowMountContent(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Import</CardTitle>
|
||||
<CardDescription>Import your Template configuration</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<AlertBlock type="warning">
|
||||
Warning: Importing a template will remove all existing environment
|
||||
variables, mounts, and domains from this service.
|
||||
</AlertBlock>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base64"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Configuration (Base64)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter your Base64 configuration here..."
|
||||
className="font-mono min-h-[200px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-fit"
|
||||
variant="outline"
|
||||
isLoading={isLoadingTemplate}
|
||||
onClick={handleLoadTemplate}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className="max-h-[80vh] max-w-[50vw] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
Template Information
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-2">
|
||||
<p>Review the template information before importing</p>
|
||||
<AlertBlock type="warning">
|
||||
Warning: This will remove all existing environment
|
||||
variables, mounts, and domains from this service.
|
||||
</AlertBlock>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code2 className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
Docker Compose
|
||||
</h3>
|
||||
</div>
|
||||
<CodeEditor
|
||||
language="yaml"
|
||||
value={templateInfo?.compose || ""}
|
||||
className="font-mono"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{templateInfo?.template.domains &&
|
||||
templateInfo.template.domains.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe2 className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Domains</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{templateInfo.template.domains.map(
|
||||
(domain, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
|
||||
>
|
||||
<div className="font-medium">
|
||||
{domain.serviceName}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div>Port: {domain.port}</div>
|
||||
{domain.host && (
|
||||
<div>Host: {domain.host}</div>
|
||||
)}
|
||||
{domain.path && (
|
||||
<div>Path: {domain.path}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{templateInfo?.template.envs &&
|
||||
templateInfo.template.envs.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code2 className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
Environment Variables
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{templateInfo.template.envs.map((env, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-card p-2 font-mono text-sm"
|
||||
>
|
||||
{env}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{templateInfo?.template.mounts &&
|
||||
templateInfo.template.mounts.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Mounts</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{templateInfo.template.mounts.map(
|
||||
(mount, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => handleShowMountContent(mount)}
|
||||
>
|
||||
{mount.filePath}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isImporting}
|
||||
type="submit"
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
className="w-fit"
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showMountContent} onOpenChange={setShowMountContent}>
|
||||
<DialogContent className="max-w-[50vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{selectedMount?.filePath}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Mount File Content</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[25vh] pr-4">
|
||||
<CodeEditor
|
||||
language="yaml"
|
||||
value={selectedMount?.content || ""}
|
||||
className="font-mono"
|
||||
readOnly
|
||||
/>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button onClick={() => setShowMountContent(false)}>Close</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -85,8 +85,20 @@ export const AddDomain = ({
|
||||
|
||||
const form = useForm<Domain>({
|
||||
resolver: zodResolver(domain),
|
||||
defaultValues: {
|
||||
host: "",
|
||||
path: undefined,
|
||||
port: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const certificateType = form.watch("certificateType");
|
||||
const https = form.watch("https");
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
@@ -94,13 +106,29 @@ export const AddDomain = ({
|
||||
/* Convert null to undefined */
|
||||
path: data?.path || undefined,
|
||||
port: data?.port || undefined,
|
||||
certificateType: data?.certificateType || undefined,
|
||||
customCertResolver: data?.customCertResolver || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!domainId) {
|
||||
form.reset({});
|
||||
form.reset({
|
||||
host: "",
|
||||
path: undefined,
|
||||
port: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data, isLoading]);
|
||||
}, [form, data, isLoading, domainId]);
|
||||
|
||||
// Separate effect for handling custom cert resolver validation
|
||||
useEffect(() => {
|
||||
if (certificateType === "custom") {
|
||||
form.trigger("customCertResolver");
|
||||
}
|
||||
}, [certificateType, form]);
|
||||
|
||||
const dictionary = {
|
||||
success: domainId ? "Domain Updated" : "Domain Created",
|
||||
@@ -256,34 +284,73 @@ export const AddDomain = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.getValues().https && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Certificate Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
{https && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Certificate Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (value !== "custom") {
|
||||
form.setValue(
|
||||
"customCertResolver",
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={"none"}>None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Let's Encrypt
|
||||
</SelectItem>
|
||||
<SelectItem value={"custom"}>Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Let's Encrypt
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
{certificateType === "custom" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customCertResolver"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Custom Certificate Resolver</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
placeholder="Enter your custom certificate resolver"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
form.trigger("customCertResolver");
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,15 +71,19 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
resolver: zodResolver(addEnvironmentSchema),
|
||||
});
|
||||
|
||||
// Watch form value
|
||||
const currentEnvironment = form.watch("environment");
|
||||
const hasChanges = currentEnvironment !== (data?.env || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
environment: data.env || "",
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (data: EnvironmentSchema) => {
|
||||
const onSubmit = async (formData: EnvironmentSchema) => {
|
||||
mutateAsync({
|
||||
mongoId: id || "",
|
||||
postgresId: id || "",
|
||||
@@ -87,7 +91,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
composeId: id || "",
|
||||
env: data.environment,
|
||||
env: formData.environment,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Environments Added");
|
||||
@@ -98,6 +102,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.reset({
|
||||
environment: data?.env || "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
@@ -106,6 +116,11 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your resource.
|
||||
{hasChanges && (
|
||||
<span className="text-yellow-500 ml-2">
|
||||
(You have unsaved changes)
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
@@ -132,8 +147,8 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
control={form.control}
|
||||
name="environment"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<FormItem>
|
||||
<FormControl className="">
|
||||
<CodeEditor
|
||||
style={
|
||||
{
|
||||
@@ -142,21 +157,35 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
}
|
||||
language="properties"
|
||||
disabled={isEnvVisible}
|
||||
className="font-mono"
|
||||
wrapperClassName="compose-file-editor"
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
className="h-96 font-mono"
|
||||
`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button isLoading={isLoading} className="w-fit" type="submit">
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const addEnvironmentSchema = z.object({
|
||||
env: z.string(),
|
||||
@@ -34,16 +35,32 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
|
||||
const form = useForm<EnvironmentSchema>({
|
||||
defaultValues: {
|
||||
env: data?.env || "",
|
||||
buildArgs: data?.buildArgs || "",
|
||||
env: "",
|
||||
buildArgs: "",
|
||||
},
|
||||
resolver: zodResolver(addEnvironmentSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: EnvironmentSchema) => {
|
||||
// Watch form values
|
||||
const currentEnv = form.watch("env");
|
||||
const currentBuildArgs = form.watch("buildArgs");
|
||||
const hasChanges =
|
||||
currentEnv !== (data?.env || "") ||
|
||||
currentBuildArgs !== (data?.buildArgs || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
env: data.env || "",
|
||||
buildArgs: data.buildArgs || "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: EnvironmentSchema) => {
|
||||
mutateAsync({
|
||||
env: data.env,
|
||||
buildArgs: data.buildArgs,
|
||||
env: formData.env,
|
||||
buildArgs: formData.buildArgs,
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -55,6 +72,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.reset({
|
||||
env: data?.env || "",
|
||||
buildArgs: data?.buildArgs || "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background px-6 pb-6">
|
||||
<Form {...form}>
|
||||
@@ -65,7 +89,16 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
<Secrets
|
||||
name="env"
|
||||
title="Environment Settings"
|
||||
description="You can add environment variables to your resource."
|
||||
description={
|
||||
<span>
|
||||
You can add environment variables to your resource.
|
||||
{hasChanges && (
|
||||
<span className="text-yellow-500 ml-2">
|
||||
(You have unsaved changes)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
|
||||
/>
|
||||
{data?.buildType === "dockerfile" && (
|
||||
@@ -89,8 +122,18 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
placeholder="NPM_TOKEN=xyz"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button isLoading={isLoading} className="w-fit" type="submit">
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -29,14 +29,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const BitbucketProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
@@ -48,6 +57,7 @@ const BitbucketProviderSchema = z.object({
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
||||
@@ -73,6 +83,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
},
|
||||
bitbucketId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
},
|
||||
resolver: zodResolver(BitbucketProviderSchema),
|
||||
});
|
||||
@@ -118,6 +129,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
},
|
||||
buildPath: data.bitbucketBuildPath || "/",
|
||||
bitbucketId: data.bitbucketId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -130,6 +142,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
bitbucketBuildPath: data.buildPath,
|
||||
bitbucketId: data.bitbucketId,
|
||||
applicationId,
|
||||
watchPaths: data.watchPaths || [],
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -195,7 +208,20 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
name="repository"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<BitbucketIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
@@ -363,6 +389,84 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -115,7 +115,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="username" {...field} />
|
||||
<Input placeholder="Username" autoComplete="username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -130,7 +130,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Password" {...field} type="password" />
|
||||
<Input placeholder="Password" autoComplete="one-time-code" {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -17,23 +17,33 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRoundIcon, LockIcon } from "lucide-react";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||
|
||||
const GitProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
repositoryURL: z.string().min(1, {
|
||||
message: "Repository URL is required",
|
||||
}),
|
||||
branch: z.string().min(1, "Branch required"),
|
||||
buildPath: z.string().min(1, "Build Path required"),
|
||||
sshKey: z.string().optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||
@@ -56,6 +66,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
buildPath: "/",
|
||||
repositoryURL: "",
|
||||
sshKey: undefined,
|
||||
watchPaths: [],
|
||||
},
|
||||
resolver: zodResolver(GitProviderSchema),
|
||||
});
|
||||
@@ -67,6 +78,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
branch: data.customGitBranch || "",
|
||||
buildPath: data.customGitBuildPath || "/",
|
||||
repositoryURL: data.customGitUrl || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -78,6 +90,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
customGitUrl: values.repositoryURL,
|
||||
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
|
||||
applicationId,
|
||||
watchPaths: values.watchPaths || [],
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Git Provider Saved");
|
||||
@@ -102,9 +115,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
name="repositoryURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Repository URL</FormLabel>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository URL</FormLabel>
|
||||
{field.value?.startsWith("https://") && (
|
||||
<Link
|
||||
href={field.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<GitIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input placeholder="git@bitbucket.org" {...field} />
|
||||
<Input placeholder="Repository URL" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -160,19 +186,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="branch"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Branch</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Branch" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="branch"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Branch</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Branch" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildPath"
|
||||
@@ -186,6 +215,85 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered. This
|
||||
will work only when manual webhook is setup.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
|
||||
@@ -28,14 +28,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
|
||||
const GithubProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
@@ -47,6 +56,7 @@ const GithubProviderSchema = z.object({
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
githubId: z.string().min(1, "Github Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
||||
@@ -113,6 +123,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
},
|
||||
buildPath: data.buildPath || "/",
|
||||
githubId: data.githubId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -125,6 +136,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
owner: data.repository.owner,
|
||||
buildPath: data.buildPath,
|
||||
githubId: data.githubId,
|
||||
watchPaths: data.watchPaths || [],
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -187,7 +199,20 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
name="repository"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<GithubIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
@@ -350,7 +375,85 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder*="Enter a path"]',
|
||||
) as HTMLInputElement;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -29,14 +29,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
|
||||
const GitlabProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
@@ -50,6 +59,7 @@ const GitlabProviderSchema = z.object({
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
||||
@@ -124,6 +134,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
},
|
||||
buildPath: data.gitlabBuildPath || "/",
|
||||
gitlabId: data.gitlabId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -138,6 +149,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
applicationId,
|
||||
gitlabProjectId: data.repository.id,
|
||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||
watchPaths: data.watchPaths || [],
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -203,7 +215,20 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
name="repository"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<GitlabIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
@@ -375,7 +400,85 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder*="Enter a path"]',
|
||||
) as HTMLInputElement;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -4,8 +4,22 @@ import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { Ban, CheckCircle2, Hammer, RefreshCcw, Terminal } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Hammer,
|
||||
HelpCircle,
|
||||
RefreshCcw,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { toast } from "sonner";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
@@ -41,128 +55,188 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<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?.projectId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
>
|
||||
Deploy
|
||||
</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();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" isLoading={isReloading}>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
</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();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error rebuilding application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
>
|
||||
Rebuild
|
||||
<Hammer className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Start Application"
|
||||
description="Are you sure you want to start this application?"
|
||||
title="Deploy Application"
|
||||
description="Are you sure you want to deploy this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
await deploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application started successfully");
|
||||
toast.success("Application deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting application");
|
||||
toast.error("Error deploying application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" isLoading={isStarting}>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Deploy
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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="Stop Application"
|
||||
description="Are you sure you want to stop this application?"
|
||||
title="Reload Application"
|
||||
description="Are you sure you want to reload this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
await reload({
|
||||
applicationId: applicationId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application stopped successfully");
|
||||
toast.success("Application reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping application");
|
||||
toast.error("Error reloading application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="destructive" isLoading={isStopping}>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Button variant="secondary" isLoading={isReloading}>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
</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();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error rebuilding application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Rebuild
|
||||
<Hammer className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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" ? (
|
||||
<DialogAction
|
||||
title="Start Application"
|
||||
description="Are you sure you want to start this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the application (requires a previous successful
|
||||
build)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Application"
|
||||
description="Are you sure you want to stop this application?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running application</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
|
||||
@@ -94,6 +94,7 @@ export const AddPreviewDomain = ({
|
||||
/* Convert null to undefined */
|
||||
path: data?.path || undefined,
|
||||
port: data?.port || undefined,
|
||||
customCertResolver: data?.customCertResolver || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,16 +35,30 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
env: z.string(),
|
||||
buildArgs: z.string(),
|
||||
wildcardDomain: z.string(),
|
||||
port: z.number(),
|
||||
previewLimit: z.number(),
|
||||
previewHttps: z.boolean(),
|
||||
previewPath: z.string(),
|
||||
previewCertificateType: z.enum(["letsencrypt", "none"]),
|
||||
});
|
||||
const schema = z
|
||||
.object({
|
||||
env: z.string(),
|
||||
buildArgs: z.string(),
|
||||
wildcardDomain: z.string(),
|
||||
port: z.number(),
|
||||
previewLimit: z.number(),
|
||||
previewHttps: z.boolean(),
|
||||
previewPath: z.string(),
|
||||
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||
previewCustomCertResolver: z.string().optional(),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (
|
||||
input.previewCertificateType === "custom" &&
|
||||
!input.previewCustomCertResolver
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["previewCustomCertResolver"],
|
||||
message: "Required",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
@@ -90,6 +104,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
previewHttps: data.previewHttps || false,
|
||||
previewPath: data.previewPath || "/",
|
||||
previewCertificateType: data.previewCertificateType || "none",
|
||||
previewCustomCertResolver: data.previewCustomCertResolver || "",
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
@@ -105,6 +120,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
previewHttps: formData.previewHttps,
|
||||
previewPath: formData.previewPath,
|
||||
previewCertificateType: formData.previewCertificateType,
|
||||
previewCustomCertResolver: formData.previewCustomCertResolver,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Preview Deployments settings updated");
|
||||
@@ -184,10 +200,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Preview Limit</FormLabel>
|
||||
{/* <FormDescription>
|
||||
Set the limit of preview deployments that can be
|
||||
created for this app.
|
||||
</FormDescription> */}
|
||||
<FormControl>
|
||||
<NumberInput placeholder="3000" {...field} />
|
||||
</FormControl>
|
||||
@@ -238,6 +250,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Let's Encrypt
|
||||
</SelectItem>
|
||||
<SelectItem value={"custom"}>Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -245,6 +258,25 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.watch("previewCertificateType") === "custom" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="previewCustomCertResolver"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Certificate Provider</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="my-custom-resolver"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-row items-center justify-between rounded-lg border p-4 col-span-2">
|
||||
|
||||
@@ -121,7 +121,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tesla" {...field} />
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -104,6 +104,15 @@ export const AddDomainCompose = ({
|
||||
|
||||
const form = useForm<Domain>({
|
||||
resolver: zodResolver(domainCompose),
|
||||
defaultValues: {
|
||||
host: "",
|
||||
path: undefined,
|
||||
port: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
serviceName: "",
|
||||
},
|
||||
});
|
||||
|
||||
const https = form.watch("https");
|
||||
@@ -116,11 +125,21 @@ export const AddDomainCompose = ({
|
||||
path: data?.path || undefined,
|
||||
port: data?.port || undefined,
|
||||
serviceName: data?.serviceName || undefined,
|
||||
certificateType: data?.certificateType || undefined,
|
||||
customCertResolver: data?.customCertResolver || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!domainId) {
|
||||
form.reset({});
|
||||
form.reset({
|
||||
host: "",
|
||||
path: undefined,
|
||||
port: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
serviceName: "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data, isLoading]);
|
||||
|
||||
@@ -393,33 +412,55 @@ export const AddDomainCompose = ({
|
||||
/>
|
||||
|
||||
{https && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Certificate Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Certificate Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Let's Encrypt
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Let's Encrypt
|
||||
</SelectItem>
|
||||
<SelectItem value={"custom"}>Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.getValues().certificateType === "custom" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customCertResolver"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Custom Certificate Resolver</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter your custom certificate resolver"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { Ban, CheckCircle2, Hammer, Terminal } from "lucide-react";
|
||||
import { Ban, CheckCircle2, Hammer, HelpCircle, Terminal } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { toast } from "sonner";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
@@ -27,103 +34,159 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
api.compose.stop.useMutation();
|
||||
return (
|
||||
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
||||
<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?.project.projectId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="default" isLoading={data?.composeStatus === "running"}>
|
||||
Deploy
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Rebuild Compose"
|
||||
description="Are you sure you want to rebuild this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose rebuilt successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error rebuilding compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
>
|
||||
Rebuild
|
||||
<Hammer className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.composeType === "docker-compose" &&
|
||||
data?.composeStatus === "idle" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<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?.project.projectId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting compose");
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" isLoading={isStarting}>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Deploy
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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="Stop Compose"
|
||||
description="Are you sure you want to stop this compose?"
|
||||
title="Rebuild Compose"
|
||||
description="Are you sure you want to rebuild this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
await redeploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose stopped successfully");
|
||||
toast.success("Compose rebuilt successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping compose");
|
||||
toast.error("Error rebuilding compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="destructive" isLoading={isStopping}>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Rebuild
|
||||
<Hammer className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Only rebuilds the compose without downloading new code</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
{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"
|
||||
>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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"
|
||||
>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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 || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
|
||||
@@ -97,6 +97,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
<div className="flex flex-col gap-4 w-full outline-none focus:outline-none overflow-auto">
|
||||
<CodeEditor
|
||||
// disabled
|
||||
language="yaml"
|
||||
value={field.value}
|
||||
className="font-mono"
|
||||
wrapperClassName="compose-file-editor"
|
||||
|
||||
@@ -29,14 +29,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const BitbucketProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
@@ -48,6 +57,7 @@ const BitbucketProviderSchema = z.object({
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
||||
@@ -73,6 +83,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
},
|
||||
bitbucketId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
},
|
||||
resolver: zodResolver(BitbucketProviderSchema),
|
||||
});
|
||||
@@ -118,6 +129,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
},
|
||||
composePath: data.composePath,
|
||||
bitbucketId: data.bitbucketId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -132,6 +144,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
composeId,
|
||||
sourceType: "bitbucket",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -197,7 +210,20 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
name="repository"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<BitbucketIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
@@ -365,6 +391,84 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
@@ -17,14 +18,22 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRoundIcon, LockIcon } from "lucide-react";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const GitProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
@@ -33,6 +42,7 @@ const GitProviderSchema = z.object({
|
||||
}),
|
||||
branch: z.string().min(1, "Branch required"),
|
||||
sshKey: z.string().optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||
@@ -54,6 +64,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
repositoryURL: "",
|
||||
composePath: "./docker-compose.yml",
|
||||
sshKey: undefined,
|
||||
watchPaths: [],
|
||||
},
|
||||
resolver: zodResolver(GitProviderSchema),
|
||||
});
|
||||
@@ -65,6 +76,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
branch: data.customGitBranch || "",
|
||||
repositoryURL: data.customGitUrl || "",
|
||||
composePath: data.composePath,
|
||||
watchPaths: data.watchPaths || [],
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -77,6 +89,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
composeId,
|
||||
sourceType: "git",
|
||||
composePath: values.composePath,
|
||||
composeStatus: "idle",
|
||||
watchPaths: values.watchPaths || [],
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Git Provider Saved");
|
||||
@@ -101,11 +115,22 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
name="repositoryURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row justify-between">
|
||||
Repository URL
|
||||
</FormLabel>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository URL</FormLabel>
|
||||
{field.value?.startsWith("https://") && (
|
||||
<Link
|
||||
href={field.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<GitIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input placeholder="git@bitbucket.org" {...field} />
|
||||
<Input placeholder="Repository URL" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -191,6 +216,85 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered. This
|
||||
will work only when manual webhook is setup.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -28,14 +29,22 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const GithubProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
@@ -47,6 +56,7 @@ const GithubProviderSchema = z.object({
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
githubId: z.string().min(1, "Github Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
||||
@@ -71,6 +81,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
},
|
||||
githubId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
},
|
||||
resolver: zodResolver(GithubProviderSchema),
|
||||
});
|
||||
@@ -113,6 +124,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
},
|
||||
composePath: data.composePath,
|
||||
githubId: data.githubId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -127,6 +139,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
githubId: data.githubId,
|
||||
sourceType: "github",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -183,13 +196,25 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repository"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<GithubIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
@@ -357,6 +382,84 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -29,14 +29,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const GitlabProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
@@ -50,6 +59,7 @@ const GitlabProviderSchema = z.object({
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
||||
@@ -76,6 +86,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
},
|
||||
gitlabId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
},
|
||||
resolver: zodResolver(GitlabProviderSchema),
|
||||
});
|
||||
@@ -124,6 +135,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
},
|
||||
composePath: data.composePath,
|
||||
gitlabId: data.gitlabId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -140,6 +152,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||
sourceType: "gitlab",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -199,13 +212,25 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repository"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<GitlabIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
@@ -382,6 +407,84 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -147,9 +147,9 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Enable Randomize ({data?.appName})</FormLabel>
|
||||
<FormLabel>Enable Isolated Deployment ({data?.appName})</FormLabel>
|
||||
<FormDescription>
|
||||
Enable randomize to the compose file.
|
||||
Enable isolated deployment to the compose file.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
|
||||
@@ -121,7 +121,7 @@ export const UpdateCompose = ({ composeId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tesla" {...field} />
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -54,6 +54,7 @@ const AddPostgresBackup1Schema = z.object({
|
||||
prefix: z.string().min(1, "Prefix required"),
|
||||
enabled: z.boolean(),
|
||||
database: z.string().min(1, "Database required"),
|
||||
keepLatestCount: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
|
||||
@@ -77,6 +78,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
||||
enabled: true,
|
||||
prefix: "/",
|
||||
schedule: "",
|
||||
keepLatestCount: undefined,
|
||||
},
|
||||
resolver: zodResolver(AddPostgresBackup1Schema),
|
||||
});
|
||||
@@ -88,6 +90,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
||||
enabled: true,
|
||||
prefix: "/",
|
||||
schedule: "",
|
||||
keepLatestCount: undefined,
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
|
||||
@@ -117,6 +120,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
||||
schedule: data.schedule,
|
||||
enabled: data.enabled,
|
||||
database: data.database,
|
||||
keepLatestCount: data.keepLatestCount,
|
||||
databaseType,
|
||||
...getDatabaseId,
|
||||
})
|
||||
@@ -265,7 +269,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
||||
<Input placeholder={"dokploy/"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use if you want to storage in a specific path of your
|
||||
Use if you want to back up in a specific path of your
|
||||
destination/bucket
|
||||
</FormDescription>
|
||||
|
||||
@@ -274,6 +278,24 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepLatestCount"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Keep the latest</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. If provided, only keeps the latest N backups in the cloud.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
|
||||
@@ -20,12 +20,14 @@ import { toast } from "sonner";
|
||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||
import { AddBackup } from "./add-backup";
|
||||
import { UpdateBackup } from "./update-backup";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: Exclude<ServiceType, "application" | "redis">;
|
||||
}
|
||||
export const ShowBackups = ({ id, type }: Props) => {
|
||||
const [activeManualBackup, setActiveManualBackup] = useState<string | undefined>();
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
@@ -106,7 +108,7 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
{postgres?.backups.map((backup) => (
|
||||
<div key={backup.backupId}>
|
||||
<div className="flex w-full flex-col md:flex-row md:items-center justify-between gap-4 md:gap-10 border rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-5 flex-col gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 flex-col gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Destination</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -137,6 +139,12 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
{backup.enabled ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Keep Latest</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{backup.keepLatestCount || 'All'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-4">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
@@ -145,8 +153,9 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
isLoading={isManualBackup}
|
||||
isLoading={isManualBackup && activeManualBackup === backup.backupId}
|
||||
onClick={async () => {
|
||||
setActiveManualBackup(backup.backupId);
|
||||
await manualBackup({
|
||||
backupId: backup.backupId as string,
|
||||
})
|
||||
@@ -160,6 +169,7 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
"Error creating the manual backup",
|
||||
);
|
||||
});
|
||||
setActiveManualBackup(undefined);
|
||||
}}
|
||||
>
|
||||
<Play className="size-5 text-muted-foreground" />
|
||||
|
||||
@@ -47,6 +47,7 @@ const UpdateBackupSchema = z.object({
|
||||
prefix: z.string().min(1, "Prefix required"),
|
||||
enabled: z.boolean(),
|
||||
database: z.string().min(1, "Database required"),
|
||||
keepLatestCount: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
type UpdateBackup = z.infer<typeof UpdateBackupSchema>;
|
||||
@@ -78,6 +79,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
||||
enabled: true,
|
||||
prefix: "/",
|
||||
schedule: "",
|
||||
keepLatestCount: undefined,
|
||||
},
|
||||
resolver: zodResolver(UpdateBackupSchema),
|
||||
});
|
||||
@@ -90,6 +92,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
||||
enabled: backup.enabled || false,
|
||||
prefix: backup.prefix,
|
||||
schedule: backup.schedule,
|
||||
keepLatestCount: backup.keepLatestCount ? Number(backup.keepLatestCount) : undefined,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, backup]);
|
||||
@@ -102,6 +105,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
||||
schedule: data.schedule,
|
||||
enabled: data.enabled,
|
||||
database: data.database,
|
||||
keepLatestCount: data.keepLatestCount as number | null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Backup Updated");
|
||||
@@ -253,7 +257,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
||||
<Input placeholder={"dokploy/"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use if you want to storage in a specific path of your
|
||||
Use if you want to back up in a specific path of your
|
||||
destination/bucket
|
||||
</FormDescription>
|
||||
|
||||
@@ -262,6 +266,24 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepLatestCount"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Keep the latest</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. If provided, only keeps the latest N backups in the cloud.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -23,6 +24,7 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
@@ -106,6 +108,17 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
</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" 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)}
|
||||
|
||||
@@ -2,8 +2,21 @@ 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 * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
RefreshCcw,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
@@ -65,92 +78,150 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<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"}
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<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}>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<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}>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Deploy
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the MariaDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Mariadb"
|
||||
description="Are you sure you want to stop this mariadb?"
|
||||
title="Reload Mariadb"
|
||||
description="Are you sure you want to reload this mariadb?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
await reload({
|
||||
mariadbId: mariadbId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb stopped successfully");
|
||||
toast.success("Mariadb reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mariadb");
|
||||
toast.error("Error reloading Mariadb");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="destructive" isLoading={isStopping}>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MariaDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<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"
|
||||
>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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>
|
||||
) : (
|
||||
<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"
|
||||
>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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 || ""}
|
||||
|
||||
@@ -119,7 +119,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tesla" {...field} />
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -23,6 +24,7 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
@@ -106,6 +108,17 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
</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" 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)}
|
||||
|
||||
@@ -2,8 +2,21 @@ 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 * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
RefreshCcw,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
@@ -64,93 +77,150 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<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"}
|
||||
>
|
||||
Deploy
|
||||
</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}>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<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({
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Deploy
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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 started successfully");
|
||||
toast.success("Mongo reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Mongo");
|
||||
toast.error("Error reloading Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" isLoading={isStarting}>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MongoDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Mongo"
|
||||
description="Are you sure you want to stop this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo stopped successfully");
|
||||
refetch();
|
||||
{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,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="destructive" isLoading={isStopping}>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
.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"
|
||||
>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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"
|
||||
>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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 || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
|
||||
@@ -121,7 +121,7 @@ export const UpdateMongo = ({ mongoId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tesla" {...field} />
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -200,7 +200,7 @@ export const ContainerFreeMonitoring = ({
|
||||
}, [appName]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-background shadow-md flex flex-col gap-4">
|
||||
<div className="rounded-xl bg-background flex flex-col gap-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Monitoring</h1>
|
||||
@@ -218,7 +218,7 @@ 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: {currentData.cpu.value}
|
||||
</span>
|
||||
<Progress value={currentData.cpu.value} className="w-[100%]" />
|
||||
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -23,6 +24,7 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
@@ -106,6 +108,17 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
</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" 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)}
|
||||
|
||||
@@ -2,8 +2,21 @@ 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 * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
RefreshCcw,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
@@ -62,93 +75,150 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<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"}
|
||||
>
|
||||
Deploy
|
||||
</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}>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<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}>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Deploy
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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="Stop Mysql"
|
||||
description="Are you sure you want to stop this mysql?"
|
||||
title="Reload Mysql"
|
||||
description="Are you sure you want to reload this mysql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
await reload({
|
||||
mysqlId: mysqlId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mysql stopped successfully");
|
||||
toast.success("Mysql reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mysql");
|
||||
toast.error("Error reloading Mysql");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="destructive" isLoading={isStopping}>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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" ? (
|
||||
<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"
|
||||
>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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"
|
||||
>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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 || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
|
||||
@@ -119,7 +119,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tesla" {...field} />
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -23,6 +24,7 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
@@ -108,6 +110,17 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
</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" 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)}
|
||||
|
||||
@@ -2,12 +2,26 @@ 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 * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
RefreshCcw,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
|
||||
interface Props {
|
||||
postgresId: string;
|
||||
}
|
||||
@@ -57,122 +71,179 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-xl">General</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-4">
|
||||
<DialogAction
|
||||
title="Deploy Postgres"
|
||||
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"}
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
</DialogAction>
|
||||
|
||||
<DialogAction
|
||||
title="Reload Postgres"
|
||||
description="Are you sure you want to reload this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
postgresId: postgresId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Postgres reloaded successfully");
|
||||
<>
|
||||
<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 Postgres"
|
||||
description="Are you sure you want to deploy this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Postgres");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" isLoading={isReloading}>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Postgres"
|
||||
description="Are you sure you want to start this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Postgres started successfully");
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Deploy
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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 Postgres"
|
||||
description="Are you sure you want to reload this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
postgresId: postgresId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Postgres");
|
||||
});
|
||||
}}
|
||||
.then(() => {
|
||||
toast.success("Postgres reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Postgres");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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" ? (
|
||||
<DialogAction
|
||||
title="Start Postgres"
|
||||
description="Are you sure you want to start this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Postgres started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Postgres");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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 Postgres"
|
||||
description="Are you sure you want to stop this postgres?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Postgres stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Postgres");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</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 || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button variant="secondary" isLoading={isStarting}>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Button variant="outline">
|
||||
<Terminal />
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Postgres"
|
||||
description="Are you sure you want to stop this postgres?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Postgres stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Postgres");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="destructive" isLoading={isStopping}>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Terminal />
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
setFilteredLogs([]);
|
||||
setIsDeploying(false);
|
||||
refetch();
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
</div>
|
||||
</DockerTerminalModal>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
setFilteredLogs([]);
|
||||
setIsDeploying(false);
|
||||
refetch();
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -121,7 +121,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tesla" {...field} />
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -494,7 +494,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="******************"
|
||||
autoComplete="off"
|
||||
autoComplete="one-time-code"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -57,32 +58,67 @@ import {
|
||||
BookText,
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
Github,
|
||||
Globe,
|
||||
HelpCircle,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Loader2,
|
||||
PuzzleIcon,
|
||||
SearchIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export const AddTemplate = ({ projectId }: Props) => {
|
||||
export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const { data } = api.compose.templates.useQuery();
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
|
||||
// Try to get from props first, then localStorage
|
||||
if (baseUrl) return baseUrl;
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem(TEMPLATE_BASE_URL_KEY) || undefined;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Save to localStorage when customBaseUrl changes
|
||||
useEffect(() => {
|
||||
if (customBaseUrl) {
|
||||
localStorage.setItem(TEMPLATE_BASE_URL_KEY, customBaseUrl);
|
||||
} else {
|
||||
localStorage.removeItem(TEMPLATE_BASE_URL_KEY);
|
||||
}
|
||||
}, [customBaseUrl]);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingTemplates,
|
||||
error: errorTemplates,
|
||||
isError: isErrorTemplates,
|
||||
} = api.compose.templates.useQuery(
|
||||
{ baseUrl: customBaseUrl },
|
||||
{
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const { data: tags, isLoading: isLoadingTags } =
|
||||
api.compose.getTags.useQuery();
|
||||
const { data: tags, isLoading: isLoadingTags } = api.compose.getTags.useQuery(
|
||||
{ baseUrl: customBaseUrl },
|
||||
{
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [serverId, setServerId] = useState<string | undefined>(undefined);
|
||||
@@ -129,6 +165,14 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
className="w-full sm:w-[200px]"
|
||||
value={query}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Base URL (optional)"
|
||||
onChange={(e) =>
|
||||
setCustomBaseUrl(e.target.value || undefined)
|
||||
}
|
||||
className="w-full sm:w-[300px]"
|
||||
value={customBaseUrl || ""}
|
||||
/>
|
||||
<Popover modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -232,7 +276,20 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
{templates.length === 0 ? (
|
||||
{isErrorTemplates && (
|
||||
<AlertBlock type="error" className="mb-4">
|
||||
{errorTemplates?.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
{isLoadingTemplates ? (
|
||||
<div className="flex justify-center items-center w-full h-full flex-row gap-4">
|
||||
<Loader2 className="size-8 text-muted-foreground animate-spin min-h-[60vh]" />
|
||||
<div className="text-lg font-medium text-muted-foreground">
|
||||
Loading templates...
|
||||
</div>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex 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">
|
||||
@@ -248,9 +305,9 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6",
|
||||
)}
|
||||
>
|
||||
{templates?.map((template, index) => (
|
||||
{templates?.map((template) => (
|
||||
<div
|
||||
key={`template-${index}`}
|
||||
key={template.id}
|
||||
className={cn(
|
||||
"flex flex-col border rounded-lg overflow-hidden relative",
|
||||
viewMode === "icon" && "h-[200px]",
|
||||
@@ -260,7 +317,6 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
<Badge className="absolute top-2 right-2" variant="blue">
|
||||
{template.version}
|
||||
</Badge>
|
||||
{/* Template Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
|
||||
@@ -268,7 +324,7 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={`/templates/${template.logo}`}
|
||||
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template.id}/${template.logo}`}
|
||||
className={cn(
|
||||
"object-contain",
|
||||
viewMode === "detailed" ? "size-24" : "size-16",
|
||||
@@ -321,7 +377,7 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Github className="size-5" />
|
||||
<GithubIcon className="size-5" />
|
||||
</Link>
|
||||
{template.links.website && (
|
||||
<Link
|
||||
@@ -383,7 +439,7 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
side="top"
|
||||
>
|
||||
<span>
|
||||
If ot server is selected, the application
|
||||
If no server is selected, the application
|
||||
will be deployed on the server where the
|
||||
user is logged in.
|
||||
</span>
|
||||
@@ -431,18 +487,19 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
projectId,
|
||||
serverId: serverId || undefined,
|
||||
id: template.id,
|
||||
baseUrl: customBaseUrl,
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Setting up...",
|
||||
success: (_data) => {
|
||||
success: () => {
|
||||
utils.project.one.invalidate({
|
||||
projectId,
|
||||
});
|
||||
setOpen(false);
|
||||
return `${template.name} template created successfully`;
|
||||
},
|
||||
error: (_err) => {
|
||||
return `An error ocurred deploying ${template.name} template`;
|
||||
error: () => {
|
||||
return `An error occurred deploying ${template.name} template`;
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -148,7 +148,7 @@ export const HandleProject = ({ projectId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tesla" {...field} />
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -115,7 +115,7 @@ export const ShowProjects = () => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 flex-wrap gap-5">
|
||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
|
||||
{filteredProjects?.map((project) => {
|
||||
const emptyServices =
|
||||
project?.mariadb.length === 0 &&
|
||||
@@ -186,7 +186,7 @@ export const ShowProjects = () => {
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
>
|
||||
<span>{domain.host}</span>
|
||||
<span className="truncate">{domain.host}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -222,7 +222,7 @@ export const ShowProjects = () => {
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
>
|
||||
<span>{domain.host}</span>
|
||||
<span className="truncate">{domain.host}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -23,6 +24,7 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
@@ -100,6 +102,17 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||
</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" 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)}
|
||||
|
||||
@@ -2,12 +2,26 @@ 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 * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
RefreshCcw,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
|
||||
interface Props {
|
||||
redisId: string;
|
||||
}
|
||||
@@ -63,94 +77,150 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<DialogAction
|
||||
title="Deploy Redis"
|
||||
description="Are you sure you want to deploy this redis?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Redis"
|
||||
description="Are you sure you want to reload this redis?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
redisId: redisId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Redis reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Redis");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" isLoading={isReloading}>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{/* <ResetRedis redisId={redisId} appName={data?.appName || ""} /> */}
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Start Redis"
|
||||
description="Are you sure you want to start this redis?"
|
||||
title="Deploy Redis"
|
||||
description="Are you sure you want to deploy this redis?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
redisId: redisId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Redis started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Redis");
|
||||
});
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" isLoading={isStarting}>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Deploy
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the Redis database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Redis"
|
||||
description="Are you sure you want to stop this redis?"
|
||||
title="Reload Redis"
|
||||
description="Are you sure you want to reload this redis?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
await reload({
|
||||
redisId: redisId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Redis stopped successfully");
|
||||
toast.success("Redis reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Redis");
|
||||
toast.error("Error reloading Redis");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="destructive" isLoading={isStopping}>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the Redis service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Redis"
|
||||
description="Are you sure you want to start this redis?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
redisId: redisId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Redis started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Redis");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the Redis database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Redis"
|
||||
description="Are you sure you want to stop this redis?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
redisId: redisId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Redis stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Redis");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running Redis database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
|
||||
@@ -119,7 +119,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tesla" {...field} />
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@@ -14,6 +14,13 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
export interface RequestDistributionChartProps {
|
||||
dateRange?: {
|
||||
from: Date | undefined;
|
||||
to: Date | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
views: {
|
||||
label: "Page Views",
|
||||
@@ -24,10 +31,22 @@ const chartConfig = {
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const RequestDistributionChart = () => {
|
||||
const { data: stats } = api.settings.readStats.useQuery(undefined, {
|
||||
refetchInterval: 1333,
|
||||
});
|
||||
export const RequestDistributionChart = ({
|
||||
dateRange,
|
||||
}: RequestDistributionChartProps) => {
|
||||
const { data: stats } = api.settings.readStats.useQuery(
|
||||
{
|
||||
dateRange: dateRange
|
||||
? {
|
||||
start: dateRange.from?.toISOString(),
|
||||
end: dateRange.to?.toISOString(),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
refetchInterval: 1333,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
|
||||
@@ -79,7 +79,15 @@ export const priorities = [
|
||||
icon: Server,
|
||||
},
|
||||
];
|
||||
export const RequestsTable = () => {
|
||||
|
||||
export interface RequestsTableProps {
|
||||
dateRange?: {
|
||||
from: Date | undefined;
|
||||
to: Date | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedRow, setSelectedRow] = useState<LogEntry>();
|
||||
@@ -98,6 +106,12 @@ export const RequestsTable = () => {
|
||||
page: pagination,
|
||||
search,
|
||||
status: statusFilter,
|
||||
dateRange: dateRange
|
||||
? {
|
||||
start: dateRange.from?.toISOString(),
|
||||
end: dateRange.to?.toISOString(),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
refetchInterval: 1333,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -8,9 +9,29 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { type RouterOutputs, api } from "@/utils/api";
|
||||
import { ArrowDownUp } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ArrowDownUp,
|
||||
AlertCircle,
|
||||
InfoIcon,
|
||||
Calendar as CalendarIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { RequestDistributionChart } from "./request-distribution-chart";
|
||||
import { RequestsTable } from "./requests-table";
|
||||
@@ -20,17 +41,30 @@ export type LogEntry = NonNullable<
|
||||
>[0];
|
||||
|
||||
export const ShowRequests = () => {
|
||||
const { data: isLogRotateActive, refetch: refetchLogRotate } =
|
||||
api.settings.getLogRotateStatus.useQuery();
|
||||
|
||||
const { mutateAsync: toggleLogRotate } =
|
||||
api.settings.toggleLogRotate.useMutation();
|
||||
|
||||
const { data: isActive, refetch } =
|
||||
api.settings.haveActivateRequests.useQuery();
|
||||
const { mutateAsync: toggleRequests } =
|
||||
api.settings.toggleRequests.useMutation();
|
||||
|
||||
const { data: logCleanupStatus } =
|
||||
api.settings.getLogCleanupStatus.useQuery();
|
||||
const { mutateAsync: updateLogCleanup } =
|
||||
api.settings.updateLogCleanup.useMutation();
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null);
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
from: Date | undefined;
|
||||
to: Date | undefined;
|
||||
}>({
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (logCleanupStatus) {
|
||||
setCronExpression(logCleanupStatus.cronExpression || "0 0 * * *");
|
||||
}
|
||||
}, [logCleanupStatus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
@@ -57,7 +91,60 @@ export const ShowRequests = () => {
|
||||
</AlertBlock>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<div className="flex w-full gap-4 justify-end">
|
||||
<div className="flex w-full gap-4 justify-end items-center">
|
||||
<div className="flex-1 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="cron" className="min-w-32">
|
||||
Log Cleanup Schedule
|
||||
</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="size-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-80">
|
||||
At the scheduled time, the cleanup job will keep
|
||||
only the last 1000 entries in the access log file
|
||||
and signal Traefik to reopen its log files. The
|
||||
default schedule is daily at midnight (0 0 * * *).
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex-1 flex gap-4">
|
||||
<Input
|
||||
id="cron"
|
||||
placeholder="0 0 * * *"
|
||||
value={cronExpression || ""}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
className="max-w-60"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (!cronExpression?.trim()) {
|
||||
toast.error("Please enter a valid cron expression");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateLogCleanup({
|
||||
cronExpression: cronExpression,
|
||||
});
|
||||
toast.success("Log cleanup schedule updated");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to update log cleanup schedule: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Update Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogAction
|
||||
title={isActive ? "Deactivate Requests" : "Activate Requests"}
|
||||
description="You will also need to restart Traefik to apply the changes"
|
||||
@@ -77,53 +164,81 @@ export const ShowRequests = () => {
|
||||
>
|
||||
<Button>{isActive ? "Deactivate" : "Activate"}</Button>
|
||||
</DialogAction>
|
||||
|
||||
<DialogAction
|
||||
title={
|
||||
isLogRotateActive
|
||||
? "Activate Log Rotate"
|
||||
: "Deactivate Log Rotate"
|
||||
}
|
||||
description={
|
||||
isLogRotateActive
|
||||
? "This will make the logs rotate on interval 1 day and maximum size of 100 MB and maximum 6 logs"
|
||||
: "The log rotation will be disabled"
|
||||
}
|
||||
onClick={() => {
|
||||
toggleLogRotate({
|
||||
enable: !isLogRotateActive,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`Log rotate ${isLogRotateActive ? "activated" : "deactivated"}`,
|
||||
);
|
||||
refetchLogRotate();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary">
|
||||
{isLogRotateActive
|
||||
? "Activate Log Rotate"
|
||||
: "Deactivate Log Rotate"}
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isActive ? (
|
||||
<RequestDistributionChart />
|
||||
) : (
|
||||
<div className="flex items-center justify-center min-h-[25vh]">
|
||||
<span className="text-muted-foreground py-6">
|
||||
You need to activate requests
|
||||
</span>
|
||||
{isActive ? (
|
||||
<>
|
||||
<div className="flex justify-end mb-4 gap-2">
|
||||
{(dateRange.from || dateRange.to) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDateRange({ from: undefined, to: undefined })
|
||||
}
|
||||
className="px-3"
|
||||
>
|
||||
Clear dates
|
||||
</Button>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[300px] justify-start text-left font-normal"
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dateRange.from ? (
|
||||
dateRange.to ? (
|
||||
<>
|
||||
{format(dateRange.from, "LLL dd, y")} -{" "}
|
||||
{format(dateRange.to, "LLL dd, y")}
|
||||
</>
|
||||
) : (
|
||||
format(dateRange.from, "LLL dd, y")
|
||||
)
|
||||
) : (
|
||||
<span>Pick a date range</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange.from}
|
||||
selected={{
|
||||
from: dateRange.from,
|
||||
to: dateRange.to,
|
||||
}}
|
||||
onSelect={(range) => {
|
||||
setDateRange({
|
||||
from: range?.from,
|
||||
to: range?.to,
|
||||
});
|
||||
}}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
{isActive && <RequestsTable />}
|
||||
</div>
|
||||
<RequestDistributionChart dateRange={dateRange} />
|
||||
<RequestsTable dateRange={dateRange} />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4 text-muted-foreground">
|
||||
<AlertCircle className="size-12 text-muted-foreground/50" />
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-medium">
|
||||
Requests are not activated
|
||||
</h3>
|
||||
<p className="text-sm max-w-md">
|
||||
Activate requests to see incoming traffic statistics and
|
||||
monitor your application's usage. After activation, you'll
|
||||
need to reload Traefik for the changes to take effect.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
@@ -441,13 +443,16 @@ export const AddApiKey = () => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded-md bg-muted p-4 font-mono text-sm break-all">
|
||||
{newApiKey}
|
||||
</div>
|
||||
<CodeEditor
|
||||
className="font-mono text-sm break-all"
|
||||
language="properties"
|
||||
value={newApiKey}
|
||||
readOnly
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(newApiKey);
|
||||
copy(newApiKey);
|
||||
toast.success("API key copied to clipboard");
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -207,7 +207,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
autoComplete="off"
|
||||
autoComplete="username"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -227,7 +227,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Password"
|
||||
autoComplete="off"
|
||||
autoComplete="one-time-code"
|
||||
{...field}
|
||||
type="password"
|
||||
/>
|
||||
|
||||
@@ -39,12 +39,12 @@ import { S3_PROVIDERS } from "./constants";
|
||||
|
||||
const addDestination = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
provider: z.string().optional(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
bucket: z.string(),
|
||||
provider: z.string().min(1, "Provider is required"),
|
||||
accessKeyId: z.string().min(1, "Access Key Id is required"),
|
||||
secretAccessKey: z.string().min(1, "Secret Access Key is required"),
|
||||
bucket: z.string().min(1, "Bucket is required"),
|
||||
region: z.string(),
|
||||
endpoint: z.string(),
|
||||
endpoint: z.string().min(1, "Endpoint is required"),
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -129,6 +129,63 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestConnection = async (serverId?: string) => {
|
||||
const result = await form.trigger([
|
||||
"provider",
|
||||
"accessKeyId",
|
||||
"secretAccessKey",
|
||||
"bucket",
|
||||
"endpoint",
|
||||
]);
|
||||
|
||||
if (!result) {
|
||||
const errors = form.formState.errors;
|
||||
const errorFields = Object.entries(errors)
|
||||
.map(([field, error]) => `${field}: ${error?.message}`)
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
toast.error("Please fill all required fields", {
|
||||
description: errorFields,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCloud && !serverId) {
|
||||
toast.error("Please select a server");
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = form.getValues("provider");
|
||||
const accessKey = form.getValues("accessKeyId");
|
||||
const secretKey = form.getValues("secretAccessKey");
|
||||
const bucket = form.getValues("bucket");
|
||||
const endpoint = form.getValues("endpoint");
|
||||
const region = form.getValues("region");
|
||||
|
||||
const connectionString = `:s3,provider=${provider},access_key_id=${accessKey},secret_access_key=${secretKey},endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}`;
|
||||
|
||||
await testConnection({
|
||||
provider,
|
||||
accessKey,
|
||||
bucket,
|
||||
endpoint,
|
||||
name: "Test",
|
||||
region,
|
||||
secretAccessKey: secretKey,
|
||||
serverId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Connection Success");
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error("Error connecting to provider", {
|
||||
description: `${e.message}\n\nTry manually: rclone ls ${connectionString}`,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger className="" asChild>
|
||||
@@ -349,26 +406,9 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoadingConnection}
|
||||
onClick={async () => {
|
||||
await testConnection({
|
||||
provider: form.getValues("provider") || "",
|
||||
accessKey: form.getValues("accessKeyId"),
|
||||
bucket: form.getValues("bucket"),
|
||||
endpoint: form.getValues("endpoint"),
|
||||
name: "Test",
|
||||
region: form.getValues("region"),
|
||||
secretAccessKey: form.getValues("secretAccessKey"),
|
||||
serverId: form.getValues("serverId"),
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Connection Success");
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error("Error connecting the provider", {
|
||||
description: e.message,
|
||||
});
|
||||
});
|
||||
await handleTestConnection(form.getValues("serverId"));
|
||||
}}
|
||||
>
|
||||
Test Connection
|
||||
@@ -380,21 +420,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
await testConnection({
|
||||
provider: form.getValues("provider") || "",
|
||||
accessKey: form.getValues("accessKeyId"),
|
||||
bucket: form.getValues("bucket"),
|
||||
endpoint: form.getValues("endpoint"),
|
||||
name: "Test",
|
||||
region: form.getValues("region"),
|
||||
secretAccessKey: form.getValues("secretAccessKey"),
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Connection Success");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error connecting the provider");
|
||||
});
|
||||
await handleTestConnection();
|
||||
}}
|
||||
>
|
||||
Test connection
|
||||
|
||||
@@ -56,9 +56,17 @@ export const ShowDestinations = () => {
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<span className="text-sm">
|
||||
{index + 1}. {destination.name}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm">
|
||||
{index + 1}. {destination.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Created at:{" "}
|
||||
{new Date(
|
||||
destination.createdAt,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-row gap-1">
|
||||
<HandleDestinations
|
||||
destinationId={destination.destinationId}
|
||||
|
||||
@@ -45,21 +45,12 @@ const Schema = z.object({
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
interface Model {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
aiId?: string;
|
||||
}
|
||||
|
||||
export const HandleAi = ({ aiId }: Props) => {
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const utils = api.useUtils();
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data, refetch } = api.ai.one.useQuery(
|
||||
@@ -73,6 +64,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
const { mutateAsync, isLoading } = aiId
|
||||
? api.ai.update.useMutation()
|
||||
: api.ai.create.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: {
|
||||
@@ -94,50 +86,33 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
});
|
||||
}, [aiId, form, data]);
|
||||
|
||||
const fetchModels = async (apiUrl: string, apiKey: string) => {
|
||||
setIsLoadingModels(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch models");
|
||||
}
|
||||
const res = await response.json();
|
||||
setModels(res.data);
|
||||
const apiUrl = form.watch("apiUrl");
|
||||
const apiKey = form.watch("apiKey");
|
||||
|
||||
// Set default model to gpt-4 if present
|
||||
const defaultModel = res.data.find(
|
||||
(model: Model) => model.id === "gpt-4",
|
||||
);
|
||||
if (defaultModel) {
|
||||
form.setValue("model", defaultModel.id);
|
||||
return defaultModel.id;
|
||||
}
|
||||
} catch (error) {
|
||||
setError("Failed to fetch models. Please check your API URL and Key.");
|
||||
setModels([]);
|
||||
} finally {
|
||||
setIsLoadingModels(false);
|
||||
}
|
||||
};
|
||||
const { data: models, isLoading: isLoadingServerModels } =
|
||||
api.ai.getModels.useQuery(
|
||||
{
|
||||
apiUrl: apiUrl ?? "",
|
||||
apiKey: apiKey ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!apiUrl && !!apiKey,
|
||||
onError: (error) => {
|
||||
setError(`Failed to fetch models: ${error.message}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const apiUrl = form.watch("apiUrl");
|
||||
const apiKey = form.watch("apiKey");
|
||||
if (apiUrl && apiKey) {
|
||||
form.setValue("model", "");
|
||||
fetchModels(apiUrl, apiKey);
|
||||
}
|
||||
}, [form.watch("apiUrl"), form.watch("apiKey")]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
try {
|
||||
console.log("Form data:", data);
|
||||
console.log("Current model value:", form.getValues("model"));
|
||||
await mutateAsync({
|
||||
...data,
|
||||
aiId: aiId || "",
|
||||
@@ -148,8 +123,9 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Submit error:", error);
|
||||
toast.error("Failed to save AI settings");
|
||||
toast.error("Failed to save AI settings", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,13 +208,13 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{isLoadingModels && (
|
||||
{isLoadingServerModels && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Loading models...
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isLoadingModels && models.length > 0 && (
|
||||
{!isLoadingServerModels && models && models.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
|
||||
@@ -64,12 +64,12 @@ export const Enable2FA = () => {
|
||||
const handlePasswordSubmit = async (formData: PasswordForm) => {
|
||||
setIsPasswordLoading(true);
|
||||
try {
|
||||
const { data: enableData } = await authClient.twoFactor.enable({
|
||||
const { data: enableData, error } = await authClient.twoFactor.enable({
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
if (!enableData) {
|
||||
throw new Error("No data received from server");
|
||||
throw new Error(error?.message || "Error enabling 2FA");
|
||||
}
|
||||
|
||||
if (enableData.backupCodes) {
|
||||
@@ -95,7 +95,8 @@ export const Enable2FA = () => {
|
||||
error instanceof Error ? error.message : "Error setting up 2FA",
|
||||
);
|
||||
passwordForm.setError("password", {
|
||||
message: "Error verifying password",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Error setting up 2FA",
|
||||
});
|
||||
} finally {
|
||||
setIsPasswordLoading(false);
|
||||
|
||||
@@ -59,15 +59,17 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
.then(async () => {
|
||||
toast.success("Traefik Reloaded");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Traefik");
|
||||
});
|
||||
.catch(() => {});
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span>{t("settings.server.webServer.reload")}</span>
|
||||
</DropdownMenuItem>
|
||||
<ShowModalLogs appName="dokploy-traefik" serverId={serverId}>
|
||||
<ShowModalLogs
|
||||
appName="dokploy-traefik"
|
||||
serverId={serverId}
|
||||
type="standalone"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="cursor-pointer"
|
||||
@@ -108,15 +110,6 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{/*
|
||||
<DockerTerminalModal appName="dokploy-traefik">
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<span>Enter the terminal</span>
|
||||
</DropdownMenuItem>
|
||||
</DockerTerminalModal> */}
|
||||
<ManageTraefikPorts serverId={serverId}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
|
||||
@@ -35,7 +35,7 @@ const addServerDomain = z
|
||||
.object({
|
||||
domain: z.string().min(1, { message: "URL is required" }),
|
||||
letsEncryptEmail: z.string(),
|
||||
certificateType: z.enum(["letsencrypt", "none"]),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
|
||||
@@ -193,6 +193,7 @@ export const WebDomain = () => {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-end col-span-2">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
{t("settings.common.save")}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -23,6 +24,7 @@ import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "../../application/logs/show";
|
||||
|
||||
const Terminal = dynamic(
|
||||
() =>
|
||||
@@ -109,7 +111,10 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
|
||||
key={container.containerId}
|
||||
value={container.containerId}
|
||||
>
|
||||
{container.name} ({container.containerId}) {container.state}
|
||||
{container.name} ({container.containerId}){" "}
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
||||
|
||||
@@ -19,13 +19,6 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
|
||||
@@ -44,7 +37,6 @@ interface Props {
|
||||
const PortSchema = z.object({
|
||||
targetPort: z.number().min(1, "Target port is required"),
|
||||
publishedPort: z.number().min(1, "Published port is required"),
|
||||
publishMode: z.enum(["ingress", "host"]),
|
||||
});
|
||||
|
||||
const TraefikPortsSchema = z.object({
|
||||
@@ -88,7 +80,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
}, [currentPorts, form]);
|
||||
|
||||
const handleAddPort = () => {
|
||||
append({ targetPort: 0, publishedPort: 0, publishMode: "host" });
|
||||
append({ targetPort: 0, publishedPort: 0 });
|
||||
};
|
||||
|
||||
const onSubmit = async (data: TraefikPortsForm) => {
|
||||
@@ -99,9 +91,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
});
|
||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("settings.server.webServer.traefik.portsUpdateError"));
|
||||
}
|
||||
} catch (_error) {}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -154,7 +144,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
<div className="grid gap-4">
|
||||
{fields.map((field, index) => (
|
||||
<Card key={field.id}>
|
||||
<CardContent className="grid grid-cols-[1fr_1fr_1.5fr_auto] gap-4 p-4 transparent">
|
||||
<CardContent className="grid grid-cols-[1fr_1fr_auto] gap-4 p-4 transparent">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ports.${index}.targetPort`}
|
||||
@@ -207,39 +197,6 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ports.${index}.publishMode`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium text-muted-foreground">
|
||||
{t(
|
||||
"settings.server.webServer.traefik.publishMode",
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="dark:bg-black">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="host">
|
||||
Host Mode
|
||||
</SelectItem>
|
||||
<SelectItem value="ingress">
|
||||
Ingress Mode
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
onClick={() => remove(index)}
|
||||
@@ -263,30 +220,23 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
<span className="text-sm">
|
||||
<strong>
|
||||
Each port mapping defines how external traffic reaches
|
||||
your containers.
|
||||
your containers through Traefik.
|
||||
</strong>
|
||||
<ul className="pt-2">
|
||||
<li>
|
||||
<strong>Host Mode:</strong> Directly binds the port
|
||||
to the host machine.
|
||||
<ul className="p-2 list-inside list-disc">
|
||||
<li>
|
||||
Best for single-node deployments or when you
|
||||
need guaranteed port availability.
|
||||
</li>
|
||||
</ul>
|
||||
<strong>Target Port:</strong> The port inside your
|
||||
container that the service is listening on.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ingress Mode:</strong> Routes through Docker
|
||||
Swarm's load balancer.
|
||||
<ul className="p-2 list-inside list-disc">
|
||||
<li>
|
||||
Recommended for multi-node deployments and
|
||||
better scalability.
|
||||
</li>
|
||||
</ul>
|
||||
<strong>Published Port:</strong> The port on your
|
||||
host machine that will be mapped to the target port.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-2">
|
||||
All ports are bound directly to the host machine,
|
||||
allowing Traefik to handle incoming traffic and route
|
||||
it appropriately to your services.
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</AlertBlock>
|
||||
|
||||
@@ -21,6 +21,8 @@ import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "../../application/logs/show";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const DockerLogsId = dynamic(
|
||||
() =>
|
||||
@@ -36,13 +38,20 @@ interface Props {
|
||||
appName: string;
|
||||
children?: React.ReactNode;
|
||||
serverId?: string;
|
||||
type?: "standalone" | "swarm";
|
||||
}
|
||||
|
||||
export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
|
||||
export const ShowModalLogs = ({
|
||||
appName,
|
||||
children,
|
||||
serverId,
|
||||
type = "swarm",
|
||||
}: Props) => {
|
||||
const { data, isLoading } = api.docker.getContainersByAppLabel.useQuery(
|
||||
{
|
||||
appName,
|
||||
serverId,
|
||||
type,
|
||||
},
|
||||
{
|
||||
enabled: !!appName,
|
||||
@@ -83,7 +92,10 @@ export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
|
||||
key={container.containerId}
|
||||
value={container.containerId}
|
||||
>
|
||||
{container.name} ({container.containerId}) {container.state}
|
||||
{container.name} ({container.containerId}){" "}
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
||||
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import type { IUpdateData } from "@dokploy/server/index";
|
||||
import {
|
||||
@@ -24,9 +30,17 @@ import { UpdateWebServer } from "./update-webserver";
|
||||
|
||||
interface Props {
|
||||
updateData?: IUpdateData;
|
||||
children?: React.ReactNode;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const UpdateServer = ({ updateData }: Props) => {
|
||||
export const UpdateServer = ({
|
||||
updateData,
|
||||
children,
|
||||
isOpen: isOpenProp,
|
||||
onOpenChange: onOpenChangeProp,
|
||||
}: Props) => {
|
||||
const [hasCheckedUpdate, setHasCheckedUpdate] = useState(!!updateData);
|
||||
const [isUpdateAvailable, setIsUpdateAvailable] = useState(
|
||||
!!updateData?.updateAvailable,
|
||||
@@ -35,10 +49,10 @@ export const UpdateServer = ({ updateData }: Props) => {
|
||||
api.settings.getUpdateData.useMutation();
|
||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||
const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState(
|
||||
updateData?.latestVersion ?? "",
|
||||
);
|
||||
const [isOpenInternal, setIsOpenInternal] = useState(false);
|
||||
|
||||
const handleCheckUpdates = async () => {
|
||||
try {
|
||||
@@ -65,28 +79,52 @@ export const UpdateServer = ({ updateData }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isOpen = isOpenInternal || isOpenProp;
|
||||
const onOpenChange = (open: boolean) => {
|
||||
setIsOpenInternal(open);
|
||||
onOpenChangeProp?.(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant={updateData ? "outline" : "secondary"}
|
||||
className="gap-2"
|
||||
>
|
||||
{updateData ? (
|
||||
<>
|
||||
<span className="flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
|
||||
</span>
|
||||
Update available
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Updates
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={updateData ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => onOpenChange?.(true)}
|
||||
>
|
||||
<Download className="h-4 w-4 flex-shrink-0" />
|
||||
{updateData ? (
|
||||
<span className="font-medium truncate group-data-[collapsible=icon]:hidden">
|
||||
Update Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium truncate group-data-[collapsible=icon]:hidden">
|
||||
Check for updates
|
||||
</span>
|
||||
)}
|
||||
{updateData && (
|
||||
<span className="absolute right-2 flex h-2 w-2 group-data-[collapsible=icon]:hidden">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{updateData && (
|
||||
<TooltipContent side="right" sideOffset={10}>
|
||||
<p>Update Available</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
@@ -217,7 +255,7 @@ export const UpdateServer = ({ updateData }: Props) => {
|
||||
|
||||
<div className="space-y-4 flex items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||
<Button variant="outline" onClick={() => onOpenChange?.(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{isUpdateAvailable ? (
|
||||
|
||||
119
apps/dokploy/components/dashboard/shared/rebuild-database.tsx
Normal file
119
apps/dokploy/components/dashboard/shared/rebuild-database.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { DatabaseIcon, AlertTriangle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mysql" | "mariadb" | "mongo" | "redis";
|
||||
}
|
||||
|
||||
export const RebuildDatabase = ({ id, type }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.rebuild.useMutation(),
|
||||
mysql: () => api.mysql.rebuild.useMutation(),
|
||||
mariadb: () => api.mariadb.rebuild.useMutation(),
|
||||
mongo: () => api.mongo.rebuild.useMutation(),
|
||||
redis: () => api.redis.rebuild.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync, isLoading } = mutationMap[type]();
|
||||
|
||||
const handleRebuild = async () => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
postgresId: type === "postgres" ? id : "",
|
||||
mysqlId: type === "mysql" ? id : "",
|
||||
mariadbId: type === "mariadb" ? id : "",
|
||||
mongoId: type === "mongo" ? id : "",
|
||||
redisId: type === "redis" ? id : "",
|
||||
});
|
||||
toast.success("Database rebuilt successfully");
|
||||
await utils.invalidate();
|
||||
} catch (error) {
|
||||
toast.error("Error rebuilding database", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-base font-semibold">Rebuild Database</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This action will completely reset your database to its initial
|
||||
state. All data, tables, and configurations will be removed.
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
variant="outline"
|
||||
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
|
||||
>
|
||||
<DatabaseIcon className="mr-2 h-4 w-4" />
|
||||
Rebuild Database
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Are you absolutely sure?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<p>This action will:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Stop the current database service</li>
|
||||
<li>Delete all existing data and volumes</li>
|
||||
<li>Reset to the default configuration</li>
|
||||
<li>Restart the service with a clean state</li>
|
||||
</ul>
|
||||
<p className="font-medium text-destructive mt-4">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRebuild}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
asChild
|
||||
>
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Yes, rebuild database
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
|
||||
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
|
||||
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
||||
import { RebuildDatabase } from "./rebuild-database";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mysql" | "mariadb" | "mongo" | "redis";
|
||||
}
|
||||
|
||||
export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<ShowCustomCommand id={id} type={type} />
|
||||
<ShowVolumes id={id} type={type} />
|
||||
<ShowResources id={id} type={type} />
|
||||
<RebuildDatabase id={id} type={type} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -176,7 +176,7 @@ export default function SwarmMonitorCard({ serverId }: Props) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
|
||||
{nodes.map((node) => (
|
||||
<NodeCard key={node.ID} node={node} serverId={serverId} />
|
||||
))}
|
||||
|
||||
@@ -37,8 +37,6 @@ import {
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -498,7 +496,6 @@ function SidebarLogo() {
|
||||
const { state } = useSidebar();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: user } = api.user.get.useQuery();
|
||||
// const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const {
|
||||
@@ -774,6 +771,7 @@ export default function Page({ children }: Props) {
|
||||
const pathname = usePathname();
|
||||
const _currentPath = router.pathname;
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||
|
||||
const includesProjects = pathname?.includes("/dashboard/project");
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
@@ -910,7 +908,7 @@ export default function Page({ children }: Props) {
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Settings</SidebarGroupLabel>
|
||||
<SidebarMenu className="gap-2">
|
||||
<SidebarMenu className="gap-1">
|
||||
{filteredSettings.map((item) => {
|
||||
const isSingle = item.isSingle !== false;
|
||||
const isActive = isSingle
|
||||
@@ -1017,21 +1015,29 @@ export default function Page({ children }: Props) {
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{!isCloud && auth?.role === "owner" && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<UpdateServerButton />
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenu className="flex flex-col gap-2">
|
||||
{!isCloud && auth?.role === "owner" && (
|
||||
<SidebarMenuItem>
|
||||
<UpdateServerButton />
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
<SidebarMenuItem>
|
||||
<UserNav />
|
||||
</SidebarMenuItem>
|
||||
{dokployVersion && (
|
||||
<>
|
||||
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
|
||||
Version {dokployVersion}
|
||||
</div>
|
||||
<div className="hidden text-xs text-muted-foreground text-center group-data-[collapsible=icon]:block">
|
||||
{dokployVersion}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
@@ -1055,10 +1061,6 @@ export default function Page({ children }: Props) {
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{activeItem?.title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
@@ -1066,7 +1068,7 @@ export default function Page({ children }: Props) {
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col w-full gap-4 p-4 pt-0">{children}</div>
|
||||
<div className="flex flex-col w-full p-4 pt-0">{children}</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { IUpdateData } from "@dokploy/server/index";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import UpdateServer from "../dashboard/settings/web-server/update-server";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
|
||||
|
||||
export const UpdateServerButton = () => {
|
||||
@@ -15,6 +22,7 @@ export const UpdateServerButton = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { mutateAsync: getUpdateData } =
|
||||
api.settings.getUpdateData.useMutation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const checkUpdatesIntervalRef = useRef<null | NodeJS.Timeout>(null);
|
||||
|
||||
@@ -69,11 +77,47 @@ export const UpdateServerButton = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
updateData.updateAvailable && (
|
||||
<div>
|
||||
<UpdateServer updateData={updateData} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
return updateData.updateAvailable ? (
|
||||
<div className="border-t pt-4">
|
||||
<UpdateServer
|
||||
updateData={updateData}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={updateData ? "outline" : "secondary"}
|
||||
className="w-full"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Download className="h-4 w-4 flex-shrink-0" />
|
||||
{updateData ? (
|
||||
<span className="font-medium truncate group-data-[collapsible=icon]:hidden">
|
||||
Update Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium truncate group-data-[collapsible=icon]:hidden">
|
||||
Check for updates
|
||||
</span>
|
||||
)}
|
||||
{updateData && (
|
||||
<span className="absolute right-2 flex h-2 w-2 group-data-[collapsible=icon]:hidden">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{updateData && (
|
||||
<TooltipContent side="right" sideOffset={10}>
|
||||
<p>Update Available</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</UpdateServer>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
|
||||
)}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="block" />
|
||||
{_index + 1 < list.length && <BreadcrumbSeparator className="block" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
|
||||
@@ -9,6 +9,116 @@ import { EditorView } from "@codemirror/view";
|
||||
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
|
||||
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
autocompletion,
|
||||
type CompletionContext,
|
||||
type CompletionResult,
|
||||
type Completion,
|
||||
} from "@codemirror/autocomplete";
|
||||
|
||||
// Docker Compose completion options
|
||||
const dockerComposeServices = [
|
||||
{ label: "services", type: "keyword", info: "Define services" },
|
||||
{ label: "version", type: "keyword", info: "Specify compose file version" },
|
||||
{ label: "volumes", type: "keyword", info: "Define volumes" },
|
||||
{ label: "networks", type: "keyword", info: "Define networks" },
|
||||
{ label: "configs", type: "keyword", info: "Define configuration files" },
|
||||
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
||||
].map((opt) => ({
|
||||
...opt,
|
||||
apply: (view: EditorView, completion: Completion) => {
|
||||
const insert = `${completion.label}:`;
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: view.state.selection.main.from,
|
||||
to: view.state.selection.main.to,
|
||||
insert,
|
||||
},
|
||||
selection: { anchor: view.state.selection.main.from + insert.length },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const dockerComposeServiceOptions = [
|
||||
{
|
||||
label: "image",
|
||||
type: "keyword",
|
||||
info: "Specify the image to start the container from",
|
||||
},
|
||||
{ label: "build", type: "keyword", info: "Build configuration" },
|
||||
{ label: "command", type: "keyword", info: "Override the default command" },
|
||||
{ label: "container_name", type: "keyword", info: "Custom container name" },
|
||||
{
|
||||
label: "depends_on",
|
||||
type: "keyword",
|
||||
info: "Express dependency between services",
|
||||
},
|
||||
{ label: "environment", type: "keyword", info: "Add environment variables" },
|
||||
{
|
||||
label: "env_file",
|
||||
type: "keyword",
|
||||
info: "Add environment variables from a file",
|
||||
},
|
||||
{
|
||||
label: "expose",
|
||||
type: "keyword",
|
||||
info: "Expose ports without publishing them",
|
||||
},
|
||||
{ label: "ports", type: "keyword", info: "Expose ports" },
|
||||
{
|
||||
label: "volumes",
|
||||
type: "keyword",
|
||||
info: "Mount host paths or named volumes",
|
||||
},
|
||||
{ label: "restart", type: "keyword", info: "Restart policy" },
|
||||
{ label: "networks", type: "keyword", info: "Networks to join" },
|
||||
].map((opt) => ({
|
||||
...opt,
|
||||
apply: (view: EditorView, completion: Completion) => {
|
||||
const insert = `${completion.label}: `;
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: view.state.selection.main.from,
|
||||
to: view.state.selection.main.to,
|
||||
insert,
|
||||
},
|
||||
selection: { anchor: view.state.selection.main.from + insert.length },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
function dockerComposeComplete(
|
||||
context: CompletionContext,
|
||||
): CompletionResult | null {
|
||||
const word = context.matchBefore(/\w*/);
|
||||
if (!word) return null;
|
||||
|
||||
if (!word.text && !context.explicit) return null;
|
||||
|
||||
// Check if we're at the root level
|
||||
const line = context.state.doc.lineAt(context.pos);
|
||||
const indentation = /^\s*/.exec(line.text)?.[0].length || 0;
|
||||
|
||||
if (indentation === 0) {
|
||||
return {
|
||||
from: word.from,
|
||||
options: dockerComposeServices,
|
||||
validFor: /^\w*$/,
|
||||
};
|
||||
}
|
||||
|
||||
// If we're inside a service definition
|
||||
if (indentation === 4) {
|
||||
return {
|
||||
from: word.from,
|
||||
options: dockerComposeServiceOptions,
|
||||
validFor: /^\w*$/,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface Props extends ReactCodeMirrorProps {
|
||||
wrapperClassName?: string;
|
||||
disabled?: boolean;
|
||||
@@ -45,6 +155,11 @@ export const CodeEditor = ({
|
||||
? StreamLanguage.define(shell)
|
||||
: StreamLanguage.define(properties),
|
||||
props.lineWrapping ? EditorView.lineWrapping : [],
|
||||
language === "yaml"
|
||||
? autocompletion({
|
||||
override: [dockerComposeComplete],
|
||||
})
|
||||
: [],
|
||||
]}
|
||||
{...props}
|
||||
editable={!props.disabled}
|
||||
@@ -55,7 +170,7 @@ export const CodeEditor = ({
|
||||
)}
|
||||
/>
|
||||
{props.disabled && (
|
||||
<div className="absolute top-0 rounded-md left-0 w-full h-full flex items-center justify-center z-[10] [background:var(--overlay)]" />
|
||||
<div className="absolute top-0 rounded-md left-0 w-full h-full flex items-center justify-center z-[10] [background:var(--overlay)] h-full" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
1078
apps/dokploy/components/shared/compose-spec.json
Normal file
1078
apps/dokploy/components/shared/compose-spec.json
Normal file
File diff suppressed because it is too large
Load Diff
68
apps/dokploy/components/ui/calendar.tsx
Normal file
68
apps/dokploy/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100",
|
||||
),
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ className, ...props }) => (
|
||||
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
|
||||
),
|
||||
IconRight: ({ className, ...props }) => (
|
||||
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar";
|
||||
|
||||
export { Calendar };
|
||||
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
1
apps/dokploy/drizzle/0070_useful_serpent_society.sql
Normal file
1
apps/dokploy/drizzle/0070_useful_serpent_society.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "destination" ADD COLUMN "createdAt" timestamp DEFAULT now() NOT NULL;
|
||||
1
apps/dokploy/drizzle/0071_flaky_black_queen.sql
Normal file
1
apps/dokploy/drizzle/0071_flaky_black_queen.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user_temp" ADD COLUMN "logCleanupCron" text;
|
||||
2
apps/dokploy/drizzle/0072_green_susan_delgado.sql
Normal file
2
apps/dokploy/drizzle/0072_green_susan_delgado.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE "public"."certificateType" ADD VALUE 'custom';--> statement-breakpoint
|
||||
ALTER TABLE "domain" ADD COLUMN "customCertResolver" text;--> statement-breakpoint
|
||||
1
apps/dokploy/drizzle/0073_hot_domino.sql
Normal file
1
apps/dokploy/drizzle/0073_hot_domino.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ADD COLUMN "previewCertificateProvider" text;
|
||||
1
apps/dokploy/drizzle/0074_black_quasar.sql
Normal file
1
apps/dokploy/drizzle/0074_black_quasar.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" RENAME COLUMN "previewCertificateProvider" TO "previewCustomCertResolver";
|
||||
1
apps/dokploy/drizzle/0075_young_typhoid_mary.sql
Normal file
1
apps/dokploy/drizzle/0075_young_typhoid_mary.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ADD COLUMN "watchPaths" text[];
|
||||
1
apps/dokploy/drizzle/0076_young_sharon_ventura.sql
Normal file
1
apps/dokploy/drizzle/0076_young_sharon_ventura.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "compose" ADD COLUMN "watchPaths" text[];
|
||||
1
apps/dokploy/drizzle/0077_chemical_dreadnoughts.sql
Normal file
1
apps/dokploy/drizzle/0077_chemical_dreadnoughts.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "backup" ADD COLUMN "keepLatestCount" integer;
|
||||
5126
apps/dokploy/drizzle/meta/0070_snapshot.json
Normal file
5126
apps/dokploy/drizzle/meta/0070_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5132
apps/dokploy/drizzle/meta/0071_snapshot.json
Normal file
5132
apps/dokploy/drizzle/meta/0071_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5132
apps/dokploy/drizzle/meta/0072_snapshot.json
Normal file
5132
apps/dokploy/drizzle/meta/0072_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5138
apps/dokploy/drizzle/meta/0073_snapshot.json
Normal file
5138
apps/dokploy/drizzle/meta/0073_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5138
apps/dokploy/drizzle/meta/0074_snapshot.json
Normal file
5138
apps/dokploy/drizzle/meta/0074_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5144
apps/dokploy/drizzle/meta/0075_snapshot.json
Normal file
5144
apps/dokploy/drizzle/meta/0075_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5150
apps/dokploy/drizzle/meta/0076_snapshot.json
Normal file
5150
apps/dokploy/drizzle/meta/0076_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5156
apps/dokploy/drizzle/meta/0077_snapshot.json
Normal file
5156
apps/dokploy/drizzle/meta/0077_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -491,6 +491,62 @@
|
||||
"when": 1741152916611,
|
||||
"tag": "0069_legal_bill_hollister",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 70,
|
||||
"version": "7",
|
||||
"when": 1741322697251,
|
||||
"tag": "0070_useful_serpent_society",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "7",
|
||||
"when": 1741460060541,
|
||||
"tag": "0071_flaky_black_queen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "7",
|
||||
"when": 1741487009559,
|
||||
"tag": "0072_green_susan_delgado",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "7",
|
||||
"when": 1741489681190,
|
||||
"tag": "0073_hot_domino",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 74,
|
||||
"version": "7",
|
||||
"when": 1741490064139,
|
||||
"tag": "0074_black_quasar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 75,
|
||||
"version": "7",
|
||||
"when": 1741491527516,
|
||||
"tag": "0075_young_typhoid_mary",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 76,
|
||||
"version": "7",
|
||||
"when": 1741493754270,
|
||||
"tag": "0076_young_sharon_ventura",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 77,
|
||||
"version": "7",
|
||||
"when": 1741510086231,
|
||||
"tag": "0077_chemical_dreadnoughts",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,54 +3,25 @@
|
||||
* for Docker builds.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import CopyWebpackPlugin from "copy-webpack-plugin";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
transpilePackages: ["@dokploy/server"],
|
||||
webpack: (config) => {
|
||||
config.plugins.push(
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: path.resolve(__dirname, "templates/**/*.yml"),
|
||||
to: ({ context, absoluteFilename }) => {
|
||||
const relativePath = path.relative(
|
||||
path.resolve(__dirname, "templates"),
|
||||
absoluteFilename || context,
|
||||
);
|
||||
return path.join(__dirname, ".next", "templates", relativePath);
|
||||
},
|
||||
globOptions: {
|
||||
ignore: ["**/node_modules/**"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* If you are using `appDir` then you must comment the below `i18n` config out.
|
||||
*
|
||||
* @see https://github.com/vercel/next.js/issues/41980
|
||||
*/
|
||||
i18n: {
|
||||
locales: ["en"],
|
||||
defaultLocale: "en",
|
||||
},
|
||||
reactStrictMode: true,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
transpilePackages: ["@dokploy/server"],
|
||||
/**
|
||||
* If you are using `appDir` then you must comment the below `i18n` config out.
|
||||
*
|
||||
* @see https://github.com/vercel/next.js/issues/41980
|
||||
*/
|
||||
i18n: {
|
||||
locales: ["en"],
|
||||
defaultLocale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.19.1",
|
||||
"version": "v0.20.3",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -36,7 +36,6 @@
|
||||
"test": "vitest --config __test__/vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"ai": "^4.0.23",
|
||||
"@ai-sdk/anthropic": "^1.0.6",
|
||||
"@ai-sdk/azure": "^1.0.15",
|
||||
"@ai-sdk/cohere": "^1.0.6",
|
||||
@@ -44,20 +43,7 @@
|
||||
"@ai-sdk/mistral": "^1.0.6",
|
||||
"@ai-sdk/openai": "^1.0.12",
|
||||
"@ai-sdk/openai-compatible": "^0.0.13",
|
||||
"ollama-ai-provider": "^1.1.0",
|
||||
"better-auth": "1.2.0",
|
||||
"bl": "6.0.11",
|
||||
"rotating-file-stream": "3.2.3",
|
||||
"qrcode": "^1.5.3",
|
||||
"otpauth": "^9.2.3",
|
||||
"hi-base32": "^0.5.1",
|
||||
"boxen": "^7.1.1",
|
||||
"@octokit/auth-app": "^6.0.4",
|
||||
"nodemailer": "6.9.14",
|
||||
"@react-email/components": "^0.0.21",
|
||||
"node-os-utils": "1.3.7",
|
||||
"@lucia-auth/adapter-drizzle": "1.0.7",
|
||||
"dockerode": "4.0.2",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-yaml": "^6.1.1",
|
||||
"@codemirror/language": "^6.10.1",
|
||||
@@ -65,7 +51,10 @@
|
||||
"@codemirror/view": "6.29.0",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@dokploy/trpc-openapi": "0.0.4",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@lucia-auth/adapter-drizzle": "1.0.7",
|
||||
"@octokit/auth-app": "^6.0.4",
|
||||
"@octokit/webhooks": "^13.2.7",
|
||||
"@radix-ui/react-accordion": "1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
@@ -86,8 +75,10 @@
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@react-email/components": "^0.0.21",
|
||||
"@stepperize/react": "4.0.1",
|
||||
"@stripe/stripe-js": "4.8.0",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.16.0",
|
||||
"@trpc/client": "^10.43.6",
|
||||
@@ -97,21 +88,26 @@
|
||||
"@uiw/codemirror-theme-github": "^4.22.1",
|
||||
"@uiw/react-codemirror": "^4.22.1",
|
||||
"@xterm/addon-attach": "0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"@xterm/addon-clipboard": "0.1.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"adm-zip": "^0.5.14",
|
||||
"ai": "^4.0.23",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "1.2.4",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.4.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"date-fns": "3.6.0",
|
||||
"dockerode": "4.0.2",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-orm": "^0.39.1",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"fancy-ansi": "^0.1.3",
|
||||
"hi-base32": "^0.5.1",
|
||||
"i18next": "^23.16.4",
|
||||
"input-otp": "^1.2.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
@@ -119,15 +115,21 @@
|
||||
"lodash": "4.17.21",
|
||||
"lucia": "^3.0.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3",
|
||||
"next": "^15.0.1",
|
||||
"next-i18next": "^15.3.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"node-os-utils": "1.3.7",
|
||||
"node-pty": "1.0.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
"octokit": "3.1.2",
|
||||
"ollama-ai-provider": "^1.1.0",
|
||||
"otpauth": "^9.2.3",
|
||||
"postgres": "3.4.4",
|
||||
"public-ip": "6.0.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "18.2.0",
|
||||
"react-confetti-explosion": "2.1.2",
|
||||
"react-day-picker": "8.10.1",
|
||||
@@ -136,6 +138,7 @@
|
||||
"react-i18next": "^15.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"recharts": "^2.12.7",
|
||||
"rotating-file-stream": "3.2.3",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.5.0",
|
||||
"ssh2": "1.15.0",
|
||||
@@ -149,21 +152,20 @@
|
||||
"ws": "8.16.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"zod": "^3.23.4",
|
||||
"zod-form-data": "^2.0.2",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@tailwindcss/typography": "0.5.16"
|
||||
"zod-form-data": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/node-os-utils": "1.3.4",
|
||||
"@types/adm-zip": "^0.5.5",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^18.17.0",
|
||||
"@types/node-os-utils": "1.3.4",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/ssh2": "1.15.1",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { applications } from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { myQueue } from "@/server/queues/queueSetup";
|
||||
import { deploy } from "@/server/utils/deploy";
|
||||
import { IS_CLOUD } from "@dokploy/server";
|
||||
import { IS_CLOUD, shouldDeploy } from "@dokploy/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
@@ -21,6 +21,7 @@ export default async function handler(
|
||||
where: eq(applications.refreshToken, refreshToken as string),
|
||||
with: {
|
||||
project: true,
|
||||
bitbucket: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,6 +58,20 @@ export default async function handler(
|
||||
return;
|
||||
}
|
||||
} else if (sourceType === "github") {
|
||||
const normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
application.watchPaths,
|
||||
normalizedCommits,
|
||||
);
|
||||
|
||||
if (!shouldDeployPaths) {
|
||||
res.status(301).json({ message: "Watch Paths Not Match" });
|
||||
return;
|
||||
}
|
||||
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
if (!branchName || branchName !== application.branch) {
|
||||
res.status(301).json({ message: "Branch Not Match" });
|
||||
@@ -64,22 +79,55 @@ export default async function handler(
|
||||
}
|
||||
} else if (sourceType === "git") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
|
||||
if (!branchName || branchName !== application.customGitBranch) {
|
||||
res.status(301).json({ message: "Branch Not Match" });
|
||||
return;
|
||||
}
|
||||
} else if (sourceType === "gitlab") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
|
||||
const normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
application.watchPaths,
|
||||
normalizedCommits,
|
||||
);
|
||||
|
||||
if (!shouldDeployPaths) {
|
||||
res.status(301).json({ message: "Watch Paths Not Match" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!branchName || branchName !== application.gitlabBranch) {
|
||||
res.status(301).json({ message: "Branch Not Match" });
|
||||
return;
|
||||
}
|
||||
} else if (sourceType === "bitbucket") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
|
||||
if (!branchName || branchName !== application.bitbucketBranch) {
|
||||
res.status(301).json({ message: "Branch Not Match" });
|
||||
return;
|
||||
}
|
||||
|
||||
const commitedPaths = await extractCommitedPaths(
|
||||
req.body,
|
||||
application.bitbucketOwner,
|
||||
application.bitbucket?.appPassword || "",
|
||||
application.bitbucketRepository || "",
|
||||
);
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
application.watchPaths,
|
||||
commitedPaths,
|
||||
);
|
||||
|
||||
if (!shouldDeployPaths) {
|
||||
res.status(301).json({ message: "Watch Paths Not Match" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -231,3 +279,42 @@ export const extractBranchName = (headers: any, body: any) => {
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const extractCommitedPaths = async (
|
||||
body: any,
|
||||
bitbucketUsername: string | null,
|
||||
bitbucketAppPassword: string | null,
|
||||
repository: string | null,
|
||||
) => {
|
||||
const changes = body.push?.changes || [];
|
||||
|
||||
const commitHashes = changes
|
||||
.map((change: any) => change.new?.target?.hash)
|
||||
.filter(Boolean);
|
||||
const commitedPaths: string[] = [];
|
||||
for (const commit of commitHashes) {
|
||||
const url = `https://api.bitbucket.org/2.0/repositories/${bitbucketUsername}/${repository}/diffstat/${commit}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${bitbucketUsername}:${bitbucketAppPassword}`).toString("base64")}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
for (const value of data.values) {
|
||||
commitedPaths.push(value.new?.path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error fetching Bitbucket diffstat for commit ${commit}:`,
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return commitedPaths;
|
||||
};
|
||||
|
||||
@@ -3,11 +3,12 @@ import { compose } from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { myQueue } from "@/server/queues/queueSetup";
|
||||
import { deploy } from "@/server/utils/deploy";
|
||||
import { IS_CLOUD } from "@dokploy/server";
|
||||
import { IS_CLOUD, shouldDeploy } from "@dokploy/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import {
|
||||
extractBranchName,
|
||||
extractCommitedPaths,
|
||||
extractCommitMessage,
|
||||
extractHash,
|
||||
} from "../[refreshToken]";
|
||||
@@ -26,6 +27,7 @@ export default async function handler(
|
||||
where: eq(compose.refreshToken, refreshToken as string),
|
||||
with: {
|
||||
project: true,
|
||||
bitbucket: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,16 +48,71 @@ export default async function handler(
|
||||
|
||||
if (sourceType === "github") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
const normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
composeResult.watchPaths,
|
||||
normalizedCommits,
|
||||
);
|
||||
|
||||
if (!shouldDeployPaths) {
|
||||
res.status(301).json({ message: "Watch Paths Not Match" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!branchName || branchName !== composeResult.branch) {
|
||||
res.status(301).json({ message: "Branch Not Match" });
|
||||
return;
|
||||
}
|
||||
} else if (sourceType === "gitlab") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
const normalizedCommits = req.body?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
composeResult.watchPaths,
|
||||
normalizedCommits,
|
||||
);
|
||||
|
||||
if (!shouldDeployPaths) {
|
||||
res.status(301).json({ message: "Watch Paths Not Match" });
|
||||
return;
|
||||
}
|
||||
if (!branchName || branchName !== composeResult.gitlabBranch) {
|
||||
res.status(301).json({ message: "Branch Not Match" });
|
||||
return;
|
||||
}
|
||||
} else if (sourceType === "bitbucket") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
if (!branchName || branchName !== composeResult.bitbucketBranch) {
|
||||
res.status(301).json({ message: "Branch Not Match" });
|
||||
return;
|
||||
}
|
||||
} else if (sourceType === "git") {
|
||||
const branchName = extractBranchName(req.headers, req.body);
|
||||
if (!branchName || branchName !== composeResult.customGitBranch) {
|
||||
res.status(301).json({ message: "Branch Not Match" });
|
||||
return;
|
||||
}
|
||||
|
||||
const commitedPaths = await extractCommitedPaths(
|
||||
req.body,
|
||||
composeResult.bitbucketOwner,
|
||||
composeResult.bitbucket?.appPassword || "",
|
||||
composeResult.bitbucketRepository || "",
|
||||
);
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
composeResult.watchPaths,
|
||||
commitedPaths,
|
||||
);
|
||||
|
||||
if (!shouldDeployPaths) {
|
||||
res.status(301).json({ message: "Watch Paths Not Match" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findPreviewDeploymentByApplicationId,
|
||||
findPreviewDeploymentsByPullRequestId,
|
||||
removePreviewDeployment,
|
||||
shouldDeploy,
|
||||
} from "@dokploy/server";
|
||||
import { Webhooks } from "@octokit/webhooks";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
@@ -95,6 +96,9 @@ export default async function handler(
|
||||
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
||||
const deploymentHash = extractHash(req.headers, req.body);
|
||||
const owner = githubBody?.repository?.owner?.name;
|
||||
const normalizedCommits = githubBody?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
|
||||
const apps = await db.query.applications.findMany({
|
||||
where: and(
|
||||
@@ -116,6 +120,15 @@ export default async function handler(
|
||||
server: !!app.serverId,
|
||||
};
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
app.watchPaths,
|
||||
normalizedCommits,
|
||||
);
|
||||
|
||||
if (!shouldDeployPaths) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IS_CLOUD && app.serverId) {
|
||||
jobData.serverId = app.serverId;
|
||||
await deploy(jobData);
|
||||
@@ -151,6 +164,14 @@ export default async function handler(
|
||||
server: !!composeApp.serverId,
|
||||
};
|
||||
|
||||
const shouldDeployPaths = shouldDeploy(
|
||||
composeApp.watchPaths,
|
||||
normalizedCommits,
|
||||
);
|
||||
|
||||
if (!shouldDeployPaths) {
|
||||
continue;
|
||||
}
|
||||
if (IS_CLOUD && composeApp.serverId) {
|
||||
jobData.serverId = composeApp.serverId;
|
||||
await deploy(jobData);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user