Compare commits

..

85 Commits

Author SHA1 Message Date
Mauricio Siu
2b7c7632f4 Merge pull request #136 from Dokploy/canary
v0.2.2
2024-06-08 22:06:39 -06:00
Mauricio Siu
7db662e332 chore: bump version 2024-06-08 21:17:55 -06:00
Mauricio Siu
cd1a686b59 refactor: update slugify compose 2024-06-08 18:40:34 -06:00
Mauricio Siu
78d573c4f3 Merge pull request #87 from hehehai/hehehai/feat-server-custom-name
feat: server support custom name
2024-06-08 16:49:15 -06:00
Mauricio Siu
0ef9b1427b chore: add slugify 2024-06-08 16:43:06 -06:00
Mauricio Siu
993d6b52f2 refactor: remove comments 2024-06-08 16:43:01 -06:00
Mauricio Siu
b9ab4a4d1a refactor: remove console log 2024-06-08 16:42:50 -06:00
Mauricio Siu
83153471b8 feat: add docker compose appName validation 2024-06-08 16:42:38 -06:00
Mauricio Siu
909e536f45 refactor: hide enviroment variables when viewing enviroment variables 2024-06-08 16:42:07 -06:00
Mauricio Siu
295cf50060 feat: add slug function 2024-06-08 16:41:46 -06:00
Mauricio Siu
dd16baf234 refactor: use slugify function 2024-06-08 16:41:38 -06:00
Mauricio Siu
72c366aa10 refactor: slugify and add hash to appName 2024-06-08 16:41:05 -06:00
Mauricio Siu
9a4f79f9e6 Merge branch 'canary' into hehehai/feat-server-custom-name 2024-06-08 14:37:51 -06:00
Mauricio Siu
30b81834fc Merge pull request #135 from Dokploy/130-how-to-make-dokploy-to-do-not-require-port-80-and-443-to-be-avilable
feat(#130): allow to pass enviroment variables to assign custom port …
2024-06-08 14:19:14 -06:00
Mauricio Siu
4e3aaa2a69 feat(#130): allow to pass enviroment variables to assign custom port on traefik 2024-06-08 14:13:43 -06:00
Mauricio Siu
3dcd89cc32 Merge pull request #128 from mariusihring/canary
Updated the database view to hide the password in the connection string
2024-06-08 14:05:16 -06:00
Mauricio Siu
41dc388bb0 refactor: update import 2024-06-08 13:40:15 -06:00
Mauricio Siu
44a592f7a7 refactor: simplify props 2024-06-08 13:36:02 -06:00
Mauricio Siu
1a4f5607dc refactor: add props to toggle and change colors of button toggle 2024-06-08 13:28:43 -06:00
Marius
d54c6e4ac9 feat: create input for toggleable inputs like passwords/connectinstrings 2024-06-08 11:55:05 +02:00
Marius
936cf76a4c fix: fix undefined error for database length 2024-06-07 13:04:30 +02:00
Marius
65254f1686 feat: Update the external database view to hide the password 2024-06-07 12:57:46 +02:00
Marius
b312b1d7e0 Updated the database view to hide the password in the connection string 2024-06-07 12:45:33 +02:00
Mauricio Siu
1b7244e841 Merge pull request #127 from Dokploy/canary
v0.2.1
2024-06-07 02:52:03 -06:00
Mauricio Siu
7eb1716d71 refactor: add docker config to improve UX 2024-06-07 01:27:21 -06:00
Mauricio Siu
b8a8e32659 refactor(#125): logout from docker when deleting the registry 2024-06-07 00:50:02 -06:00
Mauricio Siu
eea00d28cd refactor(#125): don't add the registry if the login already exists 2024-06-07 00:26:01 -06:00
Mauricio Siu
449a61e208 Merge branch 'main' into canary 2024-06-07 00:14:12 -06:00
Mauricio Siu
037f3ed118 chore: bump version 2024-06-07 00:11:22 -06:00
Mauricio Siu
471802c4da Merge pull request #126 from Dokploy/125-private-docker-image-with-docker-compose-issue
fix(#125): Use exec async and password stnd to automatically save in …
2024-06-07 00:11:04 -06:00
Mauricio Siu
502ff638d6 fix(#125): Use exec async and password stnd to automatically save in .docker/config.json 2024-06-07 00:06:11 -06:00
Mauricio Siu
750c12ded5 Merge pull request #122 from Dokploy/118-toast-indicates-success-even-for-failed-deployments
refacto(#118): remove last toaster on start deployments
2024-06-06 00:13:05 -06:00
Mauricio Siu
7fb34ade00 refacto(#118): remove last toaster on start deployments 2024-06-06 00:11:46 -06:00
Mauricio Siu
0c197c095b Merge pull request #121 from Dokploy/119-remove-none-as-protocol-option-for-ports
fix(#119): remove none ports
2024-06-05 22:49:12 -06:00
Mauricio Siu
4cbff731d1 fix(#119): remove none ports 2024-06-05 22:47:28 -06:00
Mr1Blaze
113df9ae12 Added New Tested Systems (#116)
Co-authored-by: Mr1Blaze <imarshall@pro.pro>
2024-06-03 20:52:37 -06:00
Mauricio Siu
7f13fd24ec v0.2.0 (#117)
* feat: add schema for registry and routes

* feat: add docker registry upload

* feat: add show cluster

* refactor: set the registry url in image in case we have a registry asociated

* feat: add update registry and fix the docker url markup

* chore: remove --advertise-ip on swarm script

* refactor: remove listen address of swarm initialize

* feat: add table to show nodes and add dropdown to add manager & workers

* refactor: improve interface for cluster

* refactor: improve UI

* feat: add experimental swarm settings

* refactor: remove comments

* refactor: prettify json of each setting

* refactor: add interface tooltip

* refactor: delete static form self registry

* refactor: allow to se a empty registry

* fix: remove text area warnings

* feat: add network swarm json

* refactor: update ui

* revert: go back to swarm init config

* refactor: remove initialization on server, only on setup script

* Update LICENSE.MD

* feat: appearance theme support system config

* refactor: remove logs

* fix(README-ru): hyperlink-ed docs url

* feat: (#107) webhook listener filter docker events based on image tag.

Fixes #107

* refactor: simplify comparison docker tags

* refactor: remove return in res status

* refactor: prevent to updates download automatically

* feat: support code editor (#105)

* feat: support code editor

* Update codeblock

* refactor: remove unused class

---------

Co-authored-by: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>

* fix: select the right image from sourcetype (#109)

* chore: bump minor version

* fix: add redirect to https by default (#113)

* Create FUNDING.yml

* Docker compose support (#111)

* feat(WIP): compose implementation

* feat: add volumes, networks, services name hash generate

* feat: add compose config test unique

* feat: add tests for each unique config

* feat: implement lodash for docker compose parsing

* feat: add tests for generating compose file

* refactor: implement logs docker compose

* refactor: composeFile set not empty

* feat: implement providers for compose deployments

* feat: add Files volumes to compose

* feat: add stop compose button

* refactor: change strategie of building compose

* feat: create .env file in composepath

* refactor: simplify git and github function

* chore: update deps

* refactor: update migrations and add badge to recognize compose type

* chore: update lock yaml

* refactor: use code editor

* feat: add monitoring for app types

* refactor: reset stats on change appName

* refactor: add option to clean monitoring folder

* feat: show current command that will run

* feat: add prefix

* fix: add missing types

* refactor: add docker provider and expose by default as false

* refactor: customize error page

* refactor: unified deployments to be a single one

* feat: add vitest to ci/cd

* revert: back to initial version

* refactor: add maxconcurrency vitest

* refactor: add pool forks to vitest

* feat: add pocketbase template

* fix: update path resolution compose

* removed

* feat: add template pocketbase

* feat: add pocketbase template

* feat: add support button

* feat: add plausible template

* feat: add calcom template

* feat: add version to each template

* feat: add code editor to enviroment variables and swarm settings json

* refactor: add loader when download the image

* fix: use base64 to generate keys plausible

* feat: add recognized domain names by enviroment compose

* refactor: show alert to redeploy in each card advanced tab

* refactor: add validation to prevent create compose if not have permissions

* chore: add templates section to contributing

* chore: add example contributing

* chore: add recomendation to show variables

* chore: add video to contributing templates

* chore: bump version

---------

Co-authored-by: hehehai <riverhohai@gmail.com>
Co-authored-by: Bayram Tagiev <bayram.tagiev.a@gmail.com>
Co-authored-by: Paulo Santana <30875229+hikinine@users.noreply.github.com>
2024-06-02 21:24:56 -06:00
Mauricio Siu
ce200185bb chore: bump version 2024-06-02 20:51:23 -06:00
Mauricio Siu
ff274d4f6b chore: add video to contributing templates 2024-06-02 20:47:24 -06:00
Mauricio Siu
306f02f5cd chore: add recomendation to show variables 2024-06-02 16:49:04 -06:00
Mauricio Siu
8f9d21c0f8 Docker compose support (#111)
* feat(WIP): compose implementation

* feat: add volumes, networks, services name hash generate

* feat: add compose config test unique

* feat: add tests for each unique config

* feat: implement lodash for docker compose parsing

* feat: add tests for generating compose file

* refactor: implement logs docker compose

* refactor: composeFile set not empty

* feat: implement providers for compose deployments

* feat: add Files volumes to compose

* feat: add stop compose button

* refactor: change strategie of building compose

* feat: create .env file in composepath

* refactor: simplify git and github function

* chore: update deps

* refactor: update migrations and add badge to recognize compose type

* chore: update lock yaml

* refactor: use code editor

* feat: add monitoring for app types

* refactor: reset stats on change appName

* refactor: add option to clean monitoring folder

* feat: show current command that will run

* feat: add prefix

* fix: add missing types

* refactor: add docker provider and expose by default as false

* refactor: customize error page

* refactor: unified deployments to be a single one

* feat: add vitest to ci/cd

* revert: back to initial version

* refactor: add maxconcurrency vitest

* refactor: add pool forks to vitest

* feat: add pocketbase template

* fix: update path resolution compose

* removed

* feat: add template pocketbase

* feat: add pocketbase template

* feat: add support button

* feat: add plausible template

* feat: add calcom template

* feat: add version to each template

* feat: add code editor to enviroment variables and swarm settings json

* refactor: add loader when download the image

* fix: use base64 to generate keys plausible

* feat: add recognized domain names by enviroment compose

* refactor: show alert to redeploy in each card advanced tab

* refactor: add validation to prevent create compose if not have permissions

* chore: add templates section to contributing

* chore: add example contributing
2024-06-02 15:26:28 -06:00
Mauricio Siu
1df6db738e Create FUNDING.yml 2024-05-31 00:07:56 -06:00
Mauricio Siu
36eb2edc76 Merge branch 'main' into canary 2024-05-29 23:36:43 -06:00
Mauricio Siu
a310065766 fix: add redirect to https by default (#113) 2024-05-29 23:33:33 -06:00
Mauricio Siu
71be1ed180 chore: bump minor version 2024-05-29 20:50:59 -06:00
Mauricio Siu
aa094e8472 fix: select the right image from sourcetype (#109) 2024-05-27 13:57:13 -06:00
木头981
4d3e3426ca feat: support code editor (#105)
* feat: support code editor

* Update codeblock

* refactor: remove unused class

---------

Co-authored-by: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
2024-05-26 15:32:24 -06:00
Mauricio Siu
ed6e4e8e73 Merge pull request #96 from Dokploy/feat/multi-node-support
Feat/multi node support
2024-05-26 03:13:00 -06:00
Mauricio Siu
b244aaa9de Merge branch 'canary' into feat/multi-node-support 2024-05-26 03:09:39 -06:00
Mauricio Siu
8a0ffbe754 refactor: prevent to updates download automatically 2024-05-26 03:06:31 -06:00
Mauricio Siu
d34aadde43 Merge pull request #108 from hikinine/Feat/#107/Webhook-listener-apply-filter-rules-for-docker-image
feat: (#107) webhook listener filter docker events based on image tag.
2024-05-25 16:18:54 -06:00
Mauricio Siu
e174101377 refactor: remove return in res status 2024-05-25 16:17:34 -06:00
Mauricio Siu
eef0bf6ff7 refactor: simplify comparison docker tags 2024-05-25 16:13:40 -06:00
Paulo Santana
41bdfdf78f feat: (#107) webhook listener filter docker events based on image tag.
Fixes #107
2024-05-25 09:43:49 -03:00
Mauricio Siu
874d1f9fe4 Merge pull request #106 from bayram-dev/canary
fix(README-ru): hyperlink-ed docs url
2024-05-24 15:10:12 -06:00
Bayram Tagiev
0867bc20bd fix(README-ru): hyperlink-ed docs url 2024-05-24 11:36:15 +03:00
hehehai
fae180f157 feat: server support custom name 2024-05-24 15:43:05 +08:00
Mauricio Siu
bf2516a495 Merge pull request #104 from hehehai/hehehai/appearance-theme-system
feat: appearance theme support system config
2024-05-23 22:06:10 -06:00
Mauricio Siu
ec54c79e80 refactor: remove logs 2024-05-23 22:02:43 -06:00
hehehai
422187cd4b feat: appearance theme support system config 2024-05-23 17:41:24 +08:00
Mauricio Siu
2362130927 Update LICENSE.MD 2024-05-23 02:15:40 -06:00
Mauricio Siu
0f025182f1 refactor: remove initialization on server, only on setup script 2024-05-21 22:26:33 -06:00
Mauricio Siu
3a59edbc0f revert: go back to swarm init config 2024-05-21 22:26:17 -06:00
Mauricio Siu
5f42bf63a6 refactor: update ui 2024-05-20 13:51:53 -06:00
Mauricio Siu
baecc49d86 feat: add network swarm json 2024-05-18 17:59:04 -06:00
Mauricio Siu
506fe074df fix: remove text area warnings 2024-05-18 17:37:49 -06:00
Mauricio Siu
7961591009 refactor: allow to se a empty registry 2024-05-18 17:37:35 -06:00
Mauricio Siu
f784a4f989 refactor: delete static form self registry 2024-05-18 17:24:42 -06:00
Mauricio Siu
957e5066aa Merge branch 'canary' into feat/multi-node-support 2024-05-18 15:34:30 -06:00
Mauricio Siu
bf133536ba refactor: add interface tooltip 2024-05-18 14:55:59 -06:00
Mauricio Siu
683a62f418 refactor: prettify json of each setting 2024-05-18 14:38:55 -06:00
Mauricio Siu
5a70e616e6 refactor: remove comments 2024-05-18 03:08:30 -06:00
Mauricio Siu
d52f66a716 feat: add experimental swarm settings 2024-05-18 03:07:51 -06:00
Mauricio Siu
5806068e2e refactor: improve UI 2024-05-17 23:43:07 -06:00
Mauricio Siu
667067811c refactor: improve interface for cluster 2024-05-17 22:59:31 -06:00
Mauricio Siu
eda33e095e Merge branch 'canary' into feat/multi-node-support 2024-05-17 21:43:54 -06:00
Mauricio Siu
976d1f312f feat: add table to show nodes and add dropdown to add manager & workers 2024-05-17 02:56:50 -06:00
Mauricio Siu
42e9aa1834 refactor: remove listen address of swarm initialize 2024-05-16 22:09:40 -06:00
Mauricio Siu
744f800700 chore: remove --advertise-ip on swarm script 2024-05-16 01:55:37 -06:00
Mauricio Siu
08517d6f36 feat: add update registry and fix the docker url markup 2024-05-14 03:09:07 -06:00
Mauricio Siu
d19dec8010 refactor: set the registry url in image in case we have a registry asociated 2024-05-13 03:15:09 -06:00
Mauricio Siu
c45017e204 feat: add show cluster 2024-05-13 01:28:50 -06:00
Mauricio Siu
6c792564ae feat: add docker registry upload 2024-05-13 01:18:27 -06:00
Mauricio Siu
e9245cee2c Merge branch 'canary' into feat/multi-node-support 2024-05-12 15:40:07 -06:00
Mauricio Siu
832fc184af feat: add schema for registry and routes 2024-05-03 12:27:06 -06:00
187 changed files with 17306 additions and 1641 deletions

13
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: #
open_collective: dokploy
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -30,6 +30,8 @@ jobs:
run: pnpm install
- name: Run Build
run: pnpm build
- name: Run Tests
run: pnpm run test
build-and-push-docker-on-push:
if: github.event_name == 'push'

1
.gitignore vendored
View File

@@ -55,3 +55,4 @@ yarn-error.log*
*.lockb
*.rdb
.idea

View File

@@ -151,3 +151,92 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.
- 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.

View File

@@ -39,10 +39,17 @@ curl -sSL https://dokploy.com/install.sh | sh
Getestete Systems:
- Ubuntu 20.04
- Ubuntu 24.04 LTS (Noble Numbat)
- Ubuntu 23.10 (Mantic Minotaur)
- Ubuntu 22.04 LTS (Jammy Jellyfish)
- Ubuntu 20.04 LTS (Focal Fossa)
- Ubuntu 18.04 LTS (Bionic Beaver)
- Debian 12
- Debian 11
- Fedora 40
- Centos 9
- Centos 8
## 📄 Dokumentation

View File

@@ -40,10 +40,17 @@ curl -sSL https://dokploy.com/install.sh | sh
Проверенные системы:
- Ubuntu 20.04
- Ubuntu 24.04 LTS (Noble Numbat)
- Ubuntu 23.10 (Mantic Minotaur)
- Ubuntu 22.04 LTS (Jammy Jellyfish)
- Ubuntu 20.04 LTS (Focal Fossa)
- Ubuntu 18.04 LTS (Bionic Beaver)
- Debian 12
- Debian 11
- Fedora 40
- Centos 9
- Centos 8
## 📄 Документация
Для подробной документации посетите [docs.dokploy.com/docs](https://docs.dokploy.com).

View File

@@ -43,10 +43,17 @@ curl -sSL https://dokploy.com/install.sh | sh
经过测试的系统:
- Ubuntu 20.04
- Ubuntu 24.04 LTS (Noble Numbat)
- Ubuntu 23.10 (Mantic Minotaur)
- Ubuntu 22.04 LTS (Jammy Jellyfish)
- Ubuntu 20.04 LTS (Focal Fossa)
- Ubuntu 18.04 LTS (Bionic Beaver)
- Debian 12
- Debian 11
- Fedora 40
- Centos 9
- Centos 8
## 📄 文档

View File

@@ -42,10 +42,16 @@ curl -sSL https://dokploy.com/install.sh | sh
Tested Systems:
- Ubuntu 20.04
- Ubuntu 24.04 LTS (Noble Numbat)
- Ubuntu 23.10 (Mantic Minotaur)
- Ubuntu 22.04 LTS (Jammy Jellyfish)
- Ubuntu 20.04 LTS (Focal Fossa)
- Ubuntu 18.04 LTS (Bionic Beaver)
- Debian 12
- Debian 11
- Fedora 40
- Centos 9
- Centos 8
## 📄 Documentation

View File

@@ -0,0 +1,476 @@
import { expect, test } from "vitest";
import { load } from "js-yaml";
import { addPrefixToAllProperties } from "@/server/utils/docker/compose";
import type { ComposeSpecification } from "@/server/utils/docker/types";
const composeFile1 = `
version: "3.8"
services:
web:
image: nginx:latest
container_name: web_container
depends_on:
- app
networks:
- frontend
volumes_from:
- data
links:
- db
extends:
service: base_service
configs:
- source: web_config
app:
image: node:14
networks:
- backend
- frontend
db:
image: postgres:13
networks:
- backend
data:
image: busybox
volumes:
- /data
base_service:
image: base:latest
networks:
frontend:
driver: bridge
backend:
driver: bridge
volumes:
web_data:
driver: local
configs:
web_config:
file: ./web_config.yml
secrets:
db_password:
file: ./db_password.txt
`;
const expectedComposeFile1 = load(`
version: "3.8"
services:
web-testhash:
image: nginx:latest
container_name: web_container-testhash
depends_on:
- app-testhash
networks:
- frontend-testhash
volumes_from:
- data-testhash
links:
- db-testhash
extends:
service: base_service-testhash
configs:
- source: web_config-testhash
app-testhash:
image: node:14
networks:
- backend-testhash
- frontend-testhash
db-testhash:
image: postgres:13
networks:
- backend-testhash
data-testhash:
image: busybox
volumes:
- /data
base_service-testhash:
image: base:latest
networks:
frontend-testhash:
driver: bridge
backend-testhash:
driver: bridge
volumes:
web_data-testhash:
driver: local
configs:
web_config-testhash:
file: ./web_config.yml
secrets:
db_password-testhash:
file: ./db_password.txt
`) as ComposeSpecification;
test("Add prefix to all properties in compose file 1", () => {
const composeData = load(composeFile1) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllProperties(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFile1);
});
const composeFile2 = `
version: "3.8"
services:
frontend:
image: nginx:latest
depends_on:
- backend
networks:
- public
volumes_from:
- logs
links:
- cache
extends:
service: shared_service
secrets:
- db_password
backend:
image: node:14
networks:
- private
- public
cache:
image: redis:latest
networks:
- private
logs:
image: busybox
volumes:
- /logs
shared_service:
image: shared:latest
networks:
public:
driver: bridge
private:
driver: bridge
volumes:
logs:
driver: local
configs:
app_config:
file: ./app_config.yml
secrets:
db_password:
file: ./db_password.txt
`;
const expectedComposeFile2 = load(`
version: "3.8"
services:
frontend-testhash:
image: nginx:latest
depends_on:
- backend-testhash
networks:
- public-testhash
volumes_from:
- logs-testhash
links:
- cache-testhash
extends:
service: shared_service-testhash
secrets:
- db_password-testhash
backend-testhash:
image: node:14
networks:
- private-testhash
- public-testhash
cache-testhash:
image: redis:latest
networks:
- private-testhash
logs-testhash:
image: busybox
volumes:
- /logs
shared_service-testhash:
image: shared:latest
networks:
public-testhash:
driver: bridge
private-testhash:
driver: bridge
volumes:
logs-testhash:
driver: local
configs:
app_config-testhash:
file: ./app_config.yml
secrets:
db_password-testhash:
file: ./db_password.txt
`) as ComposeSpecification;
test("Add prefix to all properties in compose file 2", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllProperties(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFile2);
});
const composeFile3 = `
version: "3.8"
services:
service_a:
image: service_a:latest
depends_on:
- service_b
networks:
- net_a
volumes_from:
- data_volume
links:
- service_c
extends:
service: common_service
configs:
- source: service_a_config
service_b:
image: service_b:latest
networks:
- net_b
- net_a
service_c:
image: service_c:latest
networks:
- net_b
data_volume:
image: busybox
volumes:
- /data
common_service:
image: common:latest
networks:
net_a:
driver: bridge
net_b:
driver: bridge
volumes:
data_volume:
driver: local
configs:
service_a_config:
file: ./service_a_config.yml
secrets:
service_secret:
file: ./service_secret.txt
`;
const expectedComposeFile3 = load(`
version: "3.8"
services:
service_a-testhash:
image: service_a:latest
depends_on:
- service_b-testhash
networks:
- net_a-testhash
volumes_from:
- data_volume-testhash
links:
- service_c-testhash
extends:
service: common_service-testhash
configs:
- source: service_a_config-testhash
service_b-testhash:
image: service_b:latest
networks:
- net_b-testhash
- net_a-testhash
service_c-testhash:
image: service_c:latest
networks:
- net_b-testhash
data_volume-testhash:
image: busybox
volumes:
- /data
common_service-testhash:
image: common:latest
networks:
net_a-testhash:
driver: bridge
net_b-testhash:
driver: bridge
volumes:
data_volume-testhash:
driver: local
configs:
service_a_config-testhash:
file: ./service_a_config.yml
secrets:
service_secret-testhash:
file: ./service_secret.txt
`) as ComposeSpecification;
test("Add prefix to all properties in compose file 3", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllProperties(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFile3);
});
const composeFile = `
version: "3.8"
services:
plausible_db:
image: postgres:16-alpine
restart: always
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=postgres
plausible_events_db:
image: clickhouse/clickhouse-server:24.3.3.102-alpine
restart: always
volumes:
- event-data:/var/lib/clickhouse
- event-logs:/var/log/clickhouse-server
- ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro
- ./clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro
ulimits:
nofile:
soft: 262144
hard: 262144
plausible:
image: ghcr.io/plausible/community-edition:v2.1.0
restart: always
command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run"
depends_on:
- plausible_db
- plausible_events_db
ports:
- 127.0.0.1:8000:8000
env_file:
- plausible-conf.env
volumes:
db-data:
driver: local
event-data:
driver: local
event-logs:
driver: local
`;
const expectedComposeFile = load(`
version: "3.8"
services:
plausible_db-testhash:
image: postgres:16-alpine
restart: always
volumes:
- db-data-testhash:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=postgres
plausible_events_db-testhash:
image: clickhouse/clickhouse-server:24.3.3.102-alpine
restart: always
volumes:
- event-data-testhash:/var/lib/clickhouse
- event-logs-testhash:/var/log/clickhouse-server
- ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro
- ./clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro
ulimits:
nofile:
soft: 262144
hard: 262144
plausible-testhash:
image: ghcr.io/plausible/community-edition:v2.1.0
restart: always
command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run"
depends_on:
- plausible_db-testhash
- plausible_events_db-testhash
ports:
- 127.0.0.1:8000:8000
env_file:
- plausible-conf.env
volumes:
db-data-testhash:
driver: local
event-data-testhash:
driver: local
event-logs-testhash:
driver: local
`) as ComposeSpecification;
test("Add prefix to all properties in Plausible compose file", () => {
const composeData = load(composeFile) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllProperties(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFile);
});

View File

@@ -0,0 +1,178 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToConfigsRoot } from "@/server/utils/docker/compose/configs";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
const composeFile = `
version: "3.8"
services:
web:
image: nginx:latest
configs:
web-config:
file: ./web-config.yml
`;
test("Add prefix to configs in root property", () => {
const composeData = load(composeFile) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.configs) {
return;
}
const configs = addPrefixToConfigsRoot(composeData.configs, prefix);
expect(configs).toBeDefined();
for (const configKey of Object.keys(configs)) {
expect(configKey).toContain(`-${prefix}`);
expect(configs[configKey]).toBeDefined();
}
});
const composeFileMultipleConfigs = `
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web-config
target: /etc/nginx/nginx.conf
- source: another-config
target: /etc/nginx/another.conf
configs:
web-config:
file: ./web-config.yml
another-config:
file: ./another-config.yml
`;
test("Add prefix to multiple configs in root property", () => {
const composeData = load(composeFileMultipleConfigs) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.configs) {
return;
}
const configs = addPrefixToConfigsRoot(composeData.configs, prefix);
expect(configs).toBeDefined();
for (const configKey of Object.keys(configs)) {
expect(configKey).toContain(`-${prefix}`);
expect(configs[configKey]).toBeDefined();
}
expect(configs).toHaveProperty(`web-config-${prefix}`);
expect(configs).toHaveProperty(`another-config-${prefix}`);
});
const composeFileDifferentProperties = `
version: "3.8"
services:
web:
image: nginx:latest
configs:
web-config:
file: ./web-config.yml
special-config:
external: true
`;
test("Add prefix to configs with different properties in root property", () => {
const composeData = load(
composeFileDifferentProperties,
) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.configs) {
return;
}
const configs = addPrefixToConfigsRoot(composeData.configs, prefix);
expect(configs).toBeDefined();
for (const configKey of Object.keys(configs)) {
expect(configKey).toContain(`-${prefix}`);
expect(configs[configKey]).toBeDefined();
}
expect(configs).toHaveProperty(`web-config-${prefix}`);
expect(configs).toHaveProperty(`special-config-${prefix}`);
});
const composeFileConfigRoot = `
version: "3.8"
services:
web:
image: nginx:latest
app:
image: node:latest
db:
image: postgres:latest
configs:
web_config:
file: ./web-config.yml
app_config:
file: ./app-config.json
db_config:
file: ./db-config.yml
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigRoot = load(`
version: "3.8"
services:
web:
image: nginx:latest
app:
image: node:latest
db:
image: postgres:latest
configs:
web_config-testhash:
file: ./web-config.yml
app_config-testhash:
file: ./app-config.json
db_config-testhash:
file: ./db-config.yml
`) as ComposeSpecification;
test("Add prefix to configs in root property", () => {
const composeData = load(composeFileConfigRoot) as ComposeSpecification;
const prefix = "testhash";
if (!composeData?.configs) {
return;
}
const configs = addPrefixToConfigsRoot(composeData.configs, prefix);
const updatedComposeData = { ...composeData, configs };
// Verificar que el resultado coincide con el archivo esperado
expect(updatedComposeData).toEqual(expectedComposeFileConfigRoot);
});

View File

@@ -0,0 +1,197 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToConfigsInServices } from "@/server/utils/docker/compose/configs";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
const composeFile = `
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web-config
target: /etc/nginx/nginx.conf
configs:
web-config:
file: ./web-config.yml
`;
test("Add prefix to configs in services", () => {
const composeData = load(composeFile) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.services) {
return;
}
const services = addPrefixToConfigsInServices(composeData.services, prefix);
const actualComposeData = { ...composeData, services };
expect(actualComposeData.services?.web?.configs).toContainEqual({
source: `web-config-${prefix}`,
target: "/etc/nginx/nginx.conf",
});
});
const composeFileSingleServiceConfig = `
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web-config
target: /etc/nginx/nginx.conf
configs:
web-config:
file: ./web-config.yml
`;
test("Add prefix to configs in services with single config", () => {
const composeData = load(
composeFileSingleServiceConfig,
) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.services) {
return;
}
const services = addPrefixToConfigsInServices(composeData.services, prefix);
expect(services).toBeDefined();
for (const serviceKey of Object.keys(services)) {
const serviceConfigs = services?.[serviceKey]?.configs;
if (serviceConfigs) {
for (const config of serviceConfigs) {
if (typeof config === "object") {
expect(config.source).toContain(`-${prefix}`);
}
}
}
}
});
const composeFileMultipleServicesConfigs = `
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web-config
target: /etc/nginx/nginx.conf
- source: common-config
target: /etc/nginx/common.conf
app:
image: node:14
configs:
- source: app-config
target: /usr/src/app/config.json
- source: common-config
target: /usr/src/app/common.json
configs:
web-config:
file: ./web-config.yml
app-config:
file: ./app-config.json
common-config:
file: ./common-config.yml
`;
test("Add prefix to configs in services with multiple configs", () => {
const composeData = load(
composeFileMultipleServicesConfigs,
) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.services) {
return;
}
const services = addPrefixToConfigsInServices(composeData.services, prefix);
expect(services).toBeDefined();
for (const serviceKey of Object.keys(services)) {
const serviceConfigs = services?.[serviceKey]?.configs;
if (serviceConfigs) {
for (const config of serviceConfigs) {
if (typeof config === "object") {
expect(config.source).toContain(`-${prefix}`);
}
}
}
}
});
const composeFileConfigServices = `
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web_config
target: /etc/nginx/nginx.conf
app:
image: node:latest
configs:
- source: app_config
target: /usr/src/app/config.json
db:
image: postgres:latest
configs:
- source: db_config
target: /etc/postgresql/postgresql.conf
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigServices = load(`
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web_config-testhash
target: /etc/nginx/nginx.conf
app:
image: node:latest
configs:
- source: app_config-testhash
target: /usr/src/app/config.json
db:
image: postgres:latest
configs:
- source: db_config-testhash
target: /etc/postgresql/postgresql.conf
`) as ComposeSpecification;
test("Add prefix to configs in services", () => {
const composeData = load(composeFileConfigServices) as ComposeSpecification;
const prefix = "testhash";
if (!composeData?.services) {
return;
}
const updatedComposeData = addPrefixToConfigsInServices(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
expect(actualComposeData).toEqual(expectedComposeFileConfigServices);
});

View File

@@ -0,0 +1,249 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import {
addPrefixToAllConfigs,
addPrefixToConfigsRoot,
} from "@/server/utils/docker/compose/configs";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
const composeFileCombinedConfigs = `
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web_config
target: /etc/nginx/nginx.conf
app:
image: node:14
configs:
- source: app_config
target: /usr/src/app/config.json
db:
image: postgres:13
configs:
- source: db_config
target: /etc/postgresql/postgresql.conf
configs:
web_config:
file: ./web-config.yml
app_config:
file: ./app-config.json
db_config:
file: ./db-config.yml
`;
const expectedComposeFileCombinedConfigs = load(`
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web_config-testhash
target: /etc/nginx/nginx.conf
app:
image: node:14
configs:
- source: app_config-testhash
target: /usr/src/app/config.json
db:
image: postgres:13
configs:
- source: db_config-testhash
target: /etc/postgresql/postgresql.conf
configs:
web_config-testhash:
file: ./web-config.yml
app_config-testhash:
file: ./app-config.json
db_config-testhash:
file: ./db-config.yml
`) as ComposeSpecification;
test("Add prefix to all configs in root and services", () => {
const composeData = load(composeFileCombinedConfigs) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllConfigs(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFileCombinedConfigs);
});
const composeFileWithEnvAndExternal = `
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web_config
target: /etc/nginx/nginx.conf
environment:
- NGINX_CONFIG=/etc/nginx/nginx.conf
app:
image: node:14
configs:
- source: app_config
target: /usr/src/app/config.json
db:
image: postgres:13
configs:
- source: db_config
target: /etc/postgresql/postgresql.conf
configs:
web_config:
external: true
app_config:
file: ./app-config.json
db_config:
environment: dev
file: ./db-config.yml
`;
const expectedComposeFileWithEnvAndExternal = load(`
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web_config-testhash
target: /etc/nginx/nginx.conf
environment:
- NGINX_CONFIG=/etc/nginx/nginx.conf
app:
image: node:14
configs:
- source: app_config-testhash
target: /usr/src/app/config.json
db:
image: postgres:13
configs:
- source: db_config-testhash
target: /etc/postgresql/postgresql.conf
configs:
web_config-testhash:
external: true
app_config-testhash:
file: ./app-config.json
db_config-testhash:
environment: dev
file: ./db-config.yml
`) as ComposeSpecification;
test("Add prefix to configs with environment and external", () => {
const composeData = load(
composeFileWithEnvAndExternal,
) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllConfigs(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFileWithEnvAndExternal);
});
const composeFileWithTemplateDriverAndLabels = `
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web_config
target: /etc/nginx/nginx.conf
app:
image: node:14
configs:
- source: app_config
target: /usr/src/app/config.json
configs:
web_config:
file: ./web-config.yml
template_driver: golang
app_config:
file: ./app-config.json
labels:
- app=frontend
db_config:
file: ./db-config.yml
`;
const expectedComposeFileWithTemplateDriverAndLabels = load(`
version: "3.8"
services:
web:
image: nginx:latest
configs:
- source: web_config-testhash
target: /etc/nginx/nginx.conf
app:
image: node:14
configs:
- source: app_config-testhash
target: /usr/src/app/config.json
configs:
web_config-testhash:
file: ./web-config.yml
template_driver: golang
app_config-testhash:
file: ./app-config.json
labels:
- app=frontend
db_config-testhash:
file: ./db-config.yml
`) as ComposeSpecification;
test("Add prefix to configs with template driver and labels", () => {
const composeData = load(
composeFileWithTemplateDriverAndLabels,
) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllConfigs(composeData, prefix);
expect(updatedComposeData).toEqual(
expectedComposeFileWithTemplateDriverAndLabels,
);
});

View File

@@ -0,0 +1,281 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToNetworksRoot } from "@/server/utils/docker/compose/network";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
const composeFile = `
version: "3.8"
services:
web:
image: nginx:latest
networks:
- frontend
networks:
frontend:
driver: bridge
driver_opts:
com.docker.network.driver.mtu: 1200
backend:
driver: bridge
attachable: true
external_network:
external: true
`;
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
test("Add prefix to networks root property", () => {
const composeData = load(composeFile) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.networks) {
return;
}
const networks = addPrefixToNetworksRoot(composeData.networks, prefix);
expect(networks).toBeDefined();
for (const volumeKey of Object.keys(networks)) {
expect(volumeKey).toContain(`-${prefix}`);
}
});
const composeFile2 = `
version: "3.8"
services:
app:
image: myapp:latest
networks:
- app_net
networks:
app_net:
driver: bridge
driver_opts:
com.docker.network.driver.mtu: 1500
ipam:
driver: default
config:
- subnet: 172.20.0.0/16
database_net:
driver: overlay
attachable: true
monitoring_net:
driver: bridge
internal: true
`;
test("Add prefix to advanced networks root property (2 TRY)", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.networks) {
return;
}
const networks = addPrefixToNetworksRoot(composeData.networks, prefix);
expect(networks).toBeDefined();
for (const networkKey of Object.keys(networks)) {
expect(networkKey).toContain(`-${prefix}`);
}
});
const composeFile3 = `
version: "3.8"
services:
web:
image: nginx:latest
networks:
- frontend
- backend
networks:
frontend:
external:
name: my_external_network
backend:
driver: bridge
labels:
- "com.example.description=Backend network"
- "com.example.environment=production"
external_network:
external: true
`;
test("Add prefix to networks with external properties", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.networks) {
return;
}
const networks = addPrefixToNetworksRoot(composeData.networks, prefix);
expect(networks).toBeDefined();
for (const networkKey of Object.keys(networks)) {
expect(networkKey).toContain(`-${prefix}`);
}
});
const composeFile4 = `
version: "3.8"
services:
db:
image: postgres:13
networks:
- db_net
networks:
db_net:
driver: bridge
ipam:
config:
- subnet: 192.168.1.0/24
- gateway: 192.168.1.1
- aux_addresses:
host1: 192.168.1.2
host2: 192.168.1.3
external_network:
external: true
`;
test("Add prefix to networks with IPAM configurations", () => {
const composeData = load(composeFile4) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.networks) {
return;
}
const networks = addPrefixToNetworksRoot(composeData.networks, prefix);
expect(networks).toBeDefined();
for (const networkKey of Object.keys(networks)) {
expect(networkKey).toContain(`-${prefix}`);
}
});
const composeFile5 = `
version: "3.8"
services:
api:
image: myapi:latest
networks:
- api_net
networks:
api_net:
driver: bridge
options:
com.docker.network.bridge.name: br0
enable_ipv6: true
ipam:
driver: default
config:
- subnet: "2001:db8:1::/64"
- gateway: "2001:db8:1::1"
external_network:
external: true
`;
test("Add prefix to networks with custom options", () => {
const composeData = load(composeFile5) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.networks) {
return;
}
const networks = addPrefixToNetworksRoot(composeData.networks, prefix);
expect(networks).toBeDefined();
for (const networkKey of Object.keys(networks)) {
expect(networkKey).toContain(`-${prefix}`);
}
});
const composeFile6 = `
version: "3.8"
services:
web:
image: nginx:latest
networks:
- frontend
networks:
frontend:
driver: bridge
driver_opts:
com.docker.network.driver.mtu: 1200
backend:
driver: bridge
attachable: true
external_network:
external: true
`;
// Expected compose file with static prefix `testhash`
const expectedComposeFile6 = `
version: "3.8"
services:
web:
image: nginx:latest
networks:
- frontend-testhash
networks:
frontend-testhash:
driver: bridge
driver_opts:
com.docker.network.driver.mtu: 1200
backend-testhash:
driver: bridge
attachable: true
external_network-testhash:
external: true
`;
test("Add prefix to networks with static prefix", () => {
const composeData = load(composeFile6) as ComposeSpecification;
const prefix = "testhash";
if (!composeData?.networks) {
return;
}
const networks = addPrefixToNetworksRoot(composeData.networks, prefix);
const expectedComposeData = load(
expectedComposeFile6,
) as ComposeSpecification;
expect(networks).toStrictEqual(expectedComposeData.networks);
});

View File

@@ -0,0 +1,181 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToServiceNetworks } from "@/server/utils/docker/compose/network";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
const composeFile = `
version: "3.8"
services:
web:
image: nginx:latest
networks:
- frontend
- backend
api:
image: myapi:latest
networks:
- backend
`;
test("Add prefix to networks in services", () => {
const composeData = load(composeFile) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.services) {
return;
}
const services = addPrefixToServiceNetworks(composeData.services, prefix);
const actualComposeData = { ...composeData, services };
expect(actualComposeData?.services?.web?.networks).toContain(
`frontend-${prefix}`,
);
expect(actualComposeData?.services?.api?.networks).toContain(
`backend-${prefix}`,
);
const apiNetworks = actualComposeData?.services?.api?.networks;
expect(apiNetworks).toBeDefined();
expect(actualComposeData?.services?.api?.networks).toContain(
`backend-${prefix}`,
);
});
// Caso 2: Objeto con aliases
const composeFile2 = `
version: "3.8"
services:
api:
image: myapi:latest
networks:
frontend:
aliases:
- api
networks:
frontend:
driver: bridge
`;
test("Add prefix to networks in services with aliases", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.services) {
return;
}
const services = addPrefixToServiceNetworks(composeData.services, prefix);
const actualComposeData = { ...composeData, services };
expect(actualComposeData.services?.api?.networks).toHaveProperty(
`frontend-${prefix}`,
);
const networkConfig =
actualComposeData?.services?.api?.networks[`frontend-${prefix}`];
expect(networkConfig).toBeDefined();
expect(networkConfig?.aliases).toContain("api");
expect(actualComposeData.services?.api?.networks).not.toHaveProperty(
"frontend-ash",
);
});
const composeFile3 = `
version: "3.8"
services:
redis:
image: redis:alpine
networks:
backend:
networks:
backend:
driver: bridge
`;
test("Add prefix to networks in services (Object with simple networks)", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.services) {
return;
}
const services = addPrefixToServiceNetworks(composeData.services, prefix);
const actualComposeData = { ...composeData, services };
expect(actualComposeData.services?.redis?.networks).toHaveProperty(
`backend-${prefix}`,
);
});
const composeFileCombined = `
version: "3.8"
services:
web:
image: nginx:latest
networks:
- frontend
- backend
api:
image: myapi:latest
networks:
frontend:
aliases:
- api
redis:
image: redis:alpine
networks:
backend:
networks:
frontend:
driver: bridge
backend:
driver: bridge
`;
test("Add prefix to networks in services (combined case)", () => {
const composeData = load(composeFileCombined) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.services) {
return;
}
const services = addPrefixToServiceNetworks(composeData.services, prefix);
const actualComposeData = { ...composeData, services };
// Caso 1: ListOfStrings
expect(actualComposeData.services?.web?.networks).toContain(
`frontend-${prefix}`,
);
expect(actualComposeData.services?.web?.networks).toContain(
`backend-${prefix}`,
);
// Caso 2: Objeto con aliases
const apiNetworks = actualComposeData.services?.api?.networks;
expect(apiNetworks).toHaveProperty(`frontend-${prefix}`);
expect(apiNetworks[`frontend-${prefix}`]).toBeDefined();
expect(apiNetworks).not.toHaveProperty("frontend");
// Caso 3: Objeto con redes simples
const redisNetworks = actualComposeData.services?.redis?.networks;
expect(redisNetworks).toHaveProperty(`backend-${prefix}`);
expect(redisNetworks).not.toHaveProperty("backend");
});

View File

@@ -0,0 +1,254 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import {
addPrefixToAllNetworks,
addPrefixToServiceNetworks,
} from "@/server/utils/docker/compose/network";
import { addPrefixToNetworksRoot } from "@/server/utils/docker/compose/network";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
const composeFileCombined = `
version: "3.8"
services:
web:
image: nginx:latest
networks:
- frontend
- backend
api:
image: myapi:latest
networks:
frontend:
aliases:
- api
redis:
image: redis:alpine
networks:
backend:
networks:
frontend:
driver: bridge
backend:
driver: bridge
`;
test("Add prefix to networks in services and root (combined case)", () => {
const composeData = load(composeFileCombined) as ComposeSpecification;
const prefix = generateRandomHash();
// Prefijo para redes definidas en el root
if (composeData.networks) {
composeData.networks = addPrefixToNetworksRoot(
composeData.networks,
prefix,
);
}
// Prefijo para redes definidas en los servicios
if (composeData.services) {
composeData.services = addPrefixToServiceNetworks(
composeData.services,
prefix,
);
}
const actualComposeData = { ...composeData };
// Verificar redes en root
expect(actualComposeData.networks).toHaveProperty(`frontend-${prefix}`);
expect(actualComposeData.networks).toHaveProperty(`backend-${prefix}`);
expect(actualComposeData.networks).not.toHaveProperty("frontend");
expect(actualComposeData.networks).not.toHaveProperty("backend");
// Caso 1: ListOfStrings
expect(actualComposeData.services?.web?.networks).toContain(
`frontend-${prefix}`,
);
expect(actualComposeData.services?.web?.networks).toContain(
`backend-${prefix}`,
);
// Caso 2: Objeto con aliases
const apiNetworks = actualComposeData.services?.api?.networks;
expect(apiNetworks).toHaveProperty(`frontend-${prefix}`);
expect(apiNetworks[`frontend-${prefix}`]?.aliases).toContain("api");
expect(apiNetworks).not.toHaveProperty("frontend");
// Caso 3: Objeto con redes simples
const redisNetworks = actualComposeData.services?.redis?.networks;
expect(redisNetworks).toHaveProperty(`backend-${prefix}`);
expect(redisNetworks).not.toHaveProperty("backend");
});
const expectedComposeFile = load(`
version: "3.8"
services:
web:
image: nginx:latest
networks:
- frontend-testhash
- backend-testhash
api:
image: myapi:latest
networks:
frontend-testhash:
aliases:
- api
redis:
image: redis:alpine
networks:
backend-testhash:
networks:
frontend-testhash:
driver: bridge
backend-testhash:
driver: bridge
`);
test("Add prefix to networks in compose file", () => {
const composeData = load(composeFileCombined) as ComposeSpecification;
const prefix = "testhash";
if (!composeData?.networks) {
return;
}
const updatedComposeData = addPrefixToAllNetworks(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFile);
});
const composeFile2 = `
version: "3.8"
services:
web:
image: nginx:latest
networks:
- frontend
- backend
db:
image: postgres:latest
networks:
backend:
aliases:
- db
networks:
frontend:
external: true
backend:
driver: bridge
`;
const expectedComposeFile2 = load(`
version: "3.8"
services:
web:
image: nginx:latest
networks:
- frontend-testhash
- backend-testhash
db:
image: postgres:latest
networks:
backend-testhash:
aliases:
- db
networks:
frontend-testhash:
external: true
backend-testhash:
driver: bridge
`);
test("Add prefix to networks in compose file with external and internal networks", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllNetworks(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFile2);
});
const composeFile3 = `
version: "3.8"
services:
app:
image: myapp:latest
networks:
frontend:
aliases:
- app
backend:
worker:
image: worker:latest
networks:
- backend
networks:
frontend:
driver: bridge
attachable: true
backend:
driver: bridge
driver_opts:
com.docker.network.bridge.enable_icc: "true"
`;
const expectedComposeFile3 = load(`
version: "3.8"
services:
app:
image: myapp:latest
networks:
frontend-testhash:
aliases:
- app
backend-testhash:
worker:
image: worker:latest
networks:
- backend-testhash
networks:
frontend-testhash:
driver: bridge
attachable: true
backend-testhash:
driver: bridge
driver_opts:
com.docker.network.bridge.enable_icc: "true"
`);
test("Add prefix to networks in compose file with multiple services and complex network configurations", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllNetworks(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFile3);
});

View File

@@ -0,0 +1,103 @@
import { expect, test } from "vitest";
import { load, dump } from "js-yaml";
import { generateRandomHash } from "@/server/utils/docker/compose";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { addPrefixToSecretsRoot } from "@/server/utils/docker/compose/secrets";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
const composeFileSecretsRoot = `
version: "3.8"
services:
web:
image: nginx:latest
secrets:
db_password:
file: ./db_password.txt
`;
test("Add prefix to secrets in root property", () => {
const composeData = load(composeFileSecretsRoot) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.secrets) {
return;
}
const secrets = addPrefixToSecretsRoot(composeData.secrets, prefix);
expect(secrets).toBeDefined();
if (secrets) {
for (const secretKey of Object.keys(secrets)) {
expect(secretKey).toContain(`-${prefix}`);
expect(secrets[secretKey]).toBeDefined();
}
}
});
const composeFileSecretsRoot1 = `
version: "3.8"
services:
api:
image: myapi:latest
secrets:
api_key:
file: ./api_key.txt
`;
test("Add prefix to secrets in root property (Test 1)", () => {
const composeData = load(composeFileSecretsRoot1) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.secrets) {
return;
}
const secrets = addPrefixToSecretsRoot(composeData.secrets, prefix);
expect(secrets).toBeDefined();
if (secrets) {
for (const secretKey of Object.keys(secrets)) {
expect(secretKey).toContain(`-${prefix}`);
expect(secrets[secretKey]).toBeDefined();
}
}
});
const composeFileSecretsRoot2 = `
version: "3.8"
services:
frontend:
image: nginx:latest
secrets:
frontend_secret:
file: ./frontend_secret.txt
db_password:
external: true
`;
test("Add prefix to secrets in root property (Test 2)", () => {
const composeData = load(composeFileSecretsRoot2) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.secrets) {
return;
}
const secrets = addPrefixToSecretsRoot(composeData.secrets, prefix);
expect(secrets).toBeDefined();
if (secrets) {
for (const secretKey of Object.keys(secrets)) {
expect(secretKey).toContain(`-${prefix}`);
expect(secrets[secretKey]).toBeDefined();
}
}
});

View File

@@ -0,0 +1,113 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToSecretsInServices } from "@/server/utils/docker/compose/secrets";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
const composeFileSecretsServices = `
version: "3.8"
services:
db:
image: postgres:latest
secrets:
- db_password
secrets:
db_password:
file: ./db_password.txt
`;
test("Add prefix to secrets in services", () => {
const composeData = load(composeFileSecretsServices) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToSecretsInServices(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
expect(actualComposeData.services?.db?.secrets).toContain(
`db_password-${prefix}`,
);
});
const composeFileSecretsServices1 = `
version: "3.8"
services:
app:
image: node:14
secrets:
- app_secret
secrets:
app_secret:
file: ./app_secret.txt
`;
test("Add prefix to secrets in services (Test 1)", () => {
const composeData = load(composeFileSecretsServices1) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToSecretsInServices(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
expect(actualComposeData.services?.app?.secrets).toContain(
`app_secret-${prefix}`,
);
});
const composeFileSecretsServices2 = `
version: "3.8"
services:
backend:
image: backend:latest
secrets:
- backend_secret
frontend:
image: frontend:latest
secrets:
- frontend_secret
secrets:
backend_secret:
file: ./backend_secret.txt
frontend_secret:
file: ./frontend_secret.txt
`;
test("Add prefix to secrets in services (Test 2)", () => {
const composeData = load(composeFileSecretsServices2) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToSecretsInServices(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
expect(actualComposeData.services?.backend?.secrets).toContain(
`backend_secret-${prefix}`,
);
expect(actualComposeData.services?.frontend?.secrets).toContain(
`frontend_secret-${prefix}`,
);
});

View File

@@ -0,0 +1,159 @@
import { addPrefixToAllSecrets } from "@/server/utils/docker/compose/secrets";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
const composeFileCombinedSecrets = `
version: "3.8"
services:
web:
image: nginx:latest
secrets:
- web_secret
app:
image: node:14
secrets:
- app_secret
secrets:
web_secret:
file: ./web_secret.txt
app_secret:
file: ./app_secret.txt
`;
const expectedComposeFileCombinedSecrets = load(`
version: "3.8"
services:
web:
image: nginx:latest
secrets:
- web_secret-testhash
app:
image: node:14
secrets:
- app_secret-testhash
secrets:
web_secret-testhash:
file: ./web_secret.txt
app_secret-testhash:
file: ./app_secret.txt
`) as ComposeSpecification;
test("Add prefix to all secrets", () => {
const composeData = load(composeFileCombinedSecrets) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllSecrets(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFileCombinedSecrets);
});
const composeFileCombinedSecrets3 = `
version: "3.8"
services:
api:
image: myapi:latest
secrets:
- api_key
cache:
image: redis:latest
secrets:
- cache_secret
secrets:
api_key:
file: ./api_key.txt
cache_secret:
file: ./cache_secret.txt
`;
const expectedComposeFileCombinedSecrets3 = load(`
version: "3.8"
services:
api:
image: myapi:latest
secrets:
- api_key-testhash
cache:
image: redis:latest
secrets:
- cache_secret-testhash
secrets:
api_key-testhash:
file: ./api_key.txt
cache_secret-testhash:
file: ./cache_secret.txt
`) as ComposeSpecification;
test("Add prefix to all secrets (3rd Case)", () => {
const composeData = load(composeFileCombinedSecrets3) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllSecrets(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFileCombinedSecrets3);
});
const composeFileCombinedSecrets4 = `
version: "3.8"
services:
web:
image: nginx:latest
secrets:
- web_secret
db:
image: postgres:latest
secrets:
- db_password
secrets:
web_secret:
file: ./web_secret.txt
db_password:
file: ./db_password.txt
`;
const expectedComposeFileCombinedSecrets4 = load(`
version: "3.8"
services:
web:
image: nginx:latest
secrets:
- web_secret-testhash
db:
image: postgres:latest
secrets:
- db_password-testhash
secrets:
web_secret-testhash:
file: ./web_secret.txt
db_password-testhash:
file: ./db_password.txt
`) as ComposeSpecification;
test("Add prefix to all secrets (4th Case)", () => {
const composeData = load(composeFileCombinedSecrets4) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllSecrets(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFileCombinedSecrets4);
});

View File

@@ -0,0 +1,59 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToServiceNames } from "@/server/utils/docker/compose/service";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
const composeFile = `
version: "3.8"
services:
web:
image: nginx:latest
container_name: web_container
api:
image: myapi:latest
networks:
default:
driver: bridge
`;
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
test("Add prefix to service names with container_name in compose file", () => {
const composeData = load(composeFile) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToServiceNames(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
// Verificar que el nombre del contenedor ha cambiado correctamente
expect(actualComposeData.services[`web-${prefix}`].container_name).toBe(
`web_container-${prefix}`,
);
// Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe
expect(actualComposeData.services).toHaveProperty(`web-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("web");
// Verificar que la configuración de la imagen sigue igual
expect(actualComposeData.services[`web-${prefix}`].image).toBe(
"nginx:latest",
);
expect(actualComposeData.services[`api-${prefix}`].image).toBe(
"myapi:latest",
);
});

View File

@@ -0,0 +1,150 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToServiceNames } from "@/server/utils/docker/compose/service";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
const composeFile4 = `
version: "3.8"
services:
web:
image: nginx:latest
depends_on:
- db
- api
api:
image: myapi:latest
db:
image: postgres:latest
networks:
default:
driver: bridge
`;
test("Add prefix to service names with depends_on (array) in compose file", () => {
const composeData = load(composeFile4) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToServiceNames(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
// Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe
expect(actualComposeData.services).toHaveProperty(`web-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("web");
// Verificar que la configuración de la imagen sigue igual
expect(actualComposeData.services[`web-${prefix}`].image).toBe(
"nginx:latest",
);
expect(actualComposeData.services[`api-${prefix}`].image).toBe(
"myapi:latest",
);
// Verificar que los nombres en depends_on tienen el prefijo
expect(actualComposeData.services[`web-${prefix}`].depends_on).toContain(
`db-${prefix}`,
);
expect(actualComposeData.services[`web-${prefix}`].depends_on).toContain(
`api-${prefix}`,
);
// Verificar que los servicios `db` y `api` también tienen el prefijo
expect(actualComposeData.services).toHaveProperty(`db-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("db");
expect(actualComposeData.services[`db-${prefix}`].image).toBe(
"postgres:latest",
);
expect(actualComposeData.services).toHaveProperty(`api-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("api");
expect(actualComposeData.services[`api-${prefix}`].image).toBe(
"myapi:latest",
);
});
const composeFile5 = `
version: "3.8"
services:
web:
image: nginx:latest
depends_on:
db:
condition: service_healthy
api:
condition: service_started
api:
image: myapi:latest
db:
image: postgres:latest
networks:
default:
driver: bridge
`;
test("Add prefix to service names with depends_on (object) in compose file", () => {
const composeData = load(composeFile5) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToServiceNames(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
// Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe
expect(actualComposeData.services).toHaveProperty(`web-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("web");
// Verificar que la configuración de la imagen sigue igual
expect(actualComposeData.services[`web-${prefix}`].image).toBe(
"nginx:latest",
);
expect(actualComposeData.services[`api-${prefix}`].image).toBe(
"myapi:latest",
);
// Verificar que los nombres en depends_on tienen el prefijo
const webDependsOn = actualComposeData.services[`web-${prefix}`]
.depends_on as Record<string, any>;
expect(webDependsOn).toHaveProperty(`db-${prefix}`);
expect(webDependsOn).toHaveProperty(`api-${prefix}`);
expect(webDependsOn[`db-${prefix}`].condition).toBe("service_healthy");
expect(webDependsOn[`api-${prefix}`].condition).toBe("service_started");
// Verificar que los servicios `db` y `api` también tienen el prefijo
expect(actualComposeData.services).toHaveProperty(`db-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("db");
expect(actualComposeData.services[`db-${prefix}`].image).toBe(
"postgres:latest",
);
expect(actualComposeData.services).toHaveProperty(`api-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("api");
expect(actualComposeData.services[`api-${prefix}`].image).toBe(
"myapi:latest",
);
});

View File

@@ -0,0 +1,131 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToServiceNames } from "@/server/utils/docker/compose/service";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
const composeFile6 = `
version: "3.8"
services:
web:
image: nginx:latest
extends: base_service
api:
image: myapi:latest
base_service:
image: base:latest
networks:
default:
driver: bridge
`;
test("Add prefix to service names with extends (string) in compose file", () => {
const composeData = load(composeFile6) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToServiceNames(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
// Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe
expect(actualComposeData.services).toHaveProperty(`web-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("web");
// Verificar que la configuración de la imagen sigue igual
expect(actualComposeData.services[`web-${prefix}`].image).toBe(
"nginx:latest",
);
expect(actualComposeData.services[`api-${prefix}`].image).toBe(
"myapi:latest",
);
// Verificar que el nombre en extends tiene el prefijo
expect(actualComposeData.services[`web-${prefix}`].extends).toBe(
`base_service-${prefix}`,
);
// Verificar que el servicio `base_service` también tiene el prefijo
expect(actualComposeData.services).toHaveProperty(`base_service-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("base_service");
expect(actualComposeData.services[`base_service-${prefix}`].image).toBe(
"base:latest",
);
});
const composeFile7 = `
version: "3.8"
services:
web:
image: nginx:latest
extends:
service: base_service
file: docker-compose.base.yml
api:
image: myapi:latest
base_service:
image: base:latest
networks:
default:
driver: bridge
`;
test("Add prefix to service names with extends (object) in compose file", () => {
const composeData = load(composeFile7) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToServiceNames(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
// Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe
expect(actualComposeData.services).toHaveProperty(`web-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("web");
// Verificar que la configuración de la imagen sigue igual
expect(actualComposeData.services[`web-${prefix}`].image).toBe(
"nginx:latest",
);
expect(actualComposeData.services[`api-${prefix}`].image).toBe(
"myapi:latest",
);
// Verificar que el nombre en extends.service tiene el prefijo
const webExtends = actualComposeData.services[`web-${prefix}`].extends;
if (typeof webExtends !== "string") {
expect(webExtends.service).toBe(`base_service-${prefix}`);
}
// Verificar que el servicio `base_service` también tiene el prefijo
expect(actualComposeData.services).toHaveProperty(`base_service-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("base_service");
expect(actualComposeData.services[`base_service-${prefix}`].image).toBe(
"base:latest",
);
});

View File

@@ -0,0 +1,76 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToServiceNames } from "@/server/utils/docker/compose/service";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
const composeFile2 = `
version: "3.8"
services:
web:
image: nginx:latest
links:
- db
api:
image: myapi:latest
db:
image: postgres:latest
networks:
default:
driver: bridge
`;
test("Add prefix to service names with links in compose file", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToServiceNames(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
// Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe
expect(actualComposeData.services).toHaveProperty(`web-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("web");
// Verificar que la configuración de la imagen sigue igual
expect(actualComposeData.services[`web-${prefix}`].image).toBe(
"nginx:latest",
);
expect(actualComposeData.services[`api-${prefix}`].image).toBe(
"myapi:latest",
);
// Verificar que los nombres en links tienen el prefijo
expect(actualComposeData.services[`web-${prefix}`].links).toContain(
`db-${prefix}`,
);
// Verificar que los servicios `db` y `api` también tienen el prefijo
expect(actualComposeData.services).toHaveProperty(`db-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("db");
expect(actualComposeData.services[`db-${prefix}`].image).toBe(
"postgres:latest",
);
expect(actualComposeData.services).toHaveProperty(`api-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("api");
expect(actualComposeData.services[`api-${prefix}`].image).toBe(
"myapi:latest",
);
});

View File

@@ -0,0 +1,49 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToServiceNames } from "@/server/utils/docker/compose/service";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
const composeFile = `
version: "3.8"
services:
web:
image: nginx:latest
api:
image: myapi:latest
networks:
default:
driver: bridge
`;
test("Add prefix to service names in compose file", () => {
const composeData = load(composeFile) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToServiceNames(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
// Verificar que los nombres de los servicios han cambiado correctamente
expect(actualComposeData.services).toHaveProperty(`web-${prefix}`);
expect(actualComposeData.services).toHaveProperty(`api-${prefix}`);
// Verificar que las claves originales no existen
expect(actualComposeData.services).not.toHaveProperty("web");
expect(actualComposeData.services).not.toHaveProperty("api");
});

View File

@@ -0,0 +1,375 @@
import {
addPrefixToAllServiceNames,
addPrefixToServiceNames,
} from "@/server/utils/docker/compose/service";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
const composeFileCombinedAllCases = `
version: "3.8"
services:
web:
image: nginx:latest
container_name: web_container
links:
- api
depends_on:
- api
extends: base_service
api:
image: myapi:latest
depends_on:
db:
condition: service_healthy
volumes_from:
- db
db:
image: postgres:latest
base_service:
image: base:latest
networks:
default:
driver: bridge
`;
const expectedComposeFile = load(`
version: "3.8"
services:
web-testhash:
image: nginx:latest
container_name: web_container-testhash
links:
- api-testhash
depends_on:
- api-testhash
extends: base_service-testhash
api-testhash:
image: myapi:latest
depends_on:
db-testhash:
condition: service_healthy
volumes_from:
- db-testhash
db-testhash:
image: postgres:latest
base_service-testhash:
image: base:latest
networks:
default:
driver: bridge
`);
test("Add prefix to all service names in compose file", () => {
const composeData = load(composeFileCombinedAllCases) as ComposeSpecification;
const prefix = "testhash";
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToServiceNames(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
expect(actualComposeData).toEqual(expectedComposeFile);
});
const composeFile1 = `
version: "3.8"
services:
web:
image: nginx:latest
container_name: web_container
depends_on:
- app
networks:
- frontend
volumes_from:
- data
links:
- db
extends:
service: base_service
app:
image: node:14
networks:
- backend
- frontend
db:
image: postgres:13
networks:
- backend
data:
image: busybox
volumes:
- /data
base_service:
image: base:latest
networks:
frontend:
driver: bridge
backend:
driver: bridge
`;
const expectedComposeFile1 = load(`
version: "3.8"
services:
web-testhash:
image: nginx:latest
container_name: web_container-testhash
depends_on:
- app-testhash
networks:
- frontend
volumes_from:
- data-testhash
links:
- db-testhash
extends:
service: base_service-testhash
app-testhash:
image: node:14
networks:
- backend
- frontend
db-testhash:
image: postgres:13
networks:
- backend
data-testhash:
image: busybox
volumes:
- /data
base_service-testhash:
image: base:latest
networks:
frontend:
driver: bridge
backend:
driver: bridge
`) as ComposeSpecification;
test("Add prefix to all service names in compose file 1", () => {
const composeData = load(composeFile1) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllServiceNames(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFile1);
});
const composeFile2 = `
version: "3.8"
services:
frontend:
image: nginx:latest
depends_on:
- backend
networks:
- public
volumes_from:
- logs
links:
- cache
extends:
service: shared_service
backend:
image: node:14
networks:
- private
- public
cache:
image: redis:latest
networks:
- private
logs:
image: busybox
volumes:
- /logs
shared_service:
image: shared:latest
networks:
public:
driver: bridge
private:
driver: bridge
`;
const expectedComposeFile2 = load(`
version: "3.8"
services:
frontend-testhash:
image: nginx:latest
depends_on:
- backend-testhash
networks:
- public
volumes_from:
- logs-testhash
links:
- cache-testhash
extends:
service: shared_service-testhash
backend-testhash:
image: node:14
networks:
- private
- public
cache-testhash:
image: redis:latest
networks:
- private
logs-testhash:
image: busybox
volumes:
- /logs
shared_service-testhash:
image: shared:latest
networks:
public:
driver: bridge
private:
driver: bridge
`) as ComposeSpecification;
test("Add prefix to all service names in compose file 2", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllServiceNames(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFile2);
});
const composeFile3 = `
version: "3.8"
services:
service_a:
image: service_a:latest
depends_on:
- service_b
networks:
- net_a
volumes_from:
- data_volume
links:
- service_c
extends:
service: common_service
service_b:
image: service_b:latest
networks:
- net_b
- net_a
service_c:
image: service_c:latest
networks:
- net_b
data_volume:
image: busybox
volumes:
- /data
common_service:
image: common:latest
networks:
net_a:
driver: bridge
net_b:
driver: bridge
`;
const expectedComposeFile3 = load(`
version: "3.8"
services:
service_a-testhash:
image: service_a:latest
depends_on:
- service_b-testhash
networks:
- net_a
volumes_from:
- data_volume-testhash
links:
- service_c-testhash
extends:
service: common_service-testhash
service_b-testhash:
image: service_b:latest
networks:
- net_b
- net_a
service_c-testhash:
image: service_c:latest
networks:
- net_b
data_volume-testhash:
image: busybox
volumes:
- /data
common_service-testhash:
image: common:latest
networks:
net_a:
driver: bridge
net_b:
driver: bridge
`) as ComposeSpecification;
test("Add prefix to all service names in compose file 3", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllServiceNames(composeData, prefix);
expect(updatedComposeData).toEqual(expectedComposeFile3);
});

View File

@@ -0,0 +1,76 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToServiceNames } from "@/server/utils/docker/compose/service";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
const composeFile3 = `
version: "3.8"
services:
web:
image: nginx:latest
volumes_from:
- shared
api:
image: myapi:latest
volumes_from:
- shared
shared:
image: busybox
volumes:
- /data
networks:
default:
driver: bridge
`;
test("Add prefix to service names with volumes_from in compose file", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToServiceNames(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
// Verificar que la nueva clave del servicio tiene el prefijo y la vieja clave no existe
expect(actualComposeData.services).toHaveProperty(`web-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("web");
// Verificar que la configuración de la imagen sigue igual
expect(actualComposeData.services[`web-${prefix}`].image).toBe(
"nginx:latest",
);
expect(actualComposeData.services[`api-${prefix}`].image).toBe(
"myapi:latest",
);
// Verificar que los nombres en volumes_from tienen el prefijo
expect(actualComposeData.services[`web-${prefix}`].volumes_from).toContain(
`shared-${prefix}`,
);
expect(actualComposeData.services[`api-${prefix}`].volumes_from).toContain(
`shared-${prefix}`,
);
// Verificar que el servicio shared también tiene el prefijo
expect(actualComposeData.services).toHaveProperty(`shared-${prefix}`);
expect(actualComposeData.services).not.toHaveProperty("shared");
expect(actualComposeData.services[`shared-${prefix}`].image).toBe("busybox");
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToVolumesRoot } from "@/server/utils/docker/compose/volume";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
const composeFile = `
version: "3.8"
services:
web:
image: nginx:latest
volumes:
- web_data:/var/lib/nginx/data
volumes:
web_data:
driver: local
networks:
default:
driver: bridge
`;
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
test("Add prefix to volumes in root property", () => {
const composeData = load(composeFile) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.volumes) {
return;
}
const volumes = addPrefixToVolumesRoot(composeData.volumes, prefix);
expect(volumes).toBeDefined();
for (const volumeKey of Object.keys(volumes)) {
expect(volumeKey).toContain(`-${prefix}`);
expect(volumes[volumeKey]).toBeDefined();
}
});
const composeFile2 = `
version: "3.8"
services:
app:
image: node:latest
volumes:
- app_data:/var/lib/app/data
volumes:
app_data:
driver: local
driver_opts:
type: nfs
o: addr=10.0.0.1,rw
device: ":/exported/path"
networks:
default:
driver: bridge
`;
test("Add prefix to volumes in root property (Case 2)", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.volumes) {
return;
}
const volumes = addPrefixToVolumesRoot(composeData.volumes, prefix);
expect(volumes).toBeDefined();
for (const volumeKey of Object.keys(volumes)) {
expect(volumeKey).toContain(`-${prefix}`);
expect(volumes[volumeKey]).toBeDefined();
}
});
const composeFile3 = `
version: "3.8"
services:
db:
image: postgres:latest
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
external: true
networks:
default:
driver: bridge
`;
test("Add prefix to volumes in root property (Case 3)", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData?.volumes) {
return;
}
const volumes = addPrefixToVolumesRoot(composeData.volumes, prefix);
expect(volumes).toBeDefined();
for (const volumeKey of Object.keys(volumes)) {
expect(volumeKey).toContain(`-${prefix}`);
expect(volumes[volumeKey]).toBeDefined();
}
});
const composeFile4 = `
version: "3.8"
services:
web:
image: nginx:latest
app:
image: node:latest
db:
image: postgres:latest
volumes:
web_data:
driver: local
app_data:
driver: local
driver_opts:
type: nfs
o: addr=10.0.0.1,rw
device: ":/exported/path"
db_data:
external: true
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFile4 = load(`
version: "3.8"
services:
web:
image: nginx:latest
app:
image: node:latest
db:
image: postgres:latest
volumes:
web_data-testhash:
driver: local
app_data-testhash:
driver: local
driver_opts:
type: nfs
o: addr=10.0.0.1,rw
device: ":/exported/path"
db_data-testhash:
external: true
`) as ComposeSpecification;
test("Add prefix to volumes in root property", () => {
const composeData = load(composeFile4) as ComposeSpecification;
const prefix = "testhash";
if (!composeData?.volumes) {
return;
}
const volumes = addPrefixToVolumesRoot(composeData.volumes, prefix);
const updatedComposeData = { ...composeData, volumes };
// Verificar que el resultado coincide con el archivo esperado
expect(updatedComposeData).toEqual(expectedComposeFile4);
});

View File

@@ -0,0 +1,81 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import { addPrefixToVolumesInServices } from "@/server/utils/docker/compose/volume";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
expect(hash).toBeDefined();
expect(hash.length).toBe(8);
});
const composeFile1 = `
version: "3.8"
services:
db:
image: postgres:latest
volumes:
- db_data:/var/lib/postgresql/data
`;
test("Add prefix to volumes declared directly in services", () => {
const composeData = load(composeFile1) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToVolumesInServices(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
expect(actualComposeData.services?.db?.volumes).toContain(
`db_data-${prefix}:/var/lib/postgresql/data`,
);
});
const composeFileTypeVolume = `
version: "3.8"
services:
db:
image: postgres:latest
volumes:
- type: volume
source: db-test
target: /var/lib/postgresql/data
volumes:
db-test:
driver: local
`;
test("Add prefix to volumes declared directly in services (Case 2)", () => {
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const prefix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addPrefixToVolumesInServices(
composeData.services,
prefix,
);
const actualComposeData = { ...composeData, services: updatedComposeData };
expect(actualComposeData.services?.db?.volumes).toEqual([
{
type: "volume",
source: `db-test-${prefix}`,
target: "/var/lib/postgresql/data",
},
]);
});

View File

@@ -0,0 +1,288 @@
import { generateRandomHash } from "@/server/utils/docker/compose";
import {
addPrefixToAllVolumes,
addPrefixToVolumesInServices,
} from "@/server/utils/docker/compose/volume";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { load } from "js-yaml";
import { expect, test } from "vitest";
const composeFileTypeVolume = `
version: "3.8"
services:
db1:
image: postgres:latest
volumes:
- "db-test:/var/lib/postgresql/data"
db2:
image: postgres:latest
volumes:
- type: volume
source: db-test
target: /var/lib/postgresql/data
volumes:
db-test:
driver: local
`;
const expectedComposeFileTypeVolume = load(`
version: "3.8"
services:
db1:
image: postgres:latest
volumes:
- "db-test-testhash:/var/lib/postgresql/data"
db2:
image: postgres:latest
volumes:
- type: volume
source: db-test-testhash
target: /var/lib/postgresql/data
volumes:
db-test-testhash:
driver: local
`) as ComposeSpecification;
test("Add prefix to volumes with type: volume in services", () => {
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllVolumes(composeData, prefix);
const actualComposeData = { ...composeData, ...updatedComposeData };
expect(actualComposeData).toEqual(expectedComposeFileTypeVolume);
});
const composeFileTypeVolume1 = `
version: "3.8"
services:
web:
image: nginx:latest
volumes:
- "web-data:/var/www/html"
- type: volume
source: web-logs
target: /var/log/nginx
volumes:
web-data:
driver: local
web-logs:
driver: local
`;
const expectedComposeFileTypeVolume1 = load(`
version: "3.8"
services:
web:
image: nginx:latest
volumes:
- "web-data-testhash:/var/www/html"
- type: volume
source: web-logs-testhash
target: /var/log/nginx
volumes:
web-data-testhash:
driver: local
web-logs-testhash:
driver: local
`) as ComposeSpecification;
test("Add prefix to mixed volumes in services", () => {
const composeData = load(composeFileTypeVolume1) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllVolumes(composeData, prefix);
const actualComposeData = { ...composeData, ...updatedComposeData };
expect(actualComposeData).toEqual(expectedComposeFileTypeVolume1);
});
const composeFileTypeVolume2 = `
version: "3.8"
services:
app:
image: node:latest
volumes:
- "app-data:/usr/src/app"
- type: volume
source: app-logs
target: /var/log/app
volume:
nocopy: true
volumes:
app-data:
driver: local
app-logs:
driver: local
driver_opts:
o: bind
type: none
device: /path/to/app/logs
`;
const expectedComposeFileTypeVolume2 = load(`
version: "3.8"
services:
app:
image: node:latest
volumes:
- "app-data-testhash:/usr/src/app"
- type: volume
source: app-logs-testhash
target: /var/log/app
volume:
nocopy: true
volumes:
app-data-testhash:
driver: local
app-logs-testhash:
driver: local
driver_opts:
o: bind
type: none
device: /path/to/app/logs
`) as ComposeSpecification;
test("Add prefix to complex volume configurations in services", () => {
const composeData = load(composeFileTypeVolume2) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllVolumes(composeData, prefix);
const actualComposeData = { ...composeData, ...updatedComposeData };
expect(actualComposeData).toEqual(expectedComposeFileTypeVolume2);
});
const composeFileTypeVolume3 = `
version: "3.8"
services:
web:
image: nginx:latest
volumes:
- "web-data:/usr/share/nginx/html"
- type: volume
source: web-logs
target: /var/log/nginx
volume:
nocopy: true
api:
image: node:latest
volumes:
- "api-data:/usr/src/app"
- type: volume
source: api-logs
target: /var/log/app
volume:
nocopy: true
- type: volume
source: shared-logs
target: /shared/logs
volumes:
web-data:
driver: local
web-logs:
driver: local
driver_opts:
o: bind
type: none
device: /path/to/web/logs
api-data:
driver: local
api-logs:
driver: local
driver_opts:
o: bind
type: none
device: /path/to/api/logs
shared-logs:
driver: local
driver_opts:
o: bind
type: none
device: /path/to/shared/logs
`;
const expectedComposeFileTypeVolume3 = load(`
version: "3.8"
services:
web:
image: nginx:latest
volumes:
- "web-data-testhash:/usr/share/nginx/html"
- type: volume
source: web-logs-testhash
target: /var/log/nginx
volume:
nocopy: true
api:
image: node:latest
volumes:
- "api-data-testhash:/usr/src/app"
- type: volume
source: api-logs-testhash
target: /var/log/app
volume:
nocopy: true
- type: volume
source: shared-logs-testhash
target: /shared/logs
volumes:
web-data-testhash:
driver: local
web-logs-testhash:
driver: local
driver_opts:
o: bind
type: none
device: /path/to/web/logs
api-data-testhash:
driver: local
api-logs-testhash:
driver: local
driver_opts:
o: bind
type: none
device: /path/to/api/logs
shared-logs-testhash:
driver: local
driver_opts:
o: bind
type: none
device: /path/to/shared/logs
`) as ComposeSpecification;
test("Add prefix to complex nested volumes configuration in services", () => {
const composeData = load(composeFileTypeVolume3) as ComposeSpecification;
const prefix = "testhash";
const updatedComposeData = addPrefixToAllVolumes(composeData, prefix);
const actualComposeData = { ...composeData, ...updatedComposeData };
expect(actualComposeData).toEqual(expectedComposeFileTypeVolume3);
});

16
__test__/vitest.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
tsconfigPaths({
root: "./",
projects: ["tsconfig.json"],
}),
],
test: {
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
pool: "forks",
},
});

View File

@@ -24,7 +24,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
import { HelpCircle, Settings } from "lucide-react";
import {
Tooltip,
@@ -32,6 +31,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CodeEditor } from "@/components/shared/code-editor";
const HealthCheckSwarmSchema = z
.object({
@@ -320,8 +320,8 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
</TooltipProvider>
<FormControl>
<Textarea
className="font-mono [field-sizing:content;] min-h-[11.2rem]"
<CodeEditor
language="json"
placeholder={`{
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
"Interval" : 10000,
@@ -329,6 +329,7 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
"StartPeriod" : 10000,
"Retries" : 10
}`}
className="h-[12rem] font-mono"
{...field}
value={field?.value || ""}
/>
@@ -374,14 +375,15 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
</TooltipProvider>
<FormControl>
<Textarea
className="font-mono [field-sizing:content;] min-h-[11.2rem]"
<CodeEditor
language="json"
placeholder={`{
"Condition" : "on-failure",
"Delay" : 10000,
"MaxAttempts" : 10,
"Window" : 10000
} `}
className="h-[12rem] font-mono"
{...field}
value={field?.value || ""}
/>
@@ -432,8 +434,8 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
</TooltipProvider>
<FormControl>
<Textarea
className="font-mono [field-sizing:content;] min-h-[18.7rem]"
<CodeEditor
language="json"
placeholder={`{
"Constraints" : ["node.role==manager"],
"Preferences" : [{
@@ -447,6 +449,7 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
"OS" : "linux"
}]
} `}
className="h-[21rem] font-mono"
{...field}
value={field?.value || ""}
/>
@@ -494,8 +497,8 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
</TooltipProvider>
<FormControl>
<Textarea
className="font-mono [field-sizing:content;] min-h-[18.7rem]"
<CodeEditor
language="json"
placeholder={`{
"Parallelism" : 1,
"Delay" : 10000,
@@ -504,6 +507,7 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
"MaxFailureRatio" : 10,
"Order" : "start-first"
}`}
className="h-[21rem] font-mono"
{...field}
value={field?.value || ""}
/>
@@ -551,8 +555,8 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
</TooltipProvider>
<FormControl>
<Textarea
className="font-mono [field-sizing:content;] min-h-[14.8rem]"
<CodeEditor
language="json"
placeholder={`{
"Parallelism" : 1,
"Delay" : 10000,
@@ -561,6 +565,7 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
"MaxFailureRatio" : 10,
"Order" : "start-first"
}`}
className="h-[17rem] font-mono"
{...field}
value={field?.value || ""}
/>
@@ -611,8 +616,8 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
</TooltipProvider>
<FormControl>
<Textarea
className="font-mono [field-sizing:content;] min-h-[14.8rem]"
<CodeEditor
language="json"
placeholder={`{
"Replicated" : {
"Replicas" : 1
@@ -624,6 +629,7 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
},
"GlobalJob" : {}
}`}
className="h-[17rem] font-mono"
{...field}
value={field?.value || ""}
/>
@@ -668,8 +674,8 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
</Tooltip>
</TooltipProvider>
<FormControl>
<Textarea
className="font-mono [field-sizing:content;] min-h-[18.5rem]"
<CodeEditor
language="json"
placeholder={`[
{
"Target" : "dokploy-network",
@@ -682,6 +688,7 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
}
}
]`}
className="h-[20rem] font-mono"
{...field}
value={field?.value || ""}
/>
@@ -722,12 +729,13 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
</Tooltip>
</TooltipProvider>
<FormControl>
<Textarea
className="font-mono [field-sizing:content;] min-h-[18.5rem]"
<CodeEditor
language="json"
placeholder={`{
"com.example.app.name" : "my-app",
"com.example.app.version" : "1.0.0"
}`}
className="h-[20rem] font-mono"
{...field}
value={field?.value || ""}
/>

View File

@@ -34,6 +34,7 @@ import {
import Link from "next/link";
import { Server } from "lucide-react";
import { AddSwarmSettings } from "./modify-swarm-settings";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
applicationId: string;
@@ -106,6 +107,10 @@ export const ShowClusterSettings = ({ applicationId }: Props) => {
<AddSwarmSettings applicationId={applicationId} />
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the cluster settings to
apply the changes.
</AlertBlock>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

@@ -183,8 +183,7 @@ export const AddPort = ({
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent defaultValue={"none"}>
<SelectItem value={"none"}>None</SelectItem>
<SelectContent>
<SelectItem value={"tcp"}>TCP</SelectItem>
<SelectItem value={"udp"}>UDP</SelectItem>
</SelectContent>

View File

@@ -11,6 +11,7 @@ import { Rss } from "lucide-react";
import { AddPort } from "./add-port";
import { DeletePort } from "./delete-port";
import { UpdatePort } from "./update-port";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
applicationId: string;
}
@@ -47,7 +48,11 @@ export const ShowPorts = ({ applicationId }: Props) => {
<AddPort applicationId={applicationId}>Add Port</AddPort>
</div>
) : (
<div className="flex flex-col pt-2">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting the ports to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.ports.map((port) => (
<div key={port.portId}>

View File

@@ -21,6 +21,7 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesApplication = z.object({
memoryReservation: z.number().nullable().optional(),
@@ -84,6 +85,10 @@ export const ShowApplicationResources = ({ applicationId }: Props) => {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<form
id="hook-form"

View File

@@ -27,6 +27,7 @@ import { PlusIcon } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface Props {
serviceId: string;
serviceType:
@@ -36,7 +37,8 @@ interface Props {
| "mongo"
| "redis"
| "mysql"
| "mariadb";
| "mariadb"
| "compose";
refetch: () => void;
children?: React.ReactNode;
}
@@ -77,7 +79,7 @@ export const AddVolumes = ({
const { mutateAsync } = api.mounts.create.useMutation();
const form = useForm<AddMount>({
defaultValues: {
type: "bind",
type: serviceType === "compose" ? "file" : "bind",
hostPath: "",
mountPath: "",
},
@@ -176,41 +178,52 @@ export const AddVolumes = ({
defaultValue={field.value}
className="grid w-full grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="bind"
id="bind"
className="peer sr-only"
/>
<Label
htmlFor="bind"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
Bind Mount
</Label>
</div>
</FormControl>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="volume"
id="volume"
className="peer sr-only"
/>
<Label
htmlFor="volume"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
Volume Mount
</Label>
</div>
</FormControl>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
{serviceType !== "compose" && (
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="bind"
id="bind"
className="peer sr-only"
/>
<Label
htmlFor="bind"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
Bind Mount
</Label>
</div>
</FormControl>
</FormItem>
)}
{serviceType !== "compose" && (
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="volume"
id="volume"
className="peer sr-only"
/>
<Label
htmlFor="volume"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
Volume Mount
</Label>
</div>
</FormControl>
</FormItem>
)}
<FormItem
className={cn(
serviceType === "compose" && "col-span-3",
"flex items-center space-x-3 space-y-0",
)}
>
<FormControl className="w-full">
<div>
<RadioGroupItem

View File

@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { AddVolumes } from "./add-volumes";
import { DeleteVolume } from "./delete-volume";
import { UpdateVolume } from "./update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
applicationId: string;
}
@@ -59,15 +61,12 @@ export const ShowVolumes = ({ applicationId }: Props) => {
</AddVolumes>
</div>
) : (
<div className="flex flex-col pt-2">
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</span>
</div>
<div className="flex flex-col gap-6 pt-6">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
@@ -114,7 +113,12 @@ export const ShowVolumes = ({ applicationId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

View File

@@ -0,0 +1,263 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
const mountSchema = z.object({
mountPath: z.string().min(1, "Mount path required"),
});
const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("bind"),
hostPath: z.string().min(1, "Host path required"),
})
.merge(mountSchema),
z
.object({
type: z.literal("volume"),
volumeName: z.string().min(1, "Volume name required"),
})
.merge(mountSchema),
z
.object({
type: z.literal("file"),
content: z.string().optional(),
})
.merge(mountSchema),
]);
type UpdateMount = z.infer<typeof mySchema>;
interface Props {
mountId: string;
type: "bind" | "volume" | "file";
refetch: () => void;
}
export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
const utils = api.useUtils();
const { data } = api.mounts.one.useQuery(
{
mountId,
},
{
enabled: !!mountId,
},
);
const { mutateAsync, isLoading, error, isError } =
api.mounts.update.useMutation();
const form = useForm<UpdateMount>({
defaultValues: {
type,
hostPath: "",
mountPath: "",
},
resolver: zodResolver(mySchema),
});
const typeForm = form.watch("type");
useEffect(() => {
if (data) {
if (typeForm === "bind") {
form.reset({
hostPath: data.hostPath || "",
mountPath: data.mountPath,
type: "bind",
});
} else if (typeForm === "volume") {
form.reset({
volumeName: data.volumeName || "",
mountPath: data.mountPath,
type: "volume",
});
} else if (typeForm === "file") {
form.reset({
content: data.content || "",
mountPath: data.mountPath,
type: "file",
});
}
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateMount) => {
if (data.type === "bind") {
await mutateAsync({
hostPath: data.hostPath,
mountPath: data.mountPath,
type: data.type,
mountId,
})
.then(() => {
toast.success("Mount Update");
})
.catch(() => {
toast.error("Error to update the Bind mount");
});
} else if (data.type === "volume") {
await mutateAsync({
volumeName: data.volumeName,
mountPath: data.mountPath,
type: data.type,
mountId,
})
.then(() => {
toast.success("Mount Update");
})
.catch(() => {
toast.error("Error to update the Volume mount");
});
} else if (data.type === "file") {
await mutateAsync({
content: data.content,
mountPath: data.mountPath,
type: data.type,
mountId,
})
.then(() => {
toast.success("Mount Update");
})
.catch(() => {
toast.error("Error to update the File mount");
});
}
refetch();
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<Pencil className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update</DialogTitle>
<DialogDescription>Update the mount</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-update-volume"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4">
{type === "bind" && (
<FormField
control={form.control}
name="hostPath"
render={({ field }) => (
<FormItem>
<FormLabel>Host Path</FormLabel>
<FormControl>
<Input placeholder="Host Path" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{type === "volume" && (
<FormField
control={form.control}
name="volumeName"
render={({ field }) => (
<FormItem>
<FormLabel>Volume Name</FormLabel>
<FormControl>
<Input
placeholder="Volume Name"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{type === "file" && (
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
<Textarea
placeholder="Any content"
className="h-64"
{...field}
/>
</FormControl>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="mountPath"
render={({ field }) => (
<FormItem>
<FormLabel>Mount Path</FormLabel>
<FormControl>
<Input placeholder="Mount Path" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-volume"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import {
Card,
CardContent,
@@ -19,7 +19,9 @@ import {
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { Toggle } from "@/components/ui/toggle";
import { CodeEditor } from "@/components/shared/code-editor";
import { EyeIcon, EyeOffIcon } from "lucide-react";
const addEnvironmentSchema = z.object({
environment: z.string(),
@@ -32,6 +34,7 @@ interface Props {
}
export const ShowEnvironment = ({ applicationId }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const { mutateAsync, isLoading } =
api.application.saveEnvironment.useMutation();
@@ -71,15 +74,50 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
toast.error("Error to add environment");
});
};
useEffect(() => {
if (isEnvVisible) {
if (data?.env) {
const maskedLines = data.env
.split("\n")
.map((line) => "*".repeat(line.length))
.join("\n");
form.reset({
environment: maskedLines,
});
} else {
form.reset({
environment: "",
});
}
} else {
form.reset({
environment: data?.env || "",
});
}
}, [form.reset, data, form, isEnvVisible]);
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription>
You can add environment variables to your resource.
</CardDescription>
<CardHeader className="flex flex-row w-full items-center justify-between">
<div>
<CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription>
You can add environment variables to your resource.
</CardDescription>
</div>
<Toggle
aria-label="Toggle bold"
pressed={isEnvVisible}
onPressedChange={setIsEnvVisible}
>
{isEnvVisible ? (
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
) : (
<EyeIcon className="h-4 w-4 text-muted-foreground" />
)}
</Toggle>
</CardHeader>
<CardContent>
<Form {...form}>
@@ -94,8 +132,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="NODE_ENV=production"
<CodeEditor
language="properties"
disabled={isEnvVisible}
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
@@ -107,7 +149,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
/>
<div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit">
<Button
disabled={isEnvVisible}
isLoading={isLoading}
className="w-fit"
type="submit"
>
Save
</Button>
</div>

View File

@@ -11,6 +11,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { toast } from "sonner";
interface Props {
@@ -51,18 +52,14 @@ export const DeployApplication = ({ applicationId }: Props) => {
applicationId,
})
.then(async () => {
toast.success("Application Deploying....");
toast.success("Deploying Application....");
await refetch();
await deploy({
applicationId,
})
.then(() => {
toast.success("Application Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Application");
});
}).catch(() => {
toast.error("Error to deploy Application");
});
await refetch();
})

View File

@@ -0,0 +1,133 @@
import React from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { z } from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { toast } from "sonner";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface Props {
composeId: string;
}
const AddRedirectSchema = z.object({
command: z.string(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
export const AddCommandCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { data: defaultCommand, refetch } =
api.compose.getDefaultCommand.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const form = useForm<AddCommand>({
defaultValues: {
command: "",
},
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
if (data?.command) {
form.reset({
command: data?.command || "",
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
composeId,
command: data?.command,
})
.then(async () => {
toast.success("Command Updated");
refetch();
await utils.compose.one.invalidate({
composeId,
});
})
.catch(() => {
toast.error("Error to update the command");
});
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between">
<div>
<CardTitle className="text-xl">Run Command</CardTitle>
<CardDescription>
Append a custom command to the compose file
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="Custom command" {...field} />
</FormControl>
<FormDescription>
Default Command ({defaultCommand})
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,133 @@
import React from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Package } from "lucide-react";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
composeId: string;
}
export const ShowVolumesCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
<div>
<CardTitle className="text-xl">Volumes</CardTitle>
<CardDescription>
If you want to persist data in this compose use the following config
to setup the volumes
</CardDescription>
</div>
{data && data?.mounts.length > 0 && (
<AddVolumes
serviceId={composeId}
refetch={refetch}
serviceType="compose"
>
Add Volume
</AddVolumes>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{data?.mounts.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<Package className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No volumes/mounts configured
</span>
<AddVolumes
serviceId={composeId}
refetch={refetch}
serviceType="compose"
>
Add Volume
</AddVolumes>
</div>
) : (
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
key={mount.mountId}
className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Type</span>
<span className="text-sm text-muted-foreground">
{mount.type.toUpperCase()}
</span>
</div>
{mount.type === "volume" && (
<div className="flex flex-col gap-1">
<span className="font-medium">Volume Name</span>
<span className="text-sm text-muted-foreground">
{mount.volumeName}
</span>
</div>
)}
{mount.type === "file" && (
<div className="flex flex-col gap-1">
<span className="font-medium">Content</span>
<span className="text-sm text-muted-foreground w-40 truncate">
{mount.content}
</span>
</div>
)}
{mount.type === "bind" && (
<div className="flex flex-col gap-1">
<span className="font-medium">Host Path</span>
<span className="text-sm text-muted-foreground">
{mount.hostPath}
</span>
</div>
)}
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Path</span>
<span className="text-sm text-muted-foreground">
{mount.mountPath}
</span>
</div>
</div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,63 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const DeleteCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.delete.useMutation();
const { push } = useRouter();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
compose and all its services.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Compose delete succesfully");
})
.catch(() => {
toast.error("Error to delete the compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,61 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const CancelQueuesCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.cleanQueues.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
Cancel Queues
<Paintbrush className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to cancel the incoming deployments?
</AlertDialogTitle>
<AlertDialogDescription>
This will cancel all the incoming deployments
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(() => {
toast.success("Queues are being cleaned");
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,60 @@
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const RefreshTokenCompose = ({ composeId }: Props) => {
const { mutateAsync } = api.compose.refreshToken.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger>
<RefreshCcw className="h-4 w-4 cursor-pointer text-muted-foreground" />
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently change the token
and all the previous tokens will be invalidated
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(() => {
utils.compose.one.invalidate({
composeId,
});
toast.success("Refresh Token updated");
})
.catch(() => {
toast.error("Error to update the refresh token");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,69 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useEffect, useRef, useState } from "react";
interface Props {
logPath: string | null;
open: boolean;
onClose: () => void;
}
export const ShowDeploymentCompose = ({ logPath, open, onClose }: Props) => {
const [data, setData] = useState("");
const endOfLogsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open || !logPath) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-deployment?logPath=${logPath}`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => {
setData((currentData) => currentData + e.data);
};
return () => ws.close();
}, [logPath, open]);
const scrollToBottom = () => {
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [data]);
return (
<Dialog
open={open}
onOpenChange={(e) => {
onClose();
if (!e) setData("");
}}
>
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription>
See all the details of this deployment
</DialogDescription>
</DialogHeader>
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
<code>
<pre className="whitespace-pre-wrap break-words">
{data || "Loading..."}
</pre>
<div ref={endOfLogsRef} />
</code>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,119 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
// import { CancelQueues } from "./cancel-queues";
// import { ShowDeployment } from "./show-deployment-compose";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { ShowDeploymentCompose } from "./show-deployment-compose";
import { RefreshTokenCompose } from "./refresh-token-compose";
import { CancelQueuesCompose } from "./cancel-queues-compose";
// import { RefreshToken } from "./refresh-token";//
interface Props {
composeId: string;
}
export const ShowDeploymentsCompose = ({ composeId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data } = api.compose.one.useQuery({ composeId });
const { data: deployments } = api.deployment.allByCompose.useQuery(
{ composeId },
{
enabled: !!composeId,
refetchInterval: 5000,
},
);
const [url, setUrl] = React.useState("");
useEffect(() => {
setUrl(document.location.origin);
}, []);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Deployments</CardTitle>
<CardDescription>
See all the 10 last deployments for this compose
</CardDescription>
</div>
<CancelQueuesCompose composeId={composeId} />
{/* <CancelQueues applicationId={applicationId} /> */}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2 text-sm">
<span>
If you want to re-deploy this application use this URL in the config
of your git provider or docker
</span>
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="text-muted-foreground">
{`${url}/api/deploy/compose/${data?.refreshToken}`}
</span>
<RefreshTokenCompose composeId={composeId} />
</div>
</div>
</div>
{data?.deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No deployments found
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{deployments?.map((deployment) => (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
<span className="text-sm text-muted-foreground">
{deployment.title}
</span>
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
</div>
<Button
onClick={() => {
setActiveLog(deployment.logPath);
}}
>
View
</Button>
</div>
</div>
))}
</div>
)}
<ShowDeploymentCompose
open={activeLog !== null}
onClose={() => setActiveLog(null)}
logPath={activeLog}
/>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,167 @@
import React, { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Toggle } from "@/components/ui/toggle";
import { EyeIcon, EyeOffIcon } from "lucide-react";
const addEnvironmentSchema = z.object({
environment: z.string(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
interface Props {
composeId: string;
}
export const ShowEnvironmentCompose = ({ composeId }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const form = useForm<EnvironmentSchema>({
defaultValues: {
environment: "",
},
resolver: zodResolver(addEnvironmentSchema),
});
useEffect(() => {
if (data) {
form.reset({
environment: data.env || "",
});
}
}, [form.reset, data, form]);
const onSubmit = async (data: EnvironmentSchema) => {
mutateAsync({
env: data.environment,
composeId,
})
.then(async () => {
toast.success("Environments Added");
await refetch();
})
.catch(() => {
toast.error("Error to add environment");
});
};
useEffect(() => {
if (isEnvVisible) {
if (data?.env) {
const maskedLines = data.env
.split("\n")
.map((line) => "*".repeat(line.length))
.join("\n");
form.reset({
environment: maskedLines,
});
} else {
form.reset({
environment: "",
});
}
} else {
form.reset({
environment: data?.env || "",
});
}
}, [form.reset, data, form, isEnvVisible]);
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader className="flex flex-row w-full items-center justify-between">
<div>
<CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription>
You can add environment variables to your resource.
</CardDescription>
</div>
<Toggle
aria-label="Toggle bold"
pressed={isEnvVisible}
onPressedChange={setIsEnvVisible}
>
{isEnvVisible ? (
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
) : (
<EyeIcon className="h-4 w-4 text-muted-foreground" />
)}
</Toggle>
</CardHeader>
<CardContent>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="environment"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<CodeEditor
language="properties"
disabled={isEnvVisible}
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-end">
<Button
disabled={isEnvVisible}
isLoading={isLoading}
className="w-fit"
type="submit"
>
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,121 @@
import { Button } from "@/components/ui/button";
import { ExternalLink, Globe, Terminal } from "lucide-react";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Toggle } from "@/components/ui/toggle";
import { RedbuildCompose } from "./rebuild-compose";
import { DeployCompose } from "./deploy-compose";
import { StopCompose } from "./stop-compose";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Link from "next/link";
interface Props {
composeId: string;
}
export const ComposeActions = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: update } = api.compose.update.useMutation();
const extractDomains = (env: string) => {
const lines = env.split("\n");
const hostLines = lines.filter((line) => {
const [key, value] = line.split("=");
return key?.trim().endsWith("_HOST");
});
const hosts = hostLines.map((line) => {
const [key, value] = line.split("=");
return value ? value.trim() : "";
});
return hosts;
};
const domains = extractDomains(data?.env || "");
return (
<div className="flex flex-row gap-4 w-full flex-wrap ">
<DeployCompose composeId={composeId} />
<Toggle
aria-label="Toggle italic"
pressed={data?.autoDeploy || false}
onPressedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
});
}}
>
Autodeploy
</Toggle>
<RedbuildCompose composeId={composeId} />
{data?.composeType === "docker-compose" && (
<StopCompose composeId={composeId} />
)}
<DockerTerminalModal appName={data?.appName || ""}>
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
{domains.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Domains
<Globe className="text-xs size-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Domains detected</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{domains.map((host, index) => {
const url =
host.startsWith("http://") || host.startsWith("https://")
? host
: `http://${host}`;
return (
<DropdownMenuItem
key={`domain-${index}`}
className="cursor-pointer"
asChild
>
<Link href={url} target="_blank">
{host}
<ExternalLink className="ml-2 text-xs text-muted-foreground" />
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};

View File

@@ -0,0 +1,142 @@
import { api } from "@/utils/api";
import { useEffect } from "react";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { RandomizeCompose } from "./randomize-compose";
import { CodeEditor } from "@/components/shared/code-editor";
interface Props {
composeId: string;
}
const AddComposeFile = z.object({
composeFile: z.string(),
});
type AddComposeFile = z.infer<typeof AddComposeFile>;
export const ComposeFileEditor = ({ composeId }: Props) => {
const utils = api.useUtils();
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync, isLoading, error, isError } =
api.compose.update.useMutation();
const form = useForm<AddComposeFile>({
defaultValues: {
composeFile: "",
},
resolver: zodResolver(AddComposeFile),
});
useEffect(() => {
if (data) {
form.reset({
composeFile: data.composeFile || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: AddComposeFile) => {
const { valid, error } = validateAndFormatYAML(data.composeFile);
if (!valid) {
form.setError("composeFile", {
type: "manual",
message: error || "Invalid YAML",
});
return;
}
form.clearErrors("composeFile");
await mutateAsync({
composeId,
composeFile: data.composeFile,
sourceType: "raw",
})
.then(async () => {
toast.success("Compose config Updated");
refetch();
await utils.compose.allServices.invalidate({
composeId,
});
})
.catch((e) => {
console.log(e);
toast.error("Error to update the compose config");
});
};
return (
<>
<div className="w-full flex flex-col lg:flex-row gap-4 ">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full relative gap-4"
>
<FormField
control={form.control}
name="composeFile"
render={({ field }) => (
<FormItem className="overflow-auto">
<FormControl className="">
<div className="flex flex-col gap-4 w-full outline-none focus:outline-none overflow-auto">
<CodeEditor
// disabled
value={field.value}
className="font-mono min-h-[20rem] compose-file-editor"
wrapperClassName="min-h-[20rem]"
placeholder={`version: '3'
services:
web:
image: nginx
ports:
- "80:80"
`}
onChange={(value) => {
field.onChange(value);
}}
/>
</div>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<div className="flex justify-between flex-col lg:flex-row gap-2">
<div className="w-full flex flex-col lg:flex-row gap-4 items-end">
<RandomizeCompose composeId={composeId} />
</div>
<Button
type="submit"
isLoading={isLoading}
className="lg:w-fit w-full"
>
Save
</Button>
</div>
</form>
</Form>
</div>
</>
);
};

View File

@@ -0,0 +1,74 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const DeployCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: markRunning } = api.compose.update.useMutation();
const { mutateAsync: deploy } = api.compose.deploy.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button isLoading={data?.composeStatus === "running"}>Deploy</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will deploy the compose
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await markRunning({
composeId,
composeStatus: "running",
})
.then(async () => {
toast.success("Deploying Compose....");
await refetch();
await deploy({
composeId,
}).catch(() => {
toast.error("Error to deploy Compose");
});
await refetch();
})
.catch((e) => {
toast.error(e.message || "Error to deploy Compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,255 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { CopyIcon, LockIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const GitProviderSchema = z.object({
composePath: z.string().min(1),
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z.string().min(1, "Branch required"),
});
type GitProvider = z.infer<typeof GitProviderSchema>;
interface Props {
composeId: string;
}
export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
api.compose.generateSSHKey.useMutation();
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
api.compose.removeSSHKey.useMutation();
const form = useForm<GitProvider>({
defaultValues: {
branch: "",
repositoryURL: "",
composePath: "./docker-compose.yml",
},
resolver: zodResolver(GitProviderSchema),
});
useEffect(() => {
if (data) {
form.reset({
branch: data.customGitBranch || "",
repositoryURL: data.customGitUrl || "",
composePath: data.composePath,
});
}
}, [form.reset, data, form]);
const onSubmit = async (values: GitProvider) => {
await mutateAsync({
customGitBranch: values.branch,
customGitUrl: values.repositoryURL,
composeId,
sourceType: "git",
composePath: values.composePath,
})
.then(async () => {
toast.success("Git Provider Saved");
await refetch();
})
.catch(() => {
toast.error("Error to save the Git provider");
});
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid md:grid-cols-2 gap-4 ">
<div className="md:col-span-2 space-y-4">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row justify-between">
Repository URL
<div className="flex gap-2">
<Dialog>
<DialogTrigger className="flex flex-row gap-2">
<LockIcon className="size-4 text-muted-foreground" />?
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Private Repository</DialogTitle>
<DialogDescription>
If your repository is private is necessary to
generate SSH Keys to add to your git provider.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="relative">
<Textarea
placeholder="Please click on Generate SSH Key"
className="no-scrollbar h-64 text-muted-foreground"
disabled={!data?.customGitSSHKey}
contentEditable={false}
value={
data?.customGitSSHKey ||
"Please click on Generate SSH Key"
}
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(
data?.customGitSSHKey ||
"Generate a SSH Key",
);
toast.success("SSH Copied to clipboard");
}}
>
<CopyIcon className="size-4" />
</button>
</div>
</div>
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full">
<div className="flex flex-row gap-2 w-full justify-between flex-wrap">
{data?.customGitSSHKey && (
<Button
variant="destructive"
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await removeSSHKey({
composeId,
})
.then(async () => {
toast.success("SSH Key Removed");
await refetch();
})
.catch(() => {
toast.error(
"Error to remove the SSH Key",
);
});
}}
type="button"
>
Remove SSH Key
</Button>
)}
<Button
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await generateSSHKey({
composeId,
})
.then(async () => {
toast.success("SSH Key Generated");
await refetch();
})
.catch(() => {
toast.error(
"Error to generate the SSH Key",
);
});
}}
type="button"
>
Generate SSH Key
</Button>
</div>
<span className="text-sm text-muted-foreground">
Is recommended to remove the SSH Key if you want
to deploy a public repository.
</span>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<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="composePath"
render={({ field }) => (
<FormItem>
<FormLabel>Compose Path</FormLabel>
<FormControl>
<Input placeholder="docker-compose.yml" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}>
Save{" "}
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,310 @@
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const GithubProviderSchema = z.object({
composePath: z.string().min(1),
repository: z
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z.string().min(1, "Branch is required"),
});
type GithubProvider = z.infer<typeof GithubProviderSchema>;
interface Props {
composeId: string;
}
export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGithubProvider } =
api.compose.update.useMutation();
const form = useForm<GithubProvider>({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
owner: "",
repo: "",
},
branch: "",
},
resolver: zodResolver(GithubProviderSchema),
});
const repository = form.watch("repository");
const { data: repositories, isLoading: isLoadingRepositories } =
api.admin.getRepositories.useQuery();
const {
data: branches,
fetchStatus,
status,
} = api.admin.getBranches.useQuery(
{
owner: repository?.owner,
repo: repository?.repo,
},
{ enabled: !!repository?.owner && !!repository?.repo },
);
useEffect(() => {
if (data) {
form.reset({
branch: data.branch || "",
repository: {
repo: data.repository || "",
owner: data.owner || "",
},
composePath: data.composePath,
});
}
}, [form.reset, data, form]);
const onSubmit = async (data: GithubProvider) => {
console.log(data);
await mutateAsync({
branch: data.branch,
repository: data.repository.repo,
composeId: composeId,
owner: data.repository.owner,
sourceType: "github",
composePath: data.composePath,
})
.then(async () => {
toast.success("Service Provided Saved");
await refetch();
})
.catch(() => {
toast.error("Error to save the github provider");
});
};
return (
<div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 py-3"
>
<div className="grid md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Repository</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name
: "Select repository"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.url}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
owner: repo.owner.login as string,
repo: repo.name,
});
form.setValue("branch", "");
}}
>
{repo.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
{form.formState.errors.repository && (
<p className={cn("text-sm font-medium text-destructive")}>
Repository is required
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="block w-full">
<FormLabel>Branch</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
" w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
(branch) => branch.name === field.value,
)?.name
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>
)}
{!repository?.owner && (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a repository
</span>
)}
<ScrollArea className="h-96">
<CommandEmpty>No branch found.</CommandEmpty>
<CommandGroup>
{branches?.map((branch) => (
<CommandItem
value={branch.name}
key={branch.commit.sha}
onSelect={() => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
branch.name === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
<FormMessage />
</Popover>
</FormItem>
)}
/>
<FormField
control={form.control}
name="composePath"
render={({ field }) => (
<FormItem>
<FormLabel>Compose Path</FormLabel>
<FormControl>
<Input placeholder="docker-compose.yml" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button
isLoading={isSavingGithubProvider}
type="submit"
className="w-fit"
>
Save
</Button>
</div>
</form>
</Form>
</div>
);
};

View File

@@ -0,0 +1,97 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import { GitBranch, LockIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { SaveGithubProviderCompose } from "./save-github-provider-compose";
import { ComposeFileEditor } from "../compose-file-editor";
import { SaveGitProviderCompose } from "./save-git-provider-compose";
type TabState = "github" | "git" | "raw";
interface Props {
composeId: string;
}
export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: haveGithubConfigured } =
api.admin.haveGithubConfigured.useQuery();
const { data: compose } = api.compose.one.useQuery({ composeId });
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Select the source of your code
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<Tabs
value={tab}
className="w-full"
onValueChange={(e) => {
setSab(e as TabState);
}}
>
<TabsList className="grid w-fit grid-cols-4 bg-transparent">
<TabsTrigger
value="github"
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
Github
</TabsTrigger>
<TabsTrigger
value="git"
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
Git
</TabsTrigger>
<TabsTrigger
value="raw"
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
Raw
</TabsTrigger>
</TabsList>
<TabsContent value="github" className="w-full p-2">
{haveGithubConfigured ? (
<SaveGithubProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3">
<LockIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitHub, you need to configure your account
first. Please, go to{" "}
<Link
href="/dashboard/settings/server"
className="text-foreground"
>
Settings
</Link>{" "}
to do so.
</span>
</div>
)}
</TabsContent>
<TabsContent value="git" className="w-full p-2">
<SaveGitProviderCompose composeId={composeId} />
</TabsContent>
<TabsContent value="raw" className="w-full p-2 flex flex-col gap-4">
<ComposeFileEditor composeId={composeId} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,98 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { Dices } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
interface Props {
composeId: string;
}
export const RandomizeCompose = ({ composeId }: Props) => {
const utils = api.useUtils();
const [prefix, setPrefix] = useState<string>("");
const [compose, setCompose] = useState<string>("");
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, error, isError } =
api.compose.randomizeCompose.useMutation();
const onSubmit = async () => {
await mutateAsync({
composeId,
prefix,
})
.then(async (data) => {
await utils.project.all.invalidate();
setCompose(data);
toast.success("Compose randomized");
})
.catch(() => {
toast.error("Error to randomize the compose");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild onClick={() => onSubmit()}>
<Button className="max-lg:w-full" variant="outline">
<Dices className="h-4 w-4" />
Randomize Compose
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-[50rem] overflow-y-auto">
<DialogHeader>
<DialogTitle>Randomize Compose (Experimental)</DialogTitle>
<DialogDescription>
Use this in case you want to deploy the same compose file and you
have conflicts with some property like volumes, networks, etc.
</DialogDescription>
</DialogHeader>
<div className="text-sm text-muted-foreground flex flex-col gap-2">
<span>
This will randomize the compose file and will add a prefix to the
property to avoid conflicts
</span>
<ul className="list-disc list-inside">
<li>volumes</li>
<li>networks</li>
<li>services</li>
<li>configs</li>
<li>secrets</li>
</ul>
</div>
<div className="flex flex-col lg:flex-row gap-2">
<Input
placeholder="Enter a prefix (Optional, example: prod)"
onChange={(e) => setPrefix(e.target.value)}
/>
<Button
type="submit"
onClick={async () => {
await onSubmit();
}}
className="lg:w-fit w-full"
>
Random
</Button>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="p-4 bg-secondary rounded-lg">
<pre>
<code className="language-yaml">{compose}</code>
</pre>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,85 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { Hammer } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const RedbuildCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: markRunning } = api.compose.update.useMutation();
const { mutateAsync } = api.compose.redeploy.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
>
Rebuild
<Hammer className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to rebuild the compose?
</AlertDialogTitle>
<AlertDialogDescription>
Is required to deploy at least 1 time in order to reuse the same
code
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await markRunning({
composeId,
composeStatus: "running",
})
.then(async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose rebuild succesfully");
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,47 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import React from "react";
import { ShowProviderFormCompose } from "./generic/show";
import { ComposeActions } from "./actions";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {
composeId: string;
}
export const ShowGeneralCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{ composeId },
{
enabled: !!composeId,
},
);
return (
<>
<Card className="bg-background">
<CardHeader>
<div className="flex flex-row gap-2 justify-between flex-wrap">
<CardTitle className="text-xl">Deploy Settings</CardTitle>
<Badge>
{data?.composeType === "docker-compose" ? "Compose" : "Stack"}
</Badge>
</div>
<CardDescription>
Create a compose file to deploy your compose
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 flex-wrap">
<ComposeActions composeId={composeId} />
</CardContent>
</Card>
<ShowProviderFormCompose composeId={composeId} />
</>
);
};

View File

@@ -0,0 +1,79 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { Ban } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const StopCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: markRunning } = api.compose.update.useMutation();
const { mutateAsync, isLoading } = api.compose.stop.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Stop
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure to stop the compose?</AlertDialogTitle>
<AlertDialogDescription>
This will stop the compose services
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await markRunning({
composeId,
composeStatus: "running",
})
.then(async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose rebuild succesfully");
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,88 @@
import dynamic from "next/dynamic";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { Label } from "@/components/ui/label";
export const DockerLogs = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
}
export const ShowDockerLogsCompose = ({ appName }: Props) => {
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
},
{
enabled: !!appName,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
useEffect(() => {
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
}
}, [data]);
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Logs</CardTitle>
<CardDescription>
Watch the logs of the application in real time
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Label>Select a container to view logs</Label>
<Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger>
<SelectValue placeholder="Select a container" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{data?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}) {container.state}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<DockerLogs
id="terminal"
containerId={containerId || "select-a-container"}
/>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,85 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { DockerMonitoring } from "../../monitoring/docker/show";
interface Props {
appName: string;
appType: "stack" | "docker-compose";
}
export const ShowMonitoringCompose = ({
appName,
appType = "stack",
}: Props) => {
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName: appName,
},
{
enabled: !!appName,
},
);
const [containerAppName, setContainerAppName] = useState<
string | undefined
>();
useEffect(() => {
if (data && data?.length > 0) {
setContainerAppName(data[0]?.name);
}
}, [data]);
return (
<div>
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Monitoring</CardTitle>
<CardDescription>Watch the usage of your compose</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Label>Select a container to watch the monitoring</Label>
<Select onValueChange={setContainerAppName} value={containerAppName}>
<SelectTrigger>
<SelectValue placeholder="Select a container" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{data?.map((container) => (
<SelectItem
key={container.containerId}
value={container.name}
>
{container.name} ({container.containerId}) {container.state}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<DockerMonitoring
appName={containerAppName || ""}
appType={appType}
/>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,159 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { SquarePen } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
const updateComposeSchema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
});
type UpdateCompose = z.infer<typeof updateComposeSchema>;
interface Props {
composeId: string;
}
export const UpdateCompose = ({ composeId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
api.compose.update.useMutation();
const { data } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const form = useForm<UpdateCompose>({
defaultValues: {
description: data?.description ?? "",
name: data?.name ?? "",
},
resolver: zodResolver(updateComposeSchema),
});
useEffect(() => {
if (data) {
form.reset({
description: data.description ?? "",
name: data.name,
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: UpdateCompose) => {
await mutateAsync({
name: formData.name,
composeId: composeId,
description: formData.description || "",
})
.then(() => {
toast.success("Compose updated succesfully");
utils.compose.one.invalidate({
composeId: composeId,
});
})
.catch(() => {
toast.error("Error to update the Compose");
})
.finally(() => {});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Modify Compose</DialogTitle>
<DialogDescription>Update the compose data</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-update-compose"
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Tesla" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Description about your project..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-compose"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,18 +1,18 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
@@ -21,213 +21,218 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesMariadb = z.object({
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
});
interface Props {
mariadbId: string;
mariadbId: string;
}
type AddResourcesMariadb = z.infer<typeof addResourcesMariadb>;
export const ShowMariadbResources = ({ mariadbId }: Props) => {
const { data, refetch } = api.mariadb.one.useQuery(
{
mariadbId,
},
{ enabled: !!mariadbId },
);
const { mutateAsync, isLoading } = api.mariadb.update.useMutation();
const form = useForm<AddResourcesMariadb>({
defaultValues: {},
resolver: zodResolver(addResourcesMariadb),
});
const { data, refetch } = api.mariadb.one.useQuery(
{
mariadbId,
},
{ enabled: !!mariadbId },
);
const { mutateAsync, isLoading } = api.mariadb.update.useMutation();
const form = useForm<AddResourcesMariadb>({
defaultValues: {},
resolver: zodResolver(addResourcesMariadb),
});
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (formData: AddResourcesMariadb) => {
await mutateAsync({
mariadbId,
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<FormLabel>Memory Reservation</FormLabel>
<FormControl>
<Input
placeholder="256 MB"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
const onSubmit = async (formData: AddResourcesMariadb) => {
await mutateAsync({
mariadbId,
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<FormLabel>Memory Reservation</FormLabel>
<FormControl>
<Input
placeholder="256 MB"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Memory Limit</FormLabel>
<FormControl>
<Input
placeholder={"1024 MB"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Memory Limit</FormLabel>
<FormControl>
<Input
placeholder={"1024 MB"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -19,7 +19,7 @@ import {
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { CodeEditor } from "@/components/shared/code-editor";
const addEnvironmentSchema = z.object({
environment: z.string(),
@@ -93,8 +93,11 @@ export const ShowMariadbEnvironment = ({ mariadbId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="MARIADB_PASSWORD=1234567678"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>

View File

@@ -50,17 +50,13 @@ export const DeployMariadb = ({ mariadbId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
mariadbId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@@ -22,6 +22,7 @@ import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
@@ -136,7 +137,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-3">
{/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */}
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
mariadbId: string;
@@ -29,20 +30,18 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
type="password"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Root Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databaseRootPassword}
type="password"
/>
</div>
</div>
@@ -58,7 +57,7 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`mariadb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:3306/${data?.databaseName}`}
/>

View File

@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
mariadbId: string;
}
@@ -59,15 +61,12 @@ export const ShowVolumes = ({ mariadbId }: Props) => {
</AddVolumes>
</div>
) : (
<div className="flex flex-col pt-2">
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</span>
</div>
<div className="flex flex-col gap-6 pt-6">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
@@ -113,7 +112,12 @@ export const ShowVolumes = ({ mariadbId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

View File

@@ -1,18 +1,18 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
@@ -21,213 +21,222 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesMongo = z.object({
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
});
interface Props {
mongoId: string;
mongoId: string;
}
type AddResourcesMongo = z.infer<typeof addResourcesMongo>;
export const ShowMongoResources = ({ mongoId }: Props) => {
const { data, refetch } = api.mongo.one.useQuery(
{
mongoId,
},
{ enabled: !!mongoId },
);
const { mutateAsync, isLoading } = api.mongo.update.useMutation();
const form = useForm<AddResourcesMongo>({
defaultValues: {},
resolver: zodResolver(addResourcesMongo),
});
const { data, refetch } = api.mongo.one.useQuery(
{
mongoId,
},
{ enabled: !!mongoId },
);
const { mutateAsync, isLoading } = api.mongo.update.useMutation();
const form = useForm<AddResourcesMongo>({
defaultValues: {},
resolver: zodResolver(addResourcesMongo),
});
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.reset]);
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: AddResourcesMongo) => {
await mutateAsync({
mongoId,
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<FormLabel>Memory Reservation</FormLabel>
<FormControl>
<Input
placeholder="256 MB"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
const onSubmit = async (formData: AddResourcesMongo) => {
await mutateAsync({
mongoId,
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to
apply the changes.
</AlertBlock>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<FormLabel>Memory Reservation</FormLabel>
<FormControl>
<Input
placeholder="256 MB"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Memory Limit</FormLabel>
<FormControl>
<Input
placeholder={"1024 MB"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Memory Limit</FormLabel>
<FormControl>
<Input
placeholder={"1024 MB"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -1,3 +1,4 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -13,7 +14,6 @@ import {
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect } from "react";
@@ -93,8 +93,11 @@ export const ShowMongoEnvironment = ({ mongoId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="MONGO_PASSWORD=1234567678"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>

View File

@@ -50,17 +50,13 @@ export const DeployMongo = ({ mongoId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
mongoId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -136,7 +137,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
mongoId: string;
@@ -26,10 +27,9 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
type="password"
/>
</div>
</div>
@@ -46,7 +46,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
/>

View File

@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
mongoId: string;
}
@@ -55,15 +57,12 @@ export const ShowVolumes = ({ mongoId }: Props) => {
</AddVolumes>
</div>
) : (
<div className="flex flex-col pt-2">
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</span>
</div>
<div className="flex flex-col gap-6 pt-6">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
@@ -109,7 +108,12 @@ export const ShowVolumes = ({ mongoId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

View File

@@ -14,8 +14,43 @@ import { DockerNetworkChart } from "./docker-network-chart";
import { DockerDiskChart } from "./docker-disk-chart";
import { api } from "@/utils/api";
const defaultData = {
cpu: {
value: 0,
time: "",
},
memory: {
value: {
used: 0,
free: 0,
usedPercentage: 0,
total: 0,
},
time: "",
},
block: {
value: {
readMb: 0,
writeMb: 0,
},
time: "",
},
network: {
value: {
inputMb: 0,
outputMb: 0,
},
time: "",
},
disk: {
value: { diskTotal: 0, diskUsage: 0, diskUsedPercentage: 0, diskFree: 0 },
time: "",
},
};
interface Props {
appName: string;
appType?: "application" | "stack" | "docker-compose";
}
export interface DockerStats {
cpu: {
@@ -65,7 +100,10 @@ export type DockerStatsJSON = {
disk: DockerStats["disk"][];
};
export const DockerMonitoring = ({ appName }: Props) => {
export const DockerMonitoring = ({
appName,
appType = "application",
}: Props) => {
const { data } = api.application.readAppMonitoring.useQuery(
{ appName },
{
@@ -79,39 +117,19 @@ export const DockerMonitoring = ({ appName }: Props) => {
network: [],
disk: [],
});
const [currentData, setCurrentData] = useState<DockerStats>({
cpu: {
value: 0,
time: "",
},
memory: {
value: {
used: 0,
free: 0,
usedPercentage: 0,
total: 0,
},
time: "",
},
block: {
value: {
readMb: 0,
writeMb: 0,
},
time: "",
},
network: {
value: {
inputMb: 0,
outputMb: 0,
},
time: "",
},
disk: {
value: { diskTotal: 0, diskUsage: 0, diskUsedPercentage: 0, diskFree: 0 },
time: "",
},
});
const [currentData, setCurrentData] = useState<DockerStats>(defaultData);
useEffect(() => {
setCurrentData(defaultData);
setAcummulativeData({
cpu: [],
memory: [],
block: [],
network: [],
disk: [],
});
}, [appName]);
useEffect(() => {
if (!data) return;
@@ -128,7 +146,7 @@ export const DockerMonitoring = ({ appName }: Props) => {
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}`;
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => {

View File

@@ -1,18 +1,18 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
@@ -21,213 +21,218 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesMysql = z.object({
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
});
interface Props {
mysqlId: string;
mysqlId: string;
}
type AddResourcesMysql = z.infer<typeof addResourcesMysql>;
export const ShowMysqlResources = ({ mysqlId }: Props) => {
const { data, refetch } = api.mysql.one.useQuery(
{
mysqlId,
},
{ enabled: !!mysqlId },
);
const { mutateAsync, isLoading } = api.mysql.update.useMutation();
const form = useForm<AddResourcesMysql>({
defaultValues: {},
resolver: zodResolver(addResourcesMysql),
});
const { data, refetch } = api.mysql.one.useQuery(
{
mysqlId,
},
{ enabled: !!mysqlId },
);
const { mutateAsync, isLoading } = api.mysql.update.useMutation();
const form = useForm<AddResourcesMysql>({
defaultValues: {},
resolver: zodResolver(addResourcesMysql),
});
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.reset]);
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: AddResourcesMysql) => {
await mutateAsync({
mysqlId,
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<FormLabel>Memory Reservation</FormLabel>
<FormControl>
<Input
placeholder="256 MB"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
const onSubmit = async (formData: AddResourcesMysql) => {
await mutateAsync({
mysqlId,
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<FormLabel>Memory Reservation</FormLabel>
<FormControl>
<Input
placeholder="256 MB"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Memory Limit</FormLabel>
<FormControl>
<Input
placeholder={"1024 MB"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Memory Limit</FormLabel>
<FormControl>
<Input
placeholder={"1024 MB"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -19,7 +19,7 @@ import {
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { CodeEditor } from "@/components/shared/code-editor";
const addEnvironmentSchema = z.object({
environment: z.string(),
@@ -93,8 +93,11 @@ export const ShowMysqlEnvironment = ({ mysqlId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="MYSQL_PASSWORD=1234567678"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>

View File

@@ -50,17 +50,13 @@ export const DeployMysql = ({ mysqlId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
mysqlId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -136,7 +137,7 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput disabled value={connectionUrl} />
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
mysqlId: string;
@@ -29,20 +30,18 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
type="password"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Root Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databaseRootPassword}
type="password"
/>
</div>
</div>
@@ -58,7 +57,7 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`mysql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:3306/${data?.databaseName}`}
/>

View File

@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
mysqlId: string;
}
@@ -55,15 +57,12 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
</AddVolumes>
</div>
) : (
<div className="flex flex-col pt-2">
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</span>
</div>
<div className="flex flex-col gap-6 pt-6">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
@@ -109,7 +108,12 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

View File

@@ -21,6 +21,7 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesPostgres = z.object({
memoryReservation: z.number().nullable().optional(),
@@ -83,6 +84,10 @@ export const ShowPostgresResources = ({ postgresId }: Props) => {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<form
id="hook-form"

View File

@@ -19,7 +19,7 @@ import {
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { CodeEditor } from "@/components/shared/code-editor";
const addEnvironmentSchema = z.object({
environment: z.string(),
@@ -93,8 +93,11 @@ export const ShowPostgresEnvironment = ({ postgresId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="POSTGRES_PASSWORD=1234567678"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>

View File

@@ -50,17 +50,13 @@ export const DeployPostgres = ({ postgresId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
postgresId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -137,7 +138,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
postgresId: string;
@@ -29,10 +30,9 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
disabled
<ToggleVisibilityInput
value={data?.databasePassword}
type="password"
disabled
/>
</div>
</div>
@@ -48,7 +48,7 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`}
/>

View File

@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
postgresId: string;
}
@@ -59,15 +61,12 @@ export const ShowVolumes = ({ postgresId }: Props) => {
</AddVolumes>
</div>
) : (
<div className="flex flex-col pt-2">
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</span>
</div>
<div className="flex flex-col gap-6 pt-6">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
@@ -114,7 +113,12 @@ export const ShowVolumes = ({ postgresId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

View File

@@ -22,16 +22,26 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Folder } from "lucide-react";
import { useEffect } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
import { slugify } from "@/lib/slug";
const AddTemplateSchema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
appName: z
.string()
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}),
description: z.string().optional(),
});
@@ -39,10 +49,13 @@ type AddTemplate = z.infer<typeof AddTemplateSchema>;
interface Props {
projectId: string;
projectName?: string;
}
export const AddApplication = ({ projectId }: Props) => {
export const AddApplication = ({ projectId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { mutateAsync, isLoading, error, isError } =
api.application.create.useMutation();
@@ -50,34 +63,34 @@ export const AddApplication = ({ projectId }: Props) => {
const form = useForm<AddTemplate>({
defaultValues: {
name: "",
appName: `${slug}-`,
description: "",
},
resolver: zodResolver(AddTemplateSchema),
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: AddTemplate) => {
await mutateAsync({
name: data.name,
appName: data.appName,
description: data.description,
projectId,
})
.then(async () => {
toast.success("Service Created");
form.reset();
setVisible(false);
await utils.project.one.invalidate({
projectId,
});
})
.catch(() => {
.catch((e) => {
toast.error("Error to create the service");
});
};
return (
<Dialog>
<Dialog open={visible} onOpenChange={setVisible}>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
@@ -95,29 +108,46 @@ export const AddApplication = ({ projectId }: Props) => {
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Frontend" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Frontend"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
form.setValue("appName", `${slug}-${val}`);
field.onChange(val);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>AppName</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
@@ -136,47 +166,6 @@ export const AddApplication = ({ projectId }: Props) => {
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="buildType"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Build Type</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="dockerfile" />
</FormControl>
<FormLabel className="font-normal">
Dockerfile
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="nixpacks" />
</FormControl>
<FormLabel className="font-normal">Nixpacks</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="heroku_buildpacks" />
</FormControl>
<FormLabel className="font-normal">
Heroku Buildpacks
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
</form>
<DialogFooter>

View File

@@ -0,0 +1,219 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { AlertBlock } from "@/components/shared/alert-block";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CircuitBoard, Folder } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { slugify } from "@/lib/slug";
const AddComposeSchema = z.object({
composeType: z.enum(["docker-compose", "stack"]).optional(),
name: z.string().min(1, {
message: "Name is required",
}),
appName: z
.string()
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}),
description: z.string().optional(),
});
type AddCompose = z.infer<typeof AddComposeSchema>;
interface Props {
projectId: string;
projectName?: string;
}
export const AddCompose = ({ projectId, projectName }: Props) => {
const utils = api.useUtils();
const slug = slugify(projectName);
const { mutateAsync, isLoading, error, isError } =
api.compose.create.useMutation();
const form = useForm<AddCompose>({
defaultValues: {
name: "",
description: "",
composeType: "docker-compose",
appName: `${slug}-`,
},
resolver: zodResolver(AddComposeSchema),
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: AddCompose) => {
await mutateAsync({
name: data.name,
description: data.description,
projectId,
composeType: data.composeType,
appName: data.appName,
})
.then(async () => {
toast.success("Compose Created");
await utils.project.one.invalidate({
projectId,
});
})
.catch(() => {
toast.error("Error to create the compose");
});
};
return (
<Dialog>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<CircuitBoard className="size-4 text-muted-foreground" />
<span>Compose</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Create Compose</DialogTitle>
<DialogDescription>
Assign a name and description to your compose
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Frontend"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
form.setValue("appName", `${slug}-${val}`);
field.onChange(val);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>AppName</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="composeType"
render={({ field }) => (
<FormItem>
<FormLabel>Compose Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a compose type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="docker-compose">
Docker Compose
</SelectItem>
<SelectItem value="stack">Stack</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Description about your service..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
<DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit">
Create
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -27,10 +27,11 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Database } from "lucide-react";
import { useEffect } from "react";
import { Database, AlertTriangle } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -58,6 +59,15 @@ const databasesUserDefaultPlaceholder: Record<
const baseDatabaseSchema = z.object({
name: z.string().min(1, "Name required"),
appName: z
.string()
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}),
databasePassword: z.string(),
dockerImage: z.string(),
description: z.string().nullable(),
@@ -101,30 +111,52 @@ const mySchema = z.discriminatedUnion("type", [
.merge(baseDatabaseSchema),
]);
const databasesMap = {
postgres: {
icon: <PostgresqlIcon />,
label: "PostgreSQL",
},
mongo: {
icon: <MongodbIcon />,
label: "MongoDB",
},
mariadb: {
icon: <MariadbIcon />,
label: "MariaDB",
},
mysql: {
icon: <MysqlIcon />,
label: "MySQL",
},
redis: {
icon: <RedisIcon />,
label: "Redis",
},
};
type AddDatabase = z.infer<typeof mySchema>;
interface Props {
projectId: string;
projectName?: string;
}
export const AddDatabase = ({ projectId }: Props) => {
export const AddDatabase = ({ projectId, projectName }: Props) => {
const utils = api.useUtils();
const { mutateAsync: createPostgresql } = api.postgres.create.useMutation();
const { mutateAsync: createMongo } = api.mongo.create.useMutation();
const { mutateAsync: createRedis } = api.redis.create.useMutation();
const { mutateAsync: createMariadb } = api.mariadb.create.useMutation();
const { mutateAsync: createMysql } = api.mysql.create.useMutation();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const postgresMutation = api.postgres.create.useMutation();
const mongoMutation = api.mongo.create.useMutation();
const redisMutation = api.redis.create.useMutation();
const mariadbMutation = api.mariadb.create.useMutation();
const mysqlMutation = api.mysql.create.useMutation();
const form = useForm<AddDatabase>({
defaultValues: {
type: "postgres",
dockerImage: "",
name: "",
appName: `${slug}-`,
databasePassword: "",
description: "",
databaseName: "",
@@ -133,76 +165,65 @@ export const AddDatabase = ({ projectId }: Props) => {
resolver: zodResolver(mySchema),
});
const type = form.watch("type");
useEffect(() => {
form.reset({
type: "postgres",
dockerImage: "",
name: "",
databasePassword: "",
description: "",
databaseName: "",
databaseUser: "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const activeMutation = {
postgres: postgresMutation,
mongo: mongoMutation,
redis: redisMutation,
mariadb: mariadbMutation,
mysql: mysqlMutation,
};
const onSubmit = async (data: AddDatabase) => {
const defaultDockerImage =
data.dockerImage || dockerImageDefaultPlaceholder[data.type];
let promise: Promise<unknown> | null = null;
const commonParams = {
name: data.name,
appName: data.appName,
dockerImage: defaultDockerImage,
projectId,
description: data.description,
};
if (data.type === "postgres") {
promise = createPostgresql({
name: data.name,
dockerImage: defaultDockerImage,
promise = postgresMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseName: data.databaseName,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
projectId,
description: data.description,
});
} else if (data.type === "mongo") {
promise = createMongo({
name: data.name,
dockerImage: defaultDockerImage,
promise = mongoMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
projectId,
description: data.description,
});
} else if (data.type === "redis") {
promise = createRedis({
name: data.name,
dockerImage: defaultDockerImage,
promise = redisMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
projectId,
description: data.description,
});
} else if (data.type === "mariadb") {
promise = createMariadb({
name: data.name,
dockerImage: defaultDockerImage,
promise = mariadbMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
projectId,
databaseRootPassword: data.databaseRootPassword,
databaseName: data.databaseName,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
description: data.description,
});
} else if (data.type === "mysql") {
promise = createMysql({
name: data.name,
dockerImage: defaultDockerImage,
promise = mysqlMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseName: data.databaseName,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
projectId,
databaseRootPassword: data.databaseRootPassword,
description: data.description,
});
}
@@ -210,6 +231,17 @@ export const AddDatabase = ({ projectId }: Props) => {
await promise
.then(async () => {
toast.success("Database Created");
form.reset({
type: "postgres",
dockerImage: "",
name: "",
appName: `${projectName}-`,
databasePassword: "",
description: "",
databaseName: "",
databaseUser: "",
});
setVisible(false);
await utils.project.one.invalidate({
projectId,
});
@@ -220,7 +252,7 @@ export const AddDatabase = ({ projectId }: Props) => {
}
};
return (
<Dialog>
<Dialog open={visible} onOpenChange={setVisible}>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
@@ -230,18 +262,10 @@ export const AddDatabase = ({ projectId }: Props) => {
<span>Database</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-screen md:max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Databases</DialogTitle>
</DialogHeader>
{/* {isError && (
<div className="flex items-center flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)} */}
<Form {...form}>
<form
@@ -264,99 +288,40 @@ export const AddDatabase = ({ projectId }: Props) => {
defaultValue={field.value}
className="grid w-full grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
>
<FormItem className="flex w-full items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="postgres"
id="postgres"
className="peer sr-only"
/>
<Label
htmlFor="postgres"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
<PostgresqlIcon />
Postgresql
</Label>
</div>
</FormControl>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="mysql"
id="mysql"
className="peer sr-only"
/>
<Label
htmlFor="mysql"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
<MysqlIcon />
Mysql
</Label>
</div>
</FormControl>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="mariadb"
id="mariadb"
className="peer sr-only"
/>
<Label
htmlFor="mariadb"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
<MariadbIcon />
Mariadb
</Label>
</div>
</FormControl>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="mongo"
id="mongo"
className="peer sr-only"
/>
<Label
htmlFor="mongo"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
<MongodbIcon />
Mongo
</Label>
</div>
</FormControl>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="redis"
id="redis"
className="peer sr-only"
/>
<Label
htmlFor="redis"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
<RedisIcon />
Redis
</Label>
</div>
</FormControl>
</FormItem>
{Object.entries(databasesMap).map(([key, value]) => (
<FormItem
key={key}
className="flex w-full items-center space-x-3 space-y-0"
>
<FormControl className="w-full">
<div>
<RadioGroupItem
value={key}
id={key}
className="peer sr-only"
/>
<Label
htmlFor={key}
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
>
{value.icon}
{value.label}
</Label>
</div>
</FormControl>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
{activeMutation[field.value].isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{activeMutation[field.value].error?.message}
</span>
</div>
)}
</FormItem>
)}
/>
@@ -372,13 +337,34 @@ export const AddDatabase = ({ projectId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Name" {...field} />
<Input
placeholder="Name"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
form.setValue("appName", `${slug}-${val}`);
field.onChange(val);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>AppName</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}

View File

@@ -0,0 +1,209 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { AlertBlock } from "@/components/shared/alert-block";
import { api } from "@/utils/api";
import { Code, Github, Globe, PuzzleIcon } from "lucide-react";
import Link from "next/link";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
interface Props {
projectId: string;
}
export const AddTemplate = ({ projectId }: Props) => {
const [query, setQuery] = useState("");
const { data } = api.compose.templates.useQuery();
const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } =
api.compose.deployTemplate.useMutation();
const templates = data?.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()),
);
return (
<Dialog>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<PuzzleIcon className="size-4 text-muted-foreground" />
<span>Templates</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl p-0">
<div className="sticky top-0 z-10 flex flex-col gap-4 bg-black p-6 border-b">
<DialogHeader>
<DialogTitle>Create Template</DialogTitle>
<DialogDescription>
Deploy a open source template to your project
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Input
placeholder="Search Template"
onChange={(e) => setQuery(e.target.value)}
value={query}
/>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 w-full gap-4">
{templates?.map((template, index) => (
<div key={`template-${index}`}>
<div
key={template.id}
className="flex flex-col gap-4 border p-6 rounded-lg h-full"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col items-center gap-2">
<img
src={`/templates/${template.logo}`}
className="size-28 object-contain"
alt=""
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 justify-center items-center">
<div className="flex flex-col gap-2 items-center justify-center">
<div className="flex flex-row gap-2 flex-wrap">
<span className="text-sm font-medium">
{template.name}
</span>
<Badge>{template.version}</Badge>
</div>
<div className="flex flex-row gap-0">
<Link
href={template.links.github}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Github className="size-4 text-muted-foreground" />
</Link>
{template.links.website && (
<Link
href={template.links.website}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Globe className="size-4 text-muted-foreground" />
</Link>
)}
{template.links.docs && (
<Link
href={template.links.docs}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Globe className="size-4 text-muted-foreground" />
</Link>
)}
<Link
href={`https://github.com/dokploy/dokploy/tree/canary/templates/${template.id}`}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Code className="size-4 text-muted-foreground" />
</Link>
</div>
<div className="flex flex-row gap-2 flex-wrap justify-center">
{template.tags.map((tag) => (
<Badge variant="secondary" key={tag}>
{tag}
</Badge>
))}
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button onSelect={(e) => e.preventDefault()}>
Deploy
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription>
This will deploy {template.name} template to
your project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
projectId,
id: template.id,
})
.then(async () => {
toast.success(
`${template.name} template created succesfully`,
);
utils.project.one.invalidate({
projectId,
});
})
.catch(() => {
toast.error(
`Error to delete ${template.name} template`,
);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<p className="text-sm text-muted-foreground">
{template.description}
</p>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -64,6 +64,7 @@ export const ShowProjects = () => {
project?.postgres.length === 0 &&
project?.redis.length === 0 &&
project?.applications.length === 0;
project?.compose.length === 0;
const totalServices =
project?.mariadb.length +
@@ -71,7 +72,8 @@ export const ShowProjects = () => {
project?.mysql.length +
project?.postgres.length +
project?.redis.length +
project?.applications.length;
project?.applications.length +
project?.compose.length;
return (
<div key={project.projectId} className="w-full lg:max-w-md">
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
@@ -89,7 +91,10 @@ export const ShowProjects = () => {
<span className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
<Link className="text-base font-medium leading-none" href={`/dashboard/project/${project.projectId}`}>
<Link
className="text-base font-medium leading-none"
href={`/dashboard/project/${project.projectId}`}
>
{project.name}
</Link>
</div>

View File

@@ -1,18 +1,18 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
@@ -21,213 +21,218 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesRedis = z.object({
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
});
interface Props {
redisId: string;
redisId: string;
}
type AddResourcesRedis = z.infer<typeof addResourcesRedis>;
export const ShowRedisResources = ({ redisId }: Props) => {
const { data, refetch } = api.redis.one.useQuery(
{
redisId,
},
{ enabled: !!redisId },
);
const { mutateAsync, isLoading } = api.redis.update.useMutation();
const form = useForm<AddResourcesRedis>({
defaultValues: {},
resolver: zodResolver(addResourcesRedis),
});
const { data, refetch } = api.redis.one.useQuery(
{
redisId,
},
{ enabled: !!redisId },
);
const { mutateAsync, isLoading } = api.redis.update.useMutation();
const form = useForm<AddResourcesRedis>({
defaultValues: {},
resolver: zodResolver(addResourcesRedis),
});
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (formData: AddResourcesRedis) => {
await mutateAsync({
redisId,
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<FormLabel>Memory Reservation</FormLabel>
<FormControl>
<Input
placeholder="256 MB"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
const onSubmit = async (formData: AddResourcesRedis) => {
await mutateAsync({
redisId,
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<FormLabel>Memory Reservation</FormLabel>
<FormControl>
<Input
placeholder="256 MB"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Memory Limit</FormLabel>
<FormControl>
<Input
placeholder={"1024 MB"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Memory Limit</FormLabel>
<FormControl>
<Input
placeholder={"1024 MB"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -19,7 +19,7 @@ import {
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { CodeEditor } from "@/components/shared/code-editor";
const addEnvironmentSchema = z.object({
environment: z.string(),
@@ -93,9 +93,12 @@ export const ShowRedisEnvironment = ({ redisId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="REDIS_PASSWORD=1234567678"
className="h-96"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -50,17 +50,13 @@ export const DeployRedis = ({ redisId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database...");
await refetch();
await deploy({
redisId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -129,7 +130,7 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
redisId: string;
@@ -25,10 +26,9 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
disabled
<ToggleVisibilityInput
value={data?.databasePassword}
type="password"
disabled
/>
</div>
</div>
@@ -44,7 +44,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`redis://default:${data?.databasePassword}@${data?.appName}:6379`}
/>

View File

@@ -10,6 +10,7 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
interface Props {
redisId: string;
}
@@ -109,7 +110,12 @@ export const ShowVolumes = ({ redisId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

View File

@@ -41,6 +41,9 @@ export const WebServer = () => {
mutateAsync: cleanDockerBuilder,
isLoading: cleanDockerBuilderIsLoading,
} = api.settings.cleanDockerBuilder.useMutation();
const { mutateAsync: cleanMonitoring, isLoading: cleanMonitoringIsLoading } =
api.settings.cleanMonitoring.useMutation();
const {
mutateAsync: cleanUnusedImages,
isLoading: cleanUnusedImagesIsLoading,
@@ -253,6 +256,20 @@ export const WebServer = () => {
>
<span>Clean Docker Builder & System</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanMonitoring()
.then(async () => {
toast.success("Cleaned Monitoring");
})
.catch(() => {
toast.error("Error to clean Monitoring");
});
}}
>
<span>Clean Monitoring </span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {

View File

@@ -11,9 +11,10 @@ import { cn } from "@/lib/utils";
import Link from "next/link";
import { Logo } from "../shared/logo";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { Badge } from "../ui/badge";
import { useRouter } from "next/router";
import { api } from "@/utils/api";
import { buttonVariants } from "../ui/button";
import { HeartIcon } from "lucide-react";
export const Navbar = () => {
const router = useRouter();
@@ -36,10 +37,22 @@ export const Navbar = () => {
className={cn("flex flex-row items-center gap-2")}
>
<Logo />
<span className="text-sm font-semibold text-primary">Dokploy</span>
<span className="text-sm font-semibold text-primary max-sm:hidden">
Dokploy
</span>
</Link>
</div>
<Link
className={buttonVariants({
variant: "outline",
className: " flex items-center gap-2 !rounded-full",
})}
href="https://opencollective.com/dokploy"
target="_blank"
>
<span className="text-sm font-semibold">Support </span>
<HeartIcon className="size-4 text-red-500 fill-red-600 animate-heartbeat " />
</Link>
<ul
className="ml-auto flex h-12 max-w-fit flex-row flex-nowrap items-center gap-0 data-[justify=end]:flex-grow data-[justify=start]:flex-grow data-[justify=end]:basis-0 data-[justify=start]:basis-0 data-[justify=start]:justify-start data-[justify=end]:justify-end data-[justify=center]:justify-center"
data-justify="end"

View File

@@ -4,19 +4,21 @@ import { json } from "@codemirror/lang-json";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { cn } from "@/lib/utils";
import { useTheme } from "next-themes";
import { StreamLanguage } from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties";
interface Props extends ReactCodeMirrorProps {
wrapperClassName?: string;
disabled?: boolean;
language?: "yaml" | "json" | "properties";
}
export const CodeEditor = ({
className,
wrapperClassName,
language = "yaml",
...props
}: Props) => {
const { resolvedTheme } = useTheme();
return (
<div className={cn("relative overflow-auto", wrapperClassName)}>
<CodeMirror
@@ -28,7 +30,13 @@ export const CodeEditor = ({
allowMultipleSelections: true,
}}
theme={resolvedTheme === "dark" ? githubDark : githubLight}
extensions={[yaml(), json()]}
extensions={[
language === "yaml"
? yaml()
: language === "json"
? json()
: StreamLanguage.define(properties),
]}
{...props}
editable={!props.disabled}
className={cn(

Some files were not shown because too many files have changed in this diff Show More