mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-29 11:05:33 +02:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce200185bb | ||
|
|
ff274d4f6b | ||
|
|
306f02f5cd | ||
|
|
8f9d21c0f8 | ||
|
|
1df6db738e | ||
|
|
36eb2edc76 | ||
|
|
a310065766 | ||
|
|
7cb299a4bb | ||
|
|
71be1ed180 | ||
|
|
aa094e8472 | ||
|
|
4d3e3426ca | ||
|
|
ed6e4e8e73 | ||
|
|
b244aaa9de | ||
|
|
8a0ffbe754 | ||
|
|
d34aadde43 | ||
|
|
e174101377 | ||
|
|
eef0bf6ff7 | ||
|
|
41bdfdf78f | ||
|
|
874d1f9fe4 | ||
|
|
0867bc20bd | ||
|
|
bf2516a495 | ||
|
|
ec54c79e80 | ||
|
|
422187cd4b | ||
|
|
2362130927 | ||
|
|
0f025182f1 | ||
|
|
3a59edbc0f | ||
|
|
5f42bf63a6 | ||
|
|
baecc49d86 | ||
|
|
506fe074df | ||
|
|
7961591009 | ||
|
|
f784a4f989 | ||
|
|
957e5066aa | ||
|
|
bf133536ba | ||
|
|
683a62f418 | ||
|
|
5a70e616e6 | ||
|
|
d52f66a716 | ||
|
|
5806068e2e | ||
|
|
667067811c | ||
|
|
eda33e095e | ||
|
|
976d1f312f | ||
|
|
42e9aa1834 | ||
|
|
744f800700 | ||
|
|
08517d6f36 | ||
|
|
d19dec8010 | ||
|
|
c45017e204 | ||
|
|
6c792564ae | ||
|
|
e9245cee2c | ||
|
|
832fc184af |
13
.github/FUNDING.yml
vendored
Normal file
13
.github/FUNDING.yml
vendored
Normal 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']
|
||||||
2
.github/workflows/pull-request.yml
vendored
2
.github/workflows/pull-request.yml
vendored
@@ -30,6 +30,8 @@ jobs:
|
|||||||
run: pnpm install
|
run: pnpm install
|
||||||
- name: Run Build
|
- name: Run Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
- name: Run Tests
|
||||||
|
run: pnpm run test
|
||||||
|
|
||||||
build-and-push-docker-on-push:
|
build-and-push-docker-on-push:
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|||||||
@@ -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.
|
- Once your pull request is merged, you will be automatically added as a contributor to the project.
|
||||||
|
|
||||||
Thank you for your contribution!
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ FROM node:18-slim AS production
|
|||||||
# Install dependencies only for production
|
# Install dependencies only for production
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable && apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
RUN corepack enable && apt-get update && apt-get install -y curl && apt-get install -y apache2-utils && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -47,7 +47,6 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-l
|
|||||||
# Install docker
|
# Install docker
|
||||||
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh
|
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh
|
||||||
|
|
||||||
|
|
||||||
# Install Nixpacks and tsx
|
# Install Nixpacks and tsx
|
||||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||||
|
|||||||
11
LICENSE.MD
11
LICENSE.MD
@@ -1,5 +1,7 @@
|
|||||||
# License
|
# License
|
||||||
|
|
||||||
|
## Core License (Apache License 2.0)
|
||||||
|
|
||||||
Copyright 2024 Mauricio Siu.
|
Copyright 2024 Mauricio Siu.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
@@ -13,11 +15,12 @@ distributed under the License is distributed on an "AS IS" BASIS,
|
|||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
See the License for the specific language governing permissions and limitations under the License.
|
See the License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
## Appendix
|
## Additional Terms for Specific Features
|
||||||
|
|
||||||
In the event of a conflict, the provisions in this appendix shall take precedence over those in the Apache License.
|
The following additional terms apply to the multi-node support and Docker Compose file support features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||||
|
|
||||||
- **Modification Distribution:** Any modifications to the software must be distributed freely.
|
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support and Docker Compose file support, will always be free to use in the self-hosted version.
|
||||||
- **Future Paid Features:** Any future paid features of Dokploy cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
- **Restriction on Resale**: The multi-node support and Docker Compose file support features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
||||||
|
- **Modification Distribution**: Any modifications to the multi-node support and Docker Compose file support features must be distributed freely and cannot be sold or offered as a service.
|
||||||
|
|
||||||
For further inquiries or permissions, please contact us directly.
|
For further inquiries or permissions, please contact us directly.
|
||||||
|
|||||||
@@ -46,4 +46,4 @@ curl -sSL https://dokploy.com/install.sh | sh
|
|||||||
- Centos 9
|
- Centos 9
|
||||||
|
|
||||||
## 📄 Документация
|
## 📄 Документация
|
||||||
Для подробной документации посетите docs.dokploy.com/docs.
|
Для подробной документации посетите [docs.dokploy.com/docs](https://docs.dokploy.com).
|
||||||
|
|||||||
476
__test__/compose/compose.test.ts
Normal file
476
__test__/compose/compose.test.ts
Normal 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);
|
||||||
|
});
|
||||||
178
__test__/compose/config/config-root.test.ts
Normal file
178
__test__/compose/config/config-root.test.ts
Normal 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);
|
||||||
|
});
|
||||||
197
__test__/compose/config/config-service.test.ts
Normal file
197
__test__/compose/config/config-service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
249
__test__/compose/config/config.test.ts
Normal file
249
__test__/compose/config/config.test.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
281
__test__/compose/network/network-root.test.ts
Normal file
281
__test__/compose/network/network-root.test.ts
Normal 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);
|
||||||
|
});
|
||||||
181
__test__/compose/network/network-service.test.ts
Normal file
181
__test__/compose/network/network-service.test.ts
Normal 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");
|
||||||
|
});
|
||||||
254
__test__/compose/network/network.test.ts
Normal file
254
__test__/compose/network/network.test.ts
Normal 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);
|
||||||
|
});
|
||||||
103
__test__/compose/secrets/secret-root.test.ts
Normal file
103
__test__/compose/secrets/secret-root.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
113
__test__/compose/secrets/secret-services.test.ts
Normal file
113
__test__/compose/secrets/secret-services.test.ts
Normal 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}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
159
__test__/compose/secrets/secret.test.ts
Normal file
159
__test__/compose/secrets/secret.test.ts
Normal 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);
|
||||||
|
});
|
||||||
59
__test__/compose/service/service-container-name.test.ts
Normal file
59
__test__/compose/service/service-container-name.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
150
__test__/compose/service/service-depends-on.test.ts
Normal file
150
__test__/compose/service/service-depends-on.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
131
__test__/compose/service/service-extends.test.ts
Normal file
131
__test__/compose/service/service-extends.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
76
__test__/compose/service/service-links.test.ts
Normal file
76
__test__/compose/service/service-links.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
49
__test__/compose/service/service-names.test.ts
Normal file
49
__test__/compose/service/service-names.test.ts
Normal 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");
|
||||||
|
});
|
||||||
375
__test__/compose/service/service.test.ts
Normal file
375
__test__/compose/service/service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
76
__test__/compose/service/sevice-volumes-from.test.ts
Normal file
76
__test__/compose/service/sevice-volumes-from.test.ts
Normal 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");
|
||||||
|
});
|
||||||
1120
__test__/compose/volume/volume-2.test.ts
Normal file
1120
__test__/compose/volume/volume-2.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
195
__test__/compose/volume/volume-root.test.ts
Normal file
195
__test__/compose/volume/volume-root.test.ts
Normal 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);
|
||||||
|
});
|
||||||
81
__test__/compose/volume/volume-services.test.ts
Normal file
81
__test__/compose/volume/volume-services.test.ts
Normal 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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
288
__test__/compose/volume/volume.test.ts
Normal file
288
__test__/compose/volume/volume.test.ts
Normal 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
16
__test__/vitest.config.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { HelpCircle, Settings } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
|
||||||
|
const HealthCheckSwarmSchema = z
|
||||||
|
.object({
|
||||||
|
Test: z.array(z.string()).optional(),
|
||||||
|
Interval: z.number().optional(),
|
||||||
|
Timeout: z.number().optional(),
|
||||||
|
StartPeriod: z.number().optional(),
|
||||||
|
Retries: z.number().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const RestartPolicySwarmSchema = z
|
||||||
|
.object({
|
||||||
|
Condition: z.string().optional(),
|
||||||
|
Delay: z.number().optional(),
|
||||||
|
MaxAttempts: z.number().optional(),
|
||||||
|
Window: z.number().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const PreferenceSchema = z
|
||||||
|
.object({
|
||||||
|
Spread: z.object({
|
||||||
|
SpreadDescriptor: z.string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const PlatformSchema = z
|
||||||
|
.object({
|
||||||
|
Architecture: z.string(),
|
||||||
|
OS: z.string(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const PlacementSwarmSchema = z
|
||||||
|
.object({
|
||||||
|
Constraints: z.array(z.string()).optional(),
|
||||||
|
Preferences: z.array(PreferenceSchema).optional(),
|
||||||
|
MaxReplicas: z.number().optional(),
|
||||||
|
Platforms: z.array(PlatformSchema).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const UpdateConfigSwarmSchema = z
|
||||||
|
.object({
|
||||||
|
Parallelism: z.number(),
|
||||||
|
Delay: z.number().optional(),
|
||||||
|
FailureAction: z.string().optional(),
|
||||||
|
Monitor: z.number().optional(),
|
||||||
|
MaxFailureRatio: z.number().optional(),
|
||||||
|
Order: z.string(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const ReplicatedSchema = z
|
||||||
|
.object({
|
||||||
|
Replicas: z.number().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const ReplicatedJobSchema = z
|
||||||
|
.object({
|
||||||
|
MaxConcurrent: z.number().optional(),
|
||||||
|
TotalCompletions: z.number().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const ServiceModeSwarmSchema = z
|
||||||
|
.object({
|
||||||
|
Replicated: ReplicatedSchema.optional(),
|
||||||
|
Global: z.object({}).optional(),
|
||||||
|
ReplicatedJob: ReplicatedJobSchema.optional(),
|
||||||
|
GlobalJob: z.object({}).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const NetworkSwarmSchema = z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
Target: z.string().optional(),
|
||||||
|
Aliases: z.array(z.string()).optional(),
|
||||||
|
DriverOpts: z.object({}).optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const LabelsSwarmSchema = z.record(z.string());
|
||||||
|
|
||||||
|
const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
|
||||||
|
return z
|
||||||
|
.string()
|
||||||
|
.transform((str, ctx) => {
|
||||||
|
if (str === null || str === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(str);
|
||||||
|
} catch (e) {
|
||||||
|
ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Object cannot be empty",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResult = schema.safeParse(data);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
for (const error of parseResult.error.issues) {
|
||||||
|
const path = error.path.join(".");
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `${path} ${error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSwarmSettings = z.object({
|
||||||
|
healthCheckSwarm: createStringToJSONSchema(HealthCheckSwarmSchema).nullable(),
|
||||||
|
restartPolicySwarm: createStringToJSONSchema(
|
||||||
|
RestartPolicySwarmSchema,
|
||||||
|
).nullable(),
|
||||||
|
placementSwarm: createStringToJSONSchema(PlacementSwarmSchema).nullable(),
|
||||||
|
updateConfigSwarm: createStringToJSONSchema(
|
||||||
|
UpdateConfigSwarmSchema,
|
||||||
|
).nullable(),
|
||||||
|
rollbackConfigSwarm: createStringToJSONSchema(
|
||||||
|
UpdateConfigSwarmSchema,
|
||||||
|
).nullable(),
|
||||||
|
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
|
||||||
|
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
|
||||||
|
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
applicationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddSwarmSettings = ({ applicationId }: Props) => {
|
||||||
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
|
{
|
||||||
|
applicationId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!applicationId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync, isError, error, isLoading } =
|
||||||
|
api.application.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<AddSwarmSettings>({
|
||||||
|
defaultValues: {
|
||||||
|
healthCheckSwarm: null,
|
||||||
|
restartPolicySwarm: null,
|
||||||
|
placementSwarm: null,
|
||||||
|
updateConfigSwarm: null,
|
||||||
|
rollbackConfigSwarm: null,
|
||||||
|
modeSwarm: null,
|
||||||
|
labelsSwarm: null,
|
||||||
|
networkSwarm: null,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(addSwarmSettings),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
form.reset({
|
||||||
|
healthCheckSwarm: data.healthCheckSwarm
|
||||||
|
? JSON.stringify(data.healthCheckSwarm, null, 2)
|
||||||
|
: null,
|
||||||
|
restartPolicySwarm: data.restartPolicySwarm
|
||||||
|
? JSON.stringify(data.restartPolicySwarm, null, 2)
|
||||||
|
: null,
|
||||||
|
placementSwarm: data.placementSwarm
|
||||||
|
? JSON.stringify(data.placementSwarm, null, 2)
|
||||||
|
: null,
|
||||||
|
updateConfigSwarm: data.updateConfigSwarm
|
||||||
|
? JSON.stringify(data.updateConfigSwarm, null, 2)
|
||||||
|
: null,
|
||||||
|
rollbackConfigSwarm: data.rollbackConfigSwarm
|
||||||
|
? JSON.stringify(data.rollbackConfigSwarm, null, 2)
|
||||||
|
: null,
|
||||||
|
modeSwarm: data.modeSwarm
|
||||||
|
? JSON.stringify(data.modeSwarm, null, 2)
|
||||||
|
: null,
|
||||||
|
labelsSwarm: data.labelsSwarm
|
||||||
|
? JSON.stringify(data.labelsSwarm, null, 2)
|
||||||
|
: null,
|
||||||
|
networkSwarm: data.networkSwarm
|
||||||
|
? JSON.stringify(data.networkSwarm, null, 2)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form, form.reset, data]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: AddSwarmSettings) => {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId,
|
||||||
|
healthCheckSwarm: data.healthCheckSwarm,
|
||||||
|
restartPolicySwarm: data.restartPolicySwarm,
|
||||||
|
placementSwarm: data.placementSwarm,
|
||||||
|
updateConfigSwarm: data.updateConfigSwarm,
|
||||||
|
rollbackConfigSwarm: data.rollbackConfigSwarm,
|
||||||
|
modeSwarm: data.modeSwarm,
|
||||||
|
labelsSwarm: data.labelsSwarm,
|
||||||
|
networkSwarm: data.networkSwarm,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Swarm settings updated");
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error to update the swarm settings");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="secondary" className="cursor-pointer w-fit">
|
||||||
|
<Settings className="size-4 text-muted-foreground" />
|
||||||
|
Swarm Settings
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-5xl p-0">
|
||||||
|
<DialogHeader className="p-6">
|
||||||
|
<DialogTitle>Swarm Settings</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update certain settings using a json object.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-add-permissions"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4 relative"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="healthCheckSwarm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
|
||||||
|
<FormLabel>Health Check</FormLabel>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Check the interface
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormDescription>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="w-full z-[999]"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
<pre>
|
||||||
|
{`{
|
||||||
|
Test?: string[] | undefined;
|
||||||
|
Interval?: number | undefined;
|
||||||
|
Timeout?: number | undefined;
|
||||||
|
StartPeriod?: number | undefined;
|
||||||
|
Retries?: number | undefined;
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
placeholder={`{
|
||||||
|
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
|
||||||
|
"Interval" : 10000,
|
||||||
|
"Timeout" : 10000,
|
||||||
|
"StartPeriod" : 10000,
|
||||||
|
"Retries" : 10
|
||||||
|
}`}
|
||||||
|
className="h-[12rem] font-mono"
|
||||||
|
{...field}
|
||||||
|
value={field?.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<pre>
|
||||||
|
<FormMessage />
|
||||||
|
</pre>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="restartPolicySwarm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
|
||||||
|
<FormLabel>Restart Policy</FormLabel>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Check the interface
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormDescription>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="w-full z-[999]"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
<pre>
|
||||||
|
{`{
|
||||||
|
Condition?: string | undefined;
|
||||||
|
Delay?: number | undefined;
|
||||||
|
MaxAttempts?: number | undefined;
|
||||||
|
Window?: number | undefined;
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
placeholder={`{
|
||||||
|
"Condition" : "on-failure",
|
||||||
|
"Delay" : 10000,
|
||||||
|
"MaxAttempts" : 10,
|
||||||
|
"Window" : 10000
|
||||||
|
} `}
|
||||||
|
className="h-[12rem] font-mono"
|
||||||
|
{...field}
|
||||||
|
value={field?.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<pre>
|
||||||
|
<FormMessage />
|
||||||
|
</pre>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="placementSwarm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
|
||||||
|
<FormLabel>Placement</FormLabel>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Check the interface
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormDescription>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="w-full z-[999]"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
<pre>
|
||||||
|
{`{
|
||||||
|
Constraints?: string[] | undefined;
|
||||||
|
Preferences?: Array<{ Spread: { SpreadDescriptor: string } }> | undefined;
|
||||||
|
MaxReplicas?: number | undefined;
|
||||||
|
Platforms?:
|
||||||
|
| Array<{
|
||||||
|
Architecture: string;
|
||||||
|
OS: string;
|
||||||
|
}>
|
||||||
|
| undefined;
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
placeholder={`{
|
||||||
|
"Constraints" : ["node.role==manager"],
|
||||||
|
"Preferences" : [{
|
||||||
|
"Spread" : {
|
||||||
|
"SpreadDescriptor" : "node.labels.region"
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"MaxReplicas" : 10,
|
||||||
|
"Platforms" : [{
|
||||||
|
"Architecture" : "amd64",
|
||||||
|
"OS" : "linux"
|
||||||
|
}]
|
||||||
|
} `}
|
||||||
|
className="h-[21rem] font-mono"
|
||||||
|
{...field}
|
||||||
|
value={field?.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<pre>
|
||||||
|
<FormMessage />
|
||||||
|
</pre>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="updateConfigSwarm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
|
||||||
|
<FormLabel>Update Config</FormLabel>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Check the interface
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormDescription>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="w-full z-[999]"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
<pre>
|
||||||
|
{`{
|
||||||
|
Parallelism?: number;
|
||||||
|
Delay?: number | undefined;
|
||||||
|
FailureAction?: string | undefined;
|
||||||
|
Monitor?: number | undefined;
|
||||||
|
MaxFailureRatio?: number | undefined;
|
||||||
|
Order: string;
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
placeholder={`{
|
||||||
|
"Parallelism" : 1,
|
||||||
|
"Delay" : 10000,
|
||||||
|
"FailureAction" : "continue",
|
||||||
|
"Monitor" : 10000,
|
||||||
|
"MaxFailureRatio" : 10,
|
||||||
|
"Order" : "start-first"
|
||||||
|
}`}
|
||||||
|
className="h-[21rem] font-mono"
|
||||||
|
{...field}
|
||||||
|
value={field?.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<pre>
|
||||||
|
<FormMessage />
|
||||||
|
</pre>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="rollbackConfigSwarm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
|
||||||
|
<FormLabel>Rollback Config</FormLabel>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Check the interface
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormDescription>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="w-full z-[999]"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
<pre>
|
||||||
|
{`{
|
||||||
|
Parallelism?: number;
|
||||||
|
Delay?: number | undefined;
|
||||||
|
FailureAction?: string | undefined;
|
||||||
|
Monitor?: number | undefined;
|
||||||
|
MaxFailureRatio?: number | undefined;
|
||||||
|
Order: string;
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
placeholder={`{
|
||||||
|
"Parallelism" : 1,
|
||||||
|
"Delay" : 10000,
|
||||||
|
"FailureAction" : "continue",
|
||||||
|
"Monitor" : 10000,
|
||||||
|
"MaxFailureRatio" : 10,
|
||||||
|
"Order" : "start-first"
|
||||||
|
}`}
|
||||||
|
className="h-[17rem] font-mono"
|
||||||
|
{...field}
|
||||||
|
value={field?.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<pre>
|
||||||
|
<FormMessage />
|
||||||
|
</pre>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="modeSwarm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
|
||||||
|
<FormLabel>Mode</FormLabel>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Check the interface
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormDescription>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="w-full z-[999]"
|
||||||
|
align="center"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
<pre>
|
||||||
|
{`{
|
||||||
|
Replicated?: { Replicas?: number | undefined } | undefined;
|
||||||
|
Global?: {} | undefined;
|
||||||
|
ReplicatedJob?:
|
||||||
|
| {
|
||||||
|
MaxConcurrent?: number | undefined;
|
||||||
|
TotalCompletions?: number | undefined;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
GlobalJob?: {} | undefined;
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
placeholder={`{
|
||||||
|
"Replicated" : {
|
||||||
|
"Replicas" : 1
|
||||||
|
},
|
||||||
|
"Global" : {},
|
||||||
|
"ReplicatedJob" : {
|
||||||
|
"MaxConcurrent" : 1,
|
||||||
|
"TotalCompletions" : 1
|
||||||
|
},
|
||||||
|
"GlobalJob" : {}
|
||||||
|
}`}
|
||||||
|
className="h-[17rem] font-mono"
|
||||||
|
{...field}
|
||||||
|
value={field?.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<pre>
|
||||||
|
<FormMessage />
|
||||||
|
</pre>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="networkSwarm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
|
||||||
|
<FormLabel>Network</FormLabel>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Check the interface
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormDescription>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="w-full z-[999]"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
<pre>
|
||||||
|
{`[
|
||||||
|
{
|
||||||
|
"Target" : string | undefined;
|
||||||
|
"Aliases" : string[] | undefined;
|
||||||
|
"DriverOpts" : { [key: string]: string } | undefined;
|
||||||
|
}
|
||||||
|
]`}
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<FormControl>
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
placeholder={`[
|
||||||
|
{
|
||||||
|
"Target" : "dokploy-network",
|
||||||
|
"Aliases" : ["dokploy-network"],
|
||||||
|
"DriverOpts" : {
|
||||||
|
"com.docker.network.driver.mtu" : "1500",
|
||||||
|
"com.docker.network.driver.host_binding" : "true",
|
||||||
|
"com.docker.network.driver.mtu" : "1500",
|
||||||
|
"com.docker.network.driver.host_binding" : "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]`}
|
||||||
|
className="h-[20rem] font-mono"
|
||||||
|
{...field}
|
||||||
|
value={field?.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<pre>
|
||||||
|
<FormMessage />
|
||||||
|
</pre>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="labelsSwarm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
|
||||||
|
<FormLabel>Labels</FormLabel>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Check the interface
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormDescription>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="w-full z-[999]"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
<pre>
|
||||||
|
{`{
|
||||||
|
[name: string]: string;
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<FormControl>
|
||||||
|
<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 || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<pre>
|
||||||
|
<FormMessage />
|
||||||
|
</pre>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border p-2 ">
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-add-permissions"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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,
|
||||||
|
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";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddRedirectchema = z.object({
|
||||||
|
replicas: z.number(),
|
||||||
|
registryId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AddCommand = z.infer<typeof AddRedirectchema>;
|
||||||
|
|
||||||
|
export const ShowClusterSettings = ({ applicationId }: Props) => {
|
||||||
|
const { data } = api.application.one.useQuery(
|
||||||
|
{
|
||||||
|
applicationId,
|
||||||
|
},
|
||||||
|
{ enabled: !!applicationId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: registries } = api.registry.all.useQuery();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<AddCommand>({
|
||||||
|
defaultValues: {
|
||||||
|
registryId: data?.registryId || "",
|
||||||
|
replicas: data?.replicas || 1,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(AddRedirectchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.command) {
|
||||||
|
form.reset({
|
||||||
|
registryId: data?.registryId || "",
|
||||||
|
replicas: data?.replicas || 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: AddCommand) => {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId,
|
||||||
|
registryId: data?.registryId === "none" ? null : data?.registryId,
|
||||||
|
replicas: data?.replicas,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Command Updated");
|
||||||
|
await utils.application.one.invalidate({
|
||||||
|
applicationId,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.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">Cluster Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add the registry and the replicas of the application
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<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)}
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="replicas"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Replicas</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="1"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(Number(e.target.value));
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{registries && registries?.length === 0 ? (
|
||||||
|
<div className="pt-10">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Server className="size-8 text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
To use a cluster feature, you need to configure at least a
|
||||||
|
registry first. Please, go to{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/cluster"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>{" "}
|
||||||
|
to do so.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="registryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Select a registry</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a registry" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{registries?.map((registry) => (
|
||||||
|
<SelectItem
|
||||||
|
key={registry.registryId}
|
||||||
|
value={registry.registryId}
|
||||||
|
>
|
||||||
|
{registry.registryName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value={"none"}>None</SelectItem>
|
||||||
|
<SelectLabel>
|
||||||
|
Registries ({registries?.length})
|
||||||
|
</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import { Rss } from "lucide-react";
|
|||||||
import { AddPort } from "./add-port";
|
import { AddPort } from "./add-port";
|
||||||
import { DeletePort } from "./delete-port";
|
import { DeletePort } from "./delete-port";
|
||||||
import { UpdatePort } from "./update-port";
|
import { UpdatePort } from "./update-port";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
}
|
}
|
||||||
@@ -47,7 +48,11 @@ export const ShowPorts = ({ applicationId }: Props) => {
|
|||||||
<AddPort applicationId={applicationId}>Add Port</AddPort>
|
<AddPort applicationId={applicationId}>Add Port</AddPort>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-6">
|
||||||
{data?.ports.map((port) => (
|
{data?.ports.map((port) => (
|
||||||
<div key={port.portId}>
|
<div key={port.portId}>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Pencil } from "lucide-react";
|
import { PenBoxIcon, Pencil } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -99,7 +99,7 @@ export const UpdatePort = ({ portId }: Props) => {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<Pencil className="size-4 text-muted-foreground" />
|
<PenBoxIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Pencil } from "lucide-react";
|
import { PenBoxIcon, Pencil } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -94,7 +94,7 @@ export const UpdateRedirect = ({ redirectId }: Props) => {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<Pencil className="size-4 text-muted-foreground" />
|
<PenBoxIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Pencil } from "lucide-react";
|
import { PenBoxIcon, Pencil } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -89,7 +89,7 @@ export const UpdateSecurity = ({ securityId }: Props) => {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<Pencil className="size-4 text-muted-foreground" />
|
<PenBoxIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import React, { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
|
||||||
const addResourcesApplication = z.object({
|
const addResourcesApplication = z.object({
|
||||||
memoryReservation: z.number().nullable().optional(),
|
memoryReservation: z.number().nullable().optional(),
|
||||||
@@ -84,6 +85,10 @@ export const ShowApplicationResources = ({ applicationId }: Props) => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<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 {...form}>
|
||||||
<form
|
<form
|
||||||
id="hook-form"
|
id="hook-form"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { File } from "lucide-react";
|
import { File } from "lucide-react";
|
||||||
import { UpdateTraefikConfig } from "./update-traefik-config";
|
import { UpdateTraefikConfig } from "./update-traefik-config";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
}
|
}
|
||||||
@@ -43,11 +44,13 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2 relative">
|
<div className="flex flex-col pt-2 relative">
|
||||||
<div className="flex flex-col gap-6 bg-input p-4 rounded-md max-h-[35rem] min-h-[10rem] overflow-y-auto">
|
<div className="flex flex-col gap-6 max-h-[35rem] min-h-[10rem] overflow-y-auto">
|
||||||
<div>
|
<CodeEditor
|
||||||
<pre className="font-sans">{data || "Empty"}</pre>
|
value={data || "Empty"}
|
||||||
</div>
|
disabled
|
||||||
<div className="flex justify-end absolute z-50 right-6">
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end absolute z-50 right-6 top-6">
|
||||||
<UpdateTraefikConfig applicationId={applicationId} />
|
<UpdateTraefikConfig applicationId={applicationId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -25,6 +24,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import jsyaml from "js-yaml";
|
import jsyaml from "js-yaml";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
|
||||||
const UpdateTraefikConfigSchema = z.object({
|
const UpdateTraefikConfigSchema = z.object({
|
||||||
traefikConfig: z.string(),
|
traefikConfig: z.string(),
|
||||||
@@ -122,7 +122,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
<form
|
<form
|
||||||
id="hook-form-update-traefik-config"
|
id="hook-form-update-traefik-config"
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid w-full py-4"
|
className="grid w-full py-4 overflow-auto"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -132,8 +132,8 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Traefik config</FormLabel>
|
<FormLabel>Traefik config</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
className="h-[35rem] font-mono"
|
wrapperClassName="h-[35rem] font-mono"
|
||||||
placeholder={`http:
|
placeholder={`http:
|
||||||
routers:
|
routers:
|
||||||
router-name:
|
router-name:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { PlusIcon } from "lucide-react";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
interface Props {
|
interface Props {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
serviceType:
|
serviceType:
|
||||||
@@ -36,7 +37,8 @@ interface Props {
|
|||||||
| "mongo"
|
| "mongo"
|
||||||
| "redis"
|
| "redis"
|
||||||
| "mysql"
|
| "mysql"
|
||||||
| "mariadb";
|
| "mariadb"
|
||||||
|
| "compose";
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -77,7 +79,7 @@ export const AddVolumes = ({
|
|||||||
const { mutateAsync } = api.mounts.create.useMutation();
|
const { mutateAsync } = api.mounts.create.useMutation();
|
||||||
const form = useForm<AddMount>({
|
const form = useForm<AddMount>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
type: "bind",
|
type: serviceType === "compose" ? "file" : "bind",
|
||||||
hostPath: "",
|
hostPath: "",
|
||||||
mountPath: "",
|
mountPath: "",
|
||||||
},
|
},
|
||||||
@@ -176,41 +178,52 @@ export const AddVolumes = ({
|
|||||||
defaultValue={field.value}
|
defaultValue={field.value}
|
||||||
className="grid w-full grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
|
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">
|
{serviceType !== "compose" && (
|
||||||
<FormControl className="w-full">
|
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||||
<div>
|
<FormControl className="w-full">
|
||||||
<RadioGroupItem
|
<div>
|
||||||
value="bind"
|
<RadioGroupItem
|
||||||
id="bind"
|
value="bind"
|
||||||
className="peer sr-only"
|
id="bind"
|
||||||
/>
|
className="peer sr-only"
|
||||||
<Label
|
/>
|
||||||
htmlFor="bind"
|
<Label
|
||||||
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"
|
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>
|
Bind Mount
|
||||||
</div>
|
</Label>
|
||||||
</FormControl>
|
</div>
|
||||||
</FormItem>
|
</FormControl>
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
</FormItem>
|
||||||
<FormControl className="w-full">
|
)}
|
||||||
<div>
|
|
||||||
<RadioGroupItem
|
{serviceType !== "compose" && (
|
||||||
value="volume"
|
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||||
id="volume"
|
<FormControl className="w-full">
|
||||||
className="peer sr-only"
|
<div>
|
||||||
/>
|
<RadioGroupItem
|
||||||
<Label
|
value="volume"
|
||||||
htmlFor="volume"
|
id="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"
|
className="peer sr-only"
|
||||||
>
|
/>
|
||||||
Volume Mount
|
<Label
|
||||||
</Label>
|
htmlFor="volume"
|
||||||
</div>
|
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"
|
||||||
</FormControl>
|
>
|
||||||
</FormItem>
|
Volume Mount
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
</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">
|
<FormControl className="w-full">
|
||||||
<div>
|
<div>
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
|
|||||||
import { AlertTriangle, Package } from "lucide-react";
|
import { AlertTriangle, Package } from "lucide-react";
|
||||||
import { AddVolumes } from "./add-volumes";
|
import { AddVolumes } from "./add-volumes";
|
||||||
import { DeleteVolume } from "./delete-volume";
|
import { DeleteVolume } from "./delete-volume";
|
||||||
|
import { UpdateVolume } from "./update-volume";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
}
|
}
|
||||||
@@ -59,15 +61,12 @@ export const ShowVolumes = ({ applicationId }: Props) => {
|
|||||||
</AddVolumes>
|
</AddVolumes>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2">
|
<div className="flex flex-col pt-2 gap-4">
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
|
<AlertBlock type="info">
|
||||||
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
|
Please remember to click Redeploy after adding, editing, or
|
||||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
deleting a mount to apply the changes.
|
||||||
Please remember to click Redeploy after adding, editing, or
|
</AlertBlock>
|
||||||
deleting a mount to apply the changes.
|
<div className="flex flex-col gap-6">
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-6 pt-6">
|
|
||||||
{data?.mounts.map((mount) => (
|
{data?.mounts.map((mount) => (
|
||||||
<div key={mount.mountId}>
|
<div key={mount.mountId}>
|
||||||
<div
|
<div
|
||||||
@@ -114,7 +113,12 @@ export const ShowVolumes = ({ applicationId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -25,8 +25,8 @@ export const DeleteApplication = ({ applicationId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 " />
|
<TrashIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export const DeleteDomain = ({ domainId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 " />
|
<TrashIcon className="size-4 text-muted-foreground " />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<AddDomain applicationId={applicationId} />
|
<AddDomain applicationId={applicationId}>
|
||||||
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
|
</AddDomain>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
<CardContent className="flex w-full flex-row gap-4">
|
||||||
@@ -51,7 +53,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
To access to the application is required to set at least 1
|
To access to the application is required to set at least 1
|
||||||
domain
|
domain
|
||||||
</span>
|
</span>
|
||||||
<AddDomain applicationId={applicationId}>Add Domain</AddDomain>
|
<AddDomain applicationId={applicationId}>
|
||||||
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
|
</AddDomain>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
@@ -75,8 +79,10 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
<Button variant="outline" disabled>
|
<Button variant="outline" disabled>
|
||||||
{item.https ? "HTTPS" : "HTTP"}
|
{item.https ? "HTTPS" : "HTTP"}
|
||||||
</Button>
|
</Button>
|
||||||
<UpdateDomain domainId={item.domainId} />
|
<div className="flex flex-row gap-1">
|
||||||
<DeleteDomain domainId={item.domainId} />
|
<UpdateDomain domainId={item.domainId} />
|
||||||
|
<DeleteDomain domainId={item.domainId} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ export const UpdateDomain = ({ domainId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger className="" asChild>
|
<DialogTrigger className="" asChild>
|
||||||
<Button>
|
<Button variant="ghost">
|
||||||
<PenBoxIcon className="size-4" />
|
<PenBoxIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
environment: z.string(),
|
environment: z.string(),
|
||||||
@@ -94,8 +95,11 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
placeholder="NODE_ENV=production"
|
language="properties"
|
||||||
|
placeholder={`NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
`}
|
||||||
className="h-96 font-mono"
|
className="h-96 font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<SquarePen className="size-4" />
|
<SquarePen className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
|
|||||||
133
components/dashboard/compose/advanced/add-command.tsx
Normal file
133
components/dashboard/compose/advanced/add-command.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
133
components/dashboard/compose/advanced/show-volumes.tsx
Normal file
133
components/dashboard/compose/advanced/show-volumes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
63
components/dashboard/compose/delete-compose.tsx
Normal file
63
components/dashboard/compose/delete-compose.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
122
components/dashboard/compose/enviroment/show.tsx
Normal file
122
components/dashboard/compose/enviroment/show.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import React, { useEffect } 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";
|
||||||
|
|
||||||
|
const addEnvironmentSchema = z.object({
|
||||||
|
environment: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
composeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowEnvironmentCompose = ({ composeId }: Props) => {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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"
|
||||||
|
placeholder={`NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
`}
|
||||||
|
className="h-96 font-mono"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-end">
|
||||||
|
<Button isLoading={isLoading} className="w-fit" type="submit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
121
components/dashboard/compose/general/actions.tsx
Normal file
121
components/dashboard/compose/general/actions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
142
components/dashboard/compose/general/compose-file-editor.tsx
Normal file
142
components/dashboard/compose/general/compose-file-editor.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
78
components/dashboard/compose/general/deploy-compose.tsx
Normal file
78
components/dashboard/compose/general/deploy-compose.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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("Compose Deploying....");
|
||||||
|
|
||||||
|
await refetch();
|
||||||
|
await deploy({
|
||||||
|
composeId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Compose Deployed Succesfully");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error to deploy Compose");
|
||||||
|
});
|
||||||
|
|
||||||
|
await refetch();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error(e.message || "Error to deploy Compose");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
97
components/dashboard/compose/general/generic/show.tsx
Normal file
97
components/dashboard/compose/general/generic/show.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
98
components/dashboard/compose/general/randomize-compose.tsx
Normal file
98
components/dashboard/compose/general/randomize-compose.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
components/dashboard/compose/general/rebuild-compose.tsx
Normal file
85
components/dashboard/compose/general/rebuild-compose.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
47
components/dashboard/compose/general/show.tsx
Normal file
47
components/dashboard/compose/general/show.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
79
components/dashboard/compose/general/stop-compose.tsx
Normal file
79
components/dashboard/compose/general/stop-compose.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
88
components/dashboard/compose/logs/show.tsx
Normal file
88
components/dashboard/compose/logs/show.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
components/dashboard/compose/monitoring/show.tsx
Normal file
85
components/dashboard/compose/monitoring/show.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
159
components/dashboard/compose/update-compose.tsx
Normal file
159
components/dashboard/compose/update-compose.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Pencil, CheckIcon, ChevronsUpDown } from "lucide-react";
|
import { Pencil, CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -115,7 +115,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<Pencil className="size-4 text-muted-foreground" />
|
<PenBoxIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
|
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
|
||||||
const UpdateServerMiddlewareConfigSchema = z.object({
|
const UpdateServerMiddlewareConfigSchema = z.object({
|
||||||
traefikConfig: z.string(),
|
traefikConfig: z.string(),
|
||||||
@@ -88,13 +87,12 @@ export const ShowTraefikFile = ({ path }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid w-full relative"
|
className="grid w-full relative z-[5]"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col overflow-auto">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="traefikConfig"
|
name="traefikConfig"
|
||||||
@@ -105,8 +103,8 @@ export const ShowTraefikFile = ({ path }: Props) => {
|
|||||||
{path}
|
{path}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
className="h-[35rem] font-mono"
|
wrapperClassName="h-[35rem] font-mono"
|
||||||
placeholder={`http:
|
placeholder={`http:
|
||||||
routers:
|
routers:
|
||||||
router-name:
|
router-name:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const ShowTraefikSystem = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("mt-6 md:grid gap-4")}>
|
<div className={cn("mt-6 md:grid gap-4")}>
|
||||||
<div className="flex flex-col md:flex-row gap-4 md:gap-10 w-full">
|
<div className="flex flex-col lg:flex-row gap-4 md:gap-10 w-full">
|
||||||
{directories?.length === 0 && (
|
{directories?.length === 0 && (
|
||||||
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
|
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
|
||||||
<span className="text-muted-foreground text-lg font-medium">
|
<span className="text-muted-foreground text-lg font-medium">
|
||||||
@@ -27,7 +27,7 @@ export const ShowTraefikSystem = () => {
|
|||||||
<>
|
<>
|
||||||
<Tree
|
<Tree
|
||||||
data={directories}
|
data={directories}
|
||||||
className="md:max-w-[19rem] w-full md:h-[660px] border rounded-lg"
|
className="lg:max-w-[19rem] w-full lg:h-[660px] border rounded-lg"
|
||||||
onSelectChange={(item) => setFile(item?.id || null)}
|
onSelectChange={(item) => setFile(item?.id || null)}
|
||||||
folderIcon={Folder}
|
folderIcon={Folder}
|
||||||
itemIcon={Workflow}
|
itemIcon={Workflow}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -21,213 +21,218 @@ import React, { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
|
||||||
const addResourcesMariadb = z.object({
|
const addResourcesMariadb = z.object({
|
||||||
memoryReservation: z.number().nullable().optional(),
|
memoryReservation: z.number().nullable().optional(),
|
||||||
cpuLimit: z.number().nullable().optional(),
|
cpuLimit: z.number().nullable().optional(),
|
||||||
memoryLimit: z.number().nullable().optional(),
|
memoryLimit: z.number().nullable().optional(),
|
||||||
cpuReservation: z.number().nullable().optional(),
|
cpuReservation: z.number().nullable().optional(),
|
||||||
});
|
});
|
||||||
interface Props {
|
interface Props {
|
||||||
mariadbId: string;
|
mariadbId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddResourcesMariadb = z.infer<typeof addResourcesMariadb>;
|
type AddResourcesMariadb = z.infer<typeof addResourcesMariadb>;
|
||||||
export const ShowMariadbResources = ({ mariadbId }: Props) => {
|
export const ShowMariadbResources = ({ mariadbId }: Props) => {
|
||||||
const { data, refetch } = api.mariadb.one.useQuery(
|
const { data, refetch } = api.mariadb.one.useQuery(
|
||||||
{
|
{
|
||||||
mariadbId,
|
mariadbId,
|
||||||
},
|
},
|
||||||
{ enabled: !!mariadbId },
|
{ enabled: !!mariadbId },
|
||||||
);
|
);
|
||||||
const { mutateAsync, isLoading } = api.mariadb.update.useMutation();
|
const { mutateAsync, isLoading } = api.mariadb.update.useMutation();
|
||||||
const form = useForm<AddResourcesMariadb>({
|
const form = useForm<AddResourcesMariadb>({
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
resolver: zodResolver(addResourcesMariadb),
|
resolver: zodResolver(addResourcesMariadb),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
cpuLimit: data?.cpuLimit || undefined,
|
cpuLimit: data?.cpuLimit || undefined,
|
||||||
cpuReservation: data?.cpuReservation || undefined,
|
cpuReservation: data?.cpuReservation || undefined,
|
||||||
memoryLimit: data?.memoryLimit || undefined,
|
memoryLimit: data?.memoryLimit || undefined,
|
||||||
memoryReservation: data?.memoryReservation || undefined,
|
memoryReservation: data?.memoryReservation || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
|
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
|
||||||
|
|
||||||
const onSubmit = async (formData: AddResourcesMariadb) => {
|
const onSubmit = async (formData: AddResourcesMariadb) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
mariadbId,
|
mariadbId,
|
||||||
cpuLimit: formData.cpuLimit || null,
|
cpuLimit: formData.cpuLimit || null,
|
||||||
cpuReservation: formData.cpuReservation || null,
|
cpuReservation: formData.cpuReservation || null,
|
||||||
memoryLimit: formData.memoryLimit || null,
|
memoryLimit: formData.memoryLimit || null,
|
||||||
memoryReservation: formData.memoryReservation || null,
|
memoryReservation: formData.memoryReservation || null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Resources Updated");
|
toast.success("Resources Updated");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to Update the resources");
|
toast.error("Error to Update the resources");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Resources</CardTitle>
|
<CardTitle className="text-xl">Resources</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
If you want to decrease or increase the resources to a specific
|
If you want to decrease or increase the resources to a specific
|
||||||
application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<Form {...form}>
|
<AlertBlock type="info">
|
||||||
<form
|
Please remember to click Redeploy after modify the resources to apply
|
||||||
id="hook-form"
|
the changes.
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
</AlertBlock>
|
||||||
className="grid w-full gap-8 "
|
<Form {...form}>
|
||||||
>
|
<form
|
||||||
<div className="grid w-full md:grid-cols-2 gap-4">
|
id="hook-form"
|
||||||
<FormField
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
control={form.control}
|
className="grid w-full gap-8 "
|
||||||
name="memoryReservation"
|
>
|
||||||
render={({ field }) => (
|
<div className="grid w-full md:grid-cols-2 gap-4">
|
||||||
<FormItem>
|
<FormField
|
||||||
<FormLabel>Memory Reservation</FormLabel>
|
control={form.control}
|
||||||
<FormControl>
|
name="memoryReservation"
|
||||||
<Input
|
render={({ field }) => (
|
||||||
placeholder="256 MB"
|
<FormItem>
|
||||||
{...field}
|
<FormLabel>Memory Reservation</FormLabel>
|
||||||
value={field.value?.toString() || ""}
|
<FormControl>
|
||||||
onChange={(e) => {
|
<Input
|
||||||
const value = e.target.value;
|
placeholder="256 MB"
|
||||||
if (value === "") {
|
{...field}
|
||||||
// Si el campo está vacío, establece el valor como null.
|
value={field.value?.toString() || ""}
|
||||||
field.onChange(null);
|
onChange={(e) => {
|
||||||
} else {
|
const value = e.target.value;
|
||||||
const number = Number.parseInt(value, 10);
|
if (value === "") {
|
||||||
if (!Number.isNaN(number)) {
|
// Si el campo está vacío, establece el valor como null.
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
field.onChange(null);
|
||||||
field.onChange(number);
|
} 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>
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="memoryLimit"
|
name="memoryLimit"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Memory Limit</FormLabel>
|
<FormLabel>Memory Limit</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"1024 MB"}
|
placeholder={"1024 MB"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cpuLimit"
|
name="cpuLimit"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Cpu Limit</FormLabel>
|
<FormLabel>Cpu Limit</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"2"}
|
placeholder={"2"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cpuReservation"
|
name="cpuReservation"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Cpu Reservation</FormLabel>
|
<FormLabel>Cpu Reservation</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"1"}
|
placeholder={"1"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -16,47 +16,47 @@ import { useRouter } from "next/router";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mariadbId: string;
|
mariadbId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteMariadb = ({ mariadbId }: Props) => {
|
export const DeleteMariadb = ({ mariadbId }: Props) => {
|
||||||
const { mutateAsync, isLoading } = api.mariadb.remove.useMutation();
|
const { mutateAsync, isLoading } = api.mariadb.remove.useMutation();
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 " />
|
<TrashIcon className="size-4 text-muted-foreground " />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
database
|
database
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
mariadbId,
|
mariadbId,
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
push(`/dashboard/project/${data?.projectId}`);
|
||||||
toast.success("Database delete succesfully");
|
toast.success("Database delete succesfully");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to delete the database");
|
toast.error("Error to delete the database");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
environment: z.string(),
|
environment: z.string(),
|
||||||
@@ -93,8 +93,11 @@ export const ShowMariadbEnvironment = ({ mariadbId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
placeholder="MARIADB_PASSWORD=1234567678"
|
language="properties"
|
||||||
|
placeholder={`NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
`}
|
||||||
className="h-96 font-mono"
|
className="h-96 font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<SquarePen className="size-4" />
|
<SquarePen className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
|
|||||||
import { AlertTriangle, Package } from "lucide-react";
|
import { AlertTriangle, Package } from "lucide-react";
|
||||||
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
|
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
|
||||||
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
|
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 {
|
interface Props {
|
||||||
mariadbId: string;
|
mariadbId: string;
|
||||||
}
|
}
|
||||||
@@ -59,15 +61,12 @@ export const ShowVolumes = ({ mariadbId }: Props) => {
|
|||||||
</AddVolumes>
|
</AddVolumes>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2">
|
<div className="flex flex-col pt-2 gap-4">
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
|
<AlertBlock type="info">
|
||||||
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
|
Please remember to click Redeploy after adding, editing, or
|
||||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
deleting a mount to apply the changes.
|
||||||
Please remember to click Redeploy after adding, editing, or
|
</AlertBlock>
|
||||||
deleting a mount to apply the changes.
|
<div className="flex flex-col gap-6">
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-6 pt-6">
|
|
||||||
{data?.mounts.map((mount) => (
|
{data?.mounts.map((mount) => (
|
||||||
<div key={mount.mountId}>
|
<div key={mount.mountId}>
|
||||||
<div
|
<div
|
||||||
@@ -113,7 +112,12 @@ export const ShowVolumes = ({ mariadbId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -21,213 +21,222 @@ import React, { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
|
||||||
const addResourcesMongo = z.object({
|
const addResourcesMongo = z.object({
|
||||||
memoryReservation: z.number().nullable().optional(),
|
memoryReservation: z.number().nullable().optional(),
|
||||||
cpuLimit: z.number().nullable().optional(),
|
cpuLimit: z.number().nullable().optional(),
|
||||||
memoryLimit: z.number().nullable().optional(),
|
memoryLimit: z.number().nullable().optional(),
|
||||||
cpuReservation: z.number().nullable().optional(),
|
cpuReservation: z.number().nullable().optional(),
|
||||||
});
|
});
|
||||||
interface Props {
|
interface Props {
|
||||||
mongoId: string;
|
mongoId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddResourcesMongo = z.infer<typeof addResourcesMongo>;
|
type AddResourcesMongo = z.infer<typeof addResourcesMongo>;
|
||||||
export const ShowMongoResources = ({ mongoId }: Props) => {
|
export const ShowMongoResources = ({ mongoId }: Props) => {
|
||||||
const { data, refetch } = api.mongo.one.useQuery(
|
const { data, refetch } = api.mongo.one.useQuery(
|
||||||
{
|
{
|
||||||
mongoId,
|
mongoId,
|
||||||
},
|
},
|
||||||
{ enabled: !!mongoId },
|
{ enabled: !!mongoId },
|
||||||
);
|
);
|
||||||
const { mutateAsync, isLoading } = api.mongo.update.useMutation();
|
const { mutateAsync, isLoading } = api.mongo.update.useMutation();
|
||||||
const form = useForm<AddResourcesMongo>({
|
const form = useForm<AddResourcesMongo>({
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
resolver: zodResolver(addResourcesMongo),
|
resolver: zodResolver(addResourcesMongo),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
cpuLimit: data?.cpuLimit || undefined,
|
cpuLimit: data?.cpuLimit || undefined,
|
||||||
cpuReservation: data?.cpuReservation || undefined,
|
cpuReservation: data?.cpuReservation || undefined,
|
||||||
memoryLimit: data?.memoryLimit || undefined,
|
memoryLimit: data?.memoryLimit || undefined,
|
||||||
memoryReservation: data?.memoryReservation || undefined,
|
memoryReservation: data?.memoryReservation || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form, form.reset]);
|
}, [data, form, form.reset]);
|
||||||
|
|
||||||
const onSubmit = async (formData: AddResourcesMongo) => {
|
const onSubmit = async (formData: AddResourcesMongo) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
mongoId,
|
mongoId,
|
||||||
cpuLimit: formData.cpuLimit || null,
|
cpuLimit: formData.cpuLimit || null,
|
||||||
cpuReservation: formData.cpuReservation || null,
|
cpuReservation: formData.cpuReservation || null,
|
||||||
memoryLimit: formData.memoryLimit || null,
|
memoryLimit: formData.memoryLimit || null,
|
||||||
memoryReservation: formData.memoryReservation || null,
|
memoryReservation: formData.memoryReservation || null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Resources Updated");
|
toast.success("Resources Updated");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to Update the resources");
|
toast.error("Error to Update the resources");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Resources</CardTitle>
|
<CardTitle className="text-xl">Resources</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
If you want to decrease or increase the resources to a specific
|
If you want to decrease or increase the resources to a specific
|
||||||
application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<Form {...form}>
|
<AlertBlock type="info">
|
||||||
<form
|
Please remember to click Redeploy after modify the resources to apply
|
||||||
id="hook-form"
|
the changes.
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
</AlertBlock>
|
||||||
className="grid w-full gap-8 "
|
<Form {...form}>
|
||||||
>
|
<AlertBlock type="info">
|
||||||
<div className="grid w-full md:grid-cols-2 gap-4">
|
Please remember to click Redeploy after modify the resources to
|
||||||
<FormField
|
apply the changes.
|
||||||
control={form.control}
|
</AlertBlock>
|
||||||
name="memoryReservation"
|
<form
|
||||||
render={({ field }) => (
|
id="hook-form"
|
||||||
<FormItem>
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
<FormLabel>Memory Reservation</FormLabel>
|
className="grid w-full gap-8 "
|
||||||
<FormControl>
|
>
|
||||||
<Input
|
<div className="grid w-full md:grid-cols-2 gap-4">
|
||||||
placeholder="256 MB"
|
<FormField
|
||||||
{...field}
|
control={form.control}
|
||||||
value={field.value?.toString() || ""}
|
name="memoryReservation"
|
||||||
onChange={(e) => {
|
render={({ field }) => (
|
||||||
const value = e.target.value;
|
<FormItem>
|
||||||
if (value === "") {
|
<FormLabel>Memory Reservation</FormLabel>
|
||||||
// Si el campo está vacío, establece el valor como null.
|
<FormControl>
|
||||||
field.onChange(null);
|
<Input
|
||||||
} else {
|
placeholder="256 MB"
|
||||||
const number = Number.parseInt(value, 10);
|
{...field}
|
||||||
if (!Number.isNaN(number)) {
|
value={field.value?.toString() || ""}
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
onChange={(e) => {
|
||||||
field.onChange(number);
|
const value = e.target.value;
|
||||||
}
|
if (value === "") {
|
||||||
}
|
// Si el campo está vacío, establece el valor como null.
|
||||||
}}
|
field.onChange(null);
|
||||||
/>
|
} else {
|
||||||
</FormControl>
|
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 />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="memoryLimit"
|
name="memoryLimit"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Memory Limit</FormLabel>
|
<FormLabel>Memory Limit</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"1024 MB"}
|
placeholder={"1024 MB"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cpuLimit"
|
name="cpuLimit"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Cpu Limit</FormLabel>
|
<FormLabel>Cpu Limit</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"2"}
|
placeholder={"2"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cpuReservation"
|
name="cpuReservation"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Cpu Reservation</FormLabel>
|
<FormLabel>Cpu Reservation</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"1"}
|
placeholder={"1"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -16,47 +16,47 @@ import { useRouter } from "next/router";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mongoId: string;
|
mongoId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteMongo = ({ mongoId }: Props) => {
|
export const DeleteMongo = ({ mongoId }: Props) => {
|
||||||
const { mutateAsync, isLoading } = api.mongo.remove.useMutation();
|
const { mutateAsync, isLoading } = api.mongo.remove.useMutation();
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 " />
|
<TrashIcon className="size-4 text-muted-foreground " />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
database
|
database
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
mongoId,
|
mongoId,
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
push(`/dashboard/project/${data?.projectId}`);
|
||||||
toast.success("Database delete succesfully");
|
toast.success("Database delete succesfully");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to delete the database");
|
toast.error("Error to delete the database");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
FormItem,
|
FormItem,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
@@ -93,8 +93,11 @@ export const ShowMongoEnvironment = ({ mongoId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
placeholder="MONGO_PASSWORD=1234567678"
|
language="properties"
|
||||||
|
placeholder={`NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
`}
|
||||||
className="h-96 font-mono"
|
className="h-96 font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const UpdateMongo = ({ mongoId }: Props) => {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<SquarePen className="size-4" />
|
<SquarePen className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
|
|||||||
import { AlertTriangle, Package } from "lucide-react";
|
import { AlertTriangle, Package } from "lucide-react";
|
||||||
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
|
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
|
||||||
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
|
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 {
|
interface Props {
|
||||||
mongoId: string;
|
mongoId: string;
|
||||||
}
|
}
|
||||||
@@ -55,15 +57,12 @@ export const ShowVolumes = ({ mongoId }: Props) => {
|
|||||||
</AddVolumes>
|
</AddVolumes>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2">
|
<div className="flex flex-col pt-2 gap-4">
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
|
<AlertBlock type="info">
|
||||||
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
|
Please remember to click Redeploy after adding, editing, or
|
||||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
deleting a mount to apply the changes.
|
||||||
Please remember to click Redeploy after adding, editing, or
|
</AlertBlock>
|
||||||
deleting a mount to apply the changes.
|
<div className="flex flex-col gap-6">
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-6 pt-6">
|
|
||||||
{data?.mounts.map((mount) => (
|
{data?.mounts.map((mount) => (
|
||||||
<div key={mount.mountId}>
|
<div key={mount.mountId}>
|
||||||
<div
|
<div
|
||||||
@@ -109,7 +108,12 @@ export const ShowVolumes = ({ mongoId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,8 +14,43 @@ import { DockerNetworkChart } from "./docker-network-chart";
|
|||||||
import { DockerDiskChart } from "./docker-disk-chart";
|
import { DockerDiskChart } from "./docker-disk-chart";
|
||||||
import { api } from "@/utils/api";
|
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 {
|
interface Props {
|
||||||
appName: string;
|
appName: string;
|
||||||
|
appType?: "application" | "stack" | "docker-compose";
|
||||||
}
|
}
|
||||||
export interface DockerStats {
|
export interface DockerStats {
|
||||||
cpu: {
|
cpu: {
|
||||||
@@ -65,7 +100,10 @@ export type DockerStatsJSON = {
|
|||||||
disk: DockerStats["disk"][];
|
disk: DockerStats["disk"][];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DockerMonitoring = ({ appName }: Props) => {
|
export const DockerMonitoring = ({
|
||||||
|
appName,
|
||||||
|
appType = "application",
|
||||||
|
}: Props) => {
|
||||||
const { data } = api.application.readAppMonitoring.useQuery(
|
const { data } = api.application.readAppMonitoring.useQuery(
|
||||||
{ appName },
|
{ appName },
|
||||||
{
|
{
|
||||||
@@ -79,39 +117,19 @@ export const DockerMonitoring = ({ appName }: Props) => {
|
|||||||
network: [],
|
network: [],
|
||||||
disk: [],
|
disk: [],
|
||||||
});
|
});
|
||||||
const [currentData, setCurrentData] = useState<DockerStats>({
|
const [currentData, setCurrentData] = useState<DockerStats>(defaultData);
|
||||||
cpu: {
|
|
||||||
value: 0,
|
useEffect(() => {
|
||||||
time: "",
|
setCurrentData(defaultData);
|
||||||
},
|
|
||||||
memory: {
|
setAcummulativeData({
|
||||||
value: {
|
cpu: [],
|
||||||
used: 0,
|
memory: [],
|
||||||
free: 0,
|
block: [],
|
||||||
usedPercentage: 0,
|
network: [],
|
||||||
total: 0,
|
disk: [],
|
||||||
},
|
});
|
||||||
time: "",
|
}, [appName]);
|
||||||
},
|
|
||||||
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: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -128,7 +146,7 @@ export const DockerMonitoring = ({ appName }: Props) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}`;
|
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -21,213 +21,218 @@ import React, { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
|
||||||
const addResourcesMysql = z.object({
|
const addResourcesMysql = z.object({
|
||||||
memoryReservation: z.number().nullable().optional(),
|
memoryReservation: z.number().nullable().optional(),
|
||||||
cpuLimit: z.number().nullable().optional(),
|
cpuLimit: z.number().nullable().optional(),
|
||||||
memoryLimit: z.number().nullable().optional(),
|
memoryLimit: z.number().nullable().optional(),
|
||||||
cpuReservation: z.number().nullable().optional(),
|
cpuReservation: z.number().nullable().optional(),
|
||||||
});
|
});
|
||||||
interface Props {
|
interface Props {
|
||||||
mysqlId: string;
|
mysqlId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddResourcesMysql = z.infer<typeof addResourcesMysql>;
|
type AddResourcesMysql = z.infer<typeof addResourcesMysql>;
|
||||||
export const ShowMysqlResources = ({ mysqlId }: Props) => {
|
export const ShowMysqlResources = ({ mysqlId }: Props) => {
|
||||||
const { data, refetch } = api.mysql.one.useQuery(
|
const { data, refetch } = api.mysql.one.useQuery(
|
||||||
{
|
{
|
||||||
mysqlId,
|
mysqlId,
|
||||||
},
|
},
|
||||||
{ enabled: !!mysqlId },
|
{ enabled: !!mysqlId },
|
||||||
);
|
);
|
||||||
const { mutateAsync, isLoading } = api.mysql.update.useMutation();
|
const { mutateAsync, isLoading } = api.mysql.update.useMutation();
|
||||||
const form = useForm<AddResourcesMysql>({
|
const form = useForm<AddResourcesMysql>({
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
resolver: zodResolver(addResourcesMysql),
|
resolver: zodResolver(addResourcesMysql),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
cpuLimit: data?.cpuLimit || undefined,
|
cpuLimit: data?.cpuLimit || undefined,
|
||||||
cpuReservation: data?.cpuReservation || undefined,
|
cpuReservation: data?.cpuReservation || undefined,
|
||||||
memoryLimit: data?.memoryLimit || undefined,
|
memoryLimit: data?.memoryLimit || undefined,
|
||||||
memoryReservation: data?.memoryReservation || undefined,
|
memoryReservation: data?.memoryReservation || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form, form.reset]);
|
}, [data, form, form.reset]);
|
||||||
|
|
||||||
const onSubmit = async (formData: AddResourcesMysql) => {
|
const onSubmit = async (formData: AddResourcesMysql) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
mysqlId,
|
mysqlId,
|
||||||
cpuLimit: formData.cpuLimit || null,
|
cpuLimit: formData.cpuLimit || null,
|
||||||
cpuReservation: formData.cpuReservation || null,
|
cpuReservation: formData.cpuReservation || null,
|
||||||
memoryLimit: formData.memoryLimit || null,
|
memoryLimit: formData.memoryLimit || null,
|
||||||
memoryReservation: formData.memoryReservation || null,
|
memoryReservation: formData.memoryReservation || null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Resources Updated");
|
toast.success("Resources Updated");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to Update the resources");
|
toast.error("Error to Update the resources");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Resources</CardTitle>
|
<CardTitle className="text-xl">Resources</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
If you want to decrease or increase the resources to a specific
|
If you want to decrease or increase the resources to a specific
|
||||||
application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<Form {...form}>
|
<AlertBlock type="info">
|
||||||
<form
|
Please remember to click Redeploy after modify the resources to apply
|
||||||
id="hook-form"
|
the changes.
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
</AlertBlock>
|
||||||
className="grid w-full gap-8 "
|
<Form {...form}>
|
||||||
>
|
<form
|
||||||
<div className="grid w-full md:grid-cols-2 gap-4">
|
id="hook-form"
|
||||||
<FormField
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
control={form.control}
|
className="grid w-full gap-8 "
|
||||||
name="memoryReservation"
|
>
|
||||||
render={({ field }) => (
|
<div className="grid w-full md:grid-cols-2 gap-4">
|
||||||
<FormItem>
|
<FormField
|
||||||
<FormLabel>Memory Reservation</FormLabel>
|
control={form.control}
|
||||||
<FormControl>
|
name="memoryReservation"
|
||||||
<Input
|
render={({ field }) => (
|
||||||
placeholder="256 MB"
|
<FormItem>
|
||||||
{...field}
|
<FormLabel>Memory Reservation</FormLabel>
|
||||||
value={field.value?.toString() || ""}
|
<FormControl>
|
||||||
onChange={(e) => {
|
<Input
|
||||||
const value = e.target.value;
|
placeholder="256 MB"
|
||||||
if (value === "") {
|
{...field}
|
||||||
// Si el campo está vacío, establece el valor como null.
|
value={field.value?.toString() || ""}
|
||||||
field.onChange(null);
|
onChange={(e) => {
|
||||||
} else {
|
const value = e.target.value;
|
||||||
const number = Number.parseInt(value, 10);
|
if (value === "") {
|
||||||
if (!Number.isNaN(number)) {
|
// Si el campo está vacío, establece el valor como null.
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
field.onChange(null);
|
||||||
field.onChange(number);
|
} 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>
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="memoryLimit"
|
name="memoryLimit"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Memory Limit</FormLabel>
|
<FormLabel>Memory Limit</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"1024 MB"}
|
placeholder={"1024 MB"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cpuLimit"
|
name="cpuLimit"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Cpu Limit</FormLabel>
|
<FormLabel>Cpu Limit</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"2"}
|
placeholder={"2"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cpuReservation"
|
name="cpuReservation"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Cpu Reservation</FormLabel>
|
<FormLabel>Cpu Reservation</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"1"}
|
placeholder={"1"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -16,47 +16,47 @@ import { useRouter } from "next/router";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mysqlId: string;
|
mysqlId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteMysql = ({ mysqlId }: Props) => {
|
export const DeleteMysql = ({ mysqlId }: Props) => {
|
||||||
const { mutateAsync, isLoading } = api.mysql.remove.useMutation();
|
const { mutateAsync, isLoading } = api.mysql.remove.useMutation();
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 " />
|
<TrashIcon className="size-4 text-muted-foreground " />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
database
|
database
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
mysqlId,
|
mysqlId,
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
push(`/dashboard/project/${data?.projectId}`);
|
||||||
toast.success("Database delete succesfully");
|
toast.success("Database delete succesfully");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to delete the database");
|
toast.error("Error to delete the database");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
environment: z.string(),
|
environment: z.string(),
|
||||||
@@ -93,8 +93,11 @@ export const ShowMysqlEnvironment = ({ mysqlId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
placeholder="MYSQL_PASSWORD=1234567678"
|
language="properties"
|
||||||
|
placeholder={`NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
`}
|
||||||
className="h-96 font-mono"
|
className="h-96 font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<SquarePen className="size-4" />
|
<SquarePen className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
|
|||||||
import { AlertTriangle, Package } from "lucide-react";
|
import { AlertTriangle, Package } from "lucide-react";
|
||||||
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
|
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
|
||||||
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
|
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 {
|
interface Props {
|
||||||
mysqlId: string;
|
mysqlId: string;
|
||||||
}
|
}
|
||||||
@@ -55,15 +57,12 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
|
|||||||
</AddVolumes>
|
</AddVolumes>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2">
|
<div className="flex flex-col pt-2 gap-4">
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
|
<AlertBlock type="info">
|
||||||
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
|
Please remember to click Redeploy after adding, editing, or
|
||||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
deleting a mount to apply the changes.
|
||||||
Please remember to click Redeploy after adding, editing, or
|
</AlertBlock>
|
||||||
deleting a mount to apply the changes.
|
<div className="flex flex-col gap-6">
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-6 pt-6">
|
|
||||||
{data?.mounts.map((mount) => (
|
{data?.mounts.map((mount) => (
|
||||||
<div key={mount.mountId}>
|
<div key={mount.mountId}>
|
||||||
<div
|
<div
|
||||||
@@ -109,7 +108,12 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import React, { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
|
||||||
const addResourcesPostgres = z.object({
|
const addResourcesPostgres = z.object({
|
||||||
memoryReservation: z.number().nullable().optional(),
|
memoryReservation: z.number().nullable().optional(),
|
||||||
@@ -83,6 +84,10 @@ export const ShowPostgresResources = ({ postgresId }: Props) => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<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 {...form}>
|
||||||
<form
|
<form
|
||||||
id="hook-form"
|
id="hook-form"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -16,47 +16,47 @@ import { useRouter } from "next/router";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postgresId: string;
|
postgresId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeletePostgres = ({ postgresId }: Props) => {
|
export const DeletePostgres = ({ postgresId }: Props) => {
|
||||||
const { mutateAsync, isLoading } = api.postgres.remove.useMutation();
|
const { mutateAsync, isLoading } = api.postgres.remove.useMutation();
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 " />
|
<TrashIcon className="size-4 text-muted-foreground " />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
database
|
database
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
postgresId,
|
postgresId,
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
push(`/dashboard/project/${data?.projectId}`);
|
||||||
toast.success("Database delete succesfully");
|
toast.success("Database delete succesfully");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to delete the database");
|
toast.error("Error to delete the database");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
environment: z.string(),
|
environment: z.string(),
|
||||||
@@ -93,8 +93,11 @@ export const ShowPostgresEnvironment = ({ postgresId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
placeholder="POSTGRES_PASSWORD=1234567678"
|
language="properties"
|
||||||
|
placeholder={`NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
`}
|
||||||
className="h-96 font-mono"
|
className="h-96 font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<SquarePen className="size-4" />
|
<SquarePen className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
|
|||||||
import { AlertTriangle, Package } from "lucide-react";
|
import { AlertTriangle, Package } from "lucide-react";
|
||||||
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
|
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
|
||||||
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
|
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 {
|
interface Props {
|
||||||
postgresId: string;
|
postgresId: string;
|
||||||
}
|
}
|
||||||
@@ -59,15 +61,12 @@ export const ShowVolumes = ({ postgresId }: Props) => {
|
|||||||
</AddVolumes>
|
</AddVolumes>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2">
|
<div className="flex flex-col pt-2 gap-4">
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
|
<AlertBlock type="info">
|
||||||
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
|
Please remember to click Redeploy after adding, editing, or
|
||||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
deleting a mount to apply the changes.
|
||||||
Please remember to click Redeploy after adding, editing, or
|
</AlertBlock>
|
||||||
deleting a mount to apply the changes.
|
<div className="flex flex-col gap-6">
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-6 pt-6">
|
|
||||||
{data?.mounts.map((mount) => (
|
{data?.mounts.map((mount) => (
|
||||||
<div key={mount.mountId}>
|
<div key={mount.mountId}>
|
||||||
<div
|
<div
|
||||||
@@ -114,7 +113,12 @@ export const ShowVolumes = ({ postgresId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
186
components/dashboard/project/add-compose.tsx
Normal file
186
components/dashboard/project/add-compose.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const AddComposeSchema = z.object({
|
||||||
|
composeType: z.enum(["docker-compose", "stack"]).optional(),
|
||||||
|
name: z.string().min(1, {
|
||||||
|
message: "Name is required",
|
||||||
|
}),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AddCompose = z.infer<typeof AddComposeSchema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddCompose = ({ projectId }: Props) => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading, error, isError } =
|
||||||
|
api.compose.create.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<AddCompose>({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
composeType: "docker-compose",
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
.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} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
209
components/dashboard/project/add-template.tsx
Normal file
209
components/dashboard/project/add-template.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -64,6 +64,7 @@ export const ShowProjects = () => {
|
|||||||
project?.postgres.length === 0 &&
|
project?.postgres.length === 0 &&
|
||||||
project?.redis.length === 0 &&
|
project?.redis.length === 0 &&
|
||||||
project?.applications.length === 0;
|
project?.applications.length === 0;
|
||||||
|
project?.compose.length === 0;
|
||||||
|
|
||||||
const totalServices =
|
const totalServices =
|
||||||
project?.mariadb.length +
|
project?.mariadb.length +
|
||||||
@@ -71,7 +72,8 @@ export const ShowProjects = () => {
|
|||||||
project?.mysql.length +
|
project?.mysql.length +
|
||||||
project?.postgres.length +
|
project?.postgres.length +
|
||||||
project?.redis.length +
|
project?.redis.length +
|
||||||
project?.applications.length;
|
project?.applications.length +
|
||||||
|
project?.compose.length;
|
||||||
return (
|
return (
|
||||||
<div key={project.projectId} className="w-full lg:max-w-md">
|
<div key={project.projectId} className="w-full lg:max-w-md">
|
||||||
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
|
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
|
||||||
@@ -89,9 +91,12 @@ export const ShowProjects = () => {
|
|||||||
<span className="flex flex-col gap-1.5">
|
<span className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookIcon className="size-4 text-muted-foreground" />
|
<BookIcon className="size-4 text-muted-foreground" />
|
||||||
<span className="text-base font-medium leading-none">
|
<Link
|
||||||
|
className="text-base font-medium leading-none"
|
||||||
|
href={`/dashboard/project/${project.projectId}`}
|
||||||
|
>
|
||||||
{project.name}
|
{project.name}
|
||||||
</span>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -21,213 +21,218 @@ import React, { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
|
||||||
const addResourcesRedis = z.object({
|
const addResourcesRedis = z.object({
|
||||||
memoryReservation: z.number().nullable().optional(),
|
memoryReservation: z.number().nullable().optional(),
|
||||||
cpuLimit: z.number().nullable().optional(),
|
cpuLimit: z.number().nullable().optional(),
|
||||||
memoryLimit: z.number().nullable().optional(),
|
memoryLimit: z.number().nullable().optional(),
|
||||||
cpuReservation: z.number().nullable().optional(),
|
cpuReservation: z.number().nullable().optional(),
|
||||||
});
|
});
|
||||||
interface Props {
|
interface Props {
|
||||||
redisId: string;
|
redisId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddResourcesRedis = z.infer<typeof addResourcesRedis>;
|
type AddResourcesRedis = z.infer<typeof addResourcesRedis>;
|
||||||
export const ShowRedisResources = ({ redisId }: Props) => {
|
export const ShowRedisResources = ({ redisId }: Props) => {
|
||||||
const { data, refetch } = api.redis.one.useQuery(
|
const { data, refetch } = api.redis.one.useQuery(
|
||||||
{
|
{
|
||||||
redisId,
|
redisId,
|
||||||
},
|
},
|
||||||
{ enabled: !!redisId },
|
{ enabled: !!redisId },
|
||||||
);
|
);
|
||||||
const { mutateAsync, isLoading } = api.redis.update.useMutation();
|
const { mutateAsync, isLoading } = api.redis.update.useMutation();
|
||||||
const form = useForm<AddResourcesRedis>({
|
const form = useForm<AddResourcesRedis>({
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
resolver: zodResolver(addResourcesRedis),
|
resolver: zodResolver(addResourcesRedis),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
cpuLimit: data?.cpuLimit || undefined,
|
cpuLimit: data?.cpuLimit || undefined,
|
||||||
cpuReservation: data?.cpuReservation || undefined,
|
cpuReservation: data?.cpuReservation || undefined,
|
||||||
memoryLimit: data?.memoryLimit || undefined,
|
memoryLimit: data?.memoryLimit || undefined,
|
||||||
memoryReservation: data?.memoryReservation || undefined,
|
memoryReservation: data?.memoryReservation || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
|
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
|
||||||
|
|
||||||
const onSubmit = async (formData: AddResourcesRedis) => {
|
const onSubmit = async (formData: AddResourcesRedis) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
redisId,
|
redisId,
|
||||||
cpuLimit: formData.cpuLimit || null,
|
cpuLimit: formData.cpuLimit || null,
|
||||||
cpuReservation: formData.cpuReservation || null,
|
cpuReservation: formData.cpuReservation || null,
|
||||||
memoryLimit: formData.memoryLimit || null,
|
memoryLimit: formData.memoryLimit || null,
|
||||||
memoryReservation: formData.memoryReservation || null,
|
memoryReservation: formData.memoryReservation || null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Resources Updated");
|
toast.success("Resources Updated");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to Update the resources");
|
toast.error("Error to Update the resources");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Resources</CardTitle>
|
<CardTitle className="text-xl">Resources</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
If you want to decrease or increase the resources to a specific
|
If you want to decrease or increase the resources to a specific
|
||||||
application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<Form {...form}>
|
<AlertBlock type="info">
|
||||||
<form
|
Please remember to click Redeploy after modify the resources to apply
|
||||||
id="hook-form"
|
the changes.
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
</AlertBlock>
|
||||||
className="grid w-full gap-8 "
|
<Form {...form}>
|
||||||
>
|
<form
|
||||||
<div className="grid w-full md:grid-cols-2 gap-4">
|
id="hook-form"
|
||||||
<FormField
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
control={form.control}
|
className="grid w-full gap-8 "
|
||||||
name="memoryReservation"
|
>
|
||||||
render={({ field }) => (
|
<div className="grid w-full md:grid-cols-2 gap-4">
|
||||||
<FormItem>
|
<FormField
|
||||||
<FormLabel>Memory Reservation</FormLabel>
|
control={form.control}
|
||||||
<FormControl>
|
name="memoryReservation"
|
||||||
<Input
|
render={({ field }) => (
|
||||||
placeholder="256 MB"
|
<FormItem>
|
||||||
{...field}
|
<FormLabel>Memory Reservation</FormLabel>
|
||||||
value={field.value?.toString() || ""}
|
<FormControl>
|
||||||
onChange={(e) => {
|
<Input
|
||||||
const value = e.target.value;
|
placeholder="256 MB"
|
||||||
if (value === "") {
|
{...field}
|
||||||
// Si el campo está vacío, establece el valor como null.
|
value={field.value?.toString() || ""}
|
||||||
field.onChange(null);
|
onChange={(e) => {
|
||||||
} else {
|
const value = e.target.value;
|
||||||
const number = Number.parseInt(value, 10);
|
if (value === "") {
|
||||||
if (!Number.isNaN(number)) {
|
// Si el campo está vacío, establece el valor como null.
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
field.onChange(null);
|
||||||
field.onChange(number);
|
} 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>
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="memoryLimit"
|
name="memoryLimit"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Memory Limit</FormLabel>
|
<FormLabel>Memory Limit</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"1024 MB"}
|
placeholder={"1024 MB"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cpuLimit"
|
name="cpuLimit"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Cpu Limit</FormLabel>
|
<FormLabel>Cpu Limit</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"2"}
|
placeholder={"2"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cpuReservation"
|
name="cpuReservation"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Cpu Reservation</FormLabel>
|
<FormLabel>Cpu Reservation</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"1"}
|
placeholder={"1"}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value?.toString() || ""}
|
value={field.value?.toString() || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
// Si el campo está vacío, establece el valor como null.
|
// Si el campo está vacío, establece el valor como null.
|
||||||
field.onChange(null);
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
const number = Number.parseInt(value, 10);
|
const number = Number.parseInt(value, 10);
|
||||||
if (!Number.isNaN(number)) {
|
if (!Number.isNaN(number)) {
|
||||||
// Solo actualiza el valor si se convierte a un número válido.
|
// Solo actualiza el valor si se convierte a un número válido.
|
||||||
field.onChange(number);
|
field.onChange(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -16,47 +16,47 @@ import { useRouter } from "next/router";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
redisId: string;
|
redisId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteRedis = ({ redisId }: Props) => {
|
export const DeleteRedis = ({ redisId }: Props) => {
|
||||||
const { mutateAsync, isLoading } = api.redis.remove.useMutation();
|
const { mutateAsync, isLoading } = api.redis.remove.useMutation();
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 " />
|
<TrashIcon className="size-4 text-muted-foreground " />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
database
|
database
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
redisId,
|
redisId,
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
push(`/dashboard/project/${data?.projectId}`);
|
||||||
toast.success("Database delete succesfully");
|
toast.success("Database delete succesfully");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to delete the database");
|
toast.error("Error to delete the database");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
environment: z.string(),
|
environment: z.string(),
|
||||||
@@ -93,9 +93,12 @@ export const ShowRedisEnvironment = ({ redisId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
placeholder="REDIS_PASSWORD=1234567678"
|
language="properties"
|
||||||
className="h-96"
|
placeholder={`NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
`}
|
||||||
|
className="h-96 font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<SquarePen className="size-4" />
|
<SquarePen className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { api } from "@/utils/api";
|
|||||||
import { AlertTriangle, Package } from "lucide-react";
|
import { AlertTriangle, Package } from "lucide-react";
|
||||||
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
|
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
|
||||||
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
|
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
|
||||||
|
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
|
||||||
interface Props {
|
interface Props {
|
||||||
redisId: string;
|
redisId: string;
|
||||||
}
|
}
|
||||||
@@ -109,7 +110,12 @@ export const ShowVolumes = ({ redisId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
const appearanceFormSchema = z.object({
|
const appearanceFormSchema = z.object({
|
||||||
theme: z.enum(["light", "dark"], {
|
theme: z.enum(["light", "dark", "system"], {
|
||||||
required_error: "Please select a theme.",
|
required_error: "Please select a theme.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -34,7 +34,7 @@ type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
|
|||||||
|
|
||||||
// This can come from your database or API.
|
// This can come from your database or API.
|
||||||
const defaultValues: Partial<AppearanceFormValues> = {
|
const defaultValues: Partial<AppearanceFormValues> = {
|
||||||
theme: "light",
|
theme: "system",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppearanceForm() {
|
export function AppearanceForm() {
|
||||||
@@ -46,7 +46,7 @@ export function AppearanceForm() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
form.reset({
|
||||||
theme: theme === "light" ? "light" : "dark",
|
theme: (theme ?? "system") as AppearanceFormValues["theme"],
|
||||||
});
|
});
|
||||||
}, [form, theme]);
|
}, [form, theme]);
|
||||||
function onSubmit(data: AppearanceFormValues) {
|
function onSubmit(data: AppearanceFormValues) {
|
||||||
@@ -81,28 +81,15 @@ export function AppearanceForm() {
|
|||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={field.value}
|
defaultValue={field.value}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
className="grid max-w-md grid-cols-1 sm:grid-cols-2 gap-8 pt-2"
|
className="grid max-w-md md:max-w-lg grid-cols-1 sm:grid-cols-3 gap-8 pt-2"
|
||||||
>
|
>
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
|
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<RadioGroupItem value="light" className="sr-only" />
|
<RadioGroupItem value="light" className="sr-only" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
|
<div className="items-center rounded-md border-2 border-muted p-1 hover:bg-accent transition-colors cursor-pointer">
|
||||||
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
|
<img src="/images/theme-light.svg" alt="light" />
|
||||||
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="block w-full p-2 text-center font-normal">
|
<span className="block w-full p-2 text-center font-normal">
|
||||||
Light
|
Light
|
||||||
@@ -114,27 +101,30 @@ export function AppearanceForm() {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<RadioGroupItem value="dark" className="sr-only" />
|
<RadioGroupItem value="dark" className="sr-only" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
|
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer">
|
||||||
<div className="space-y-2 rounded-sm bg-slate-950 p-2">
|
<img src="/images/theme-dark.svg" alt="dark" />
|
||||||
<div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-2 w-[80px] rounded-lg bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="block w-full p-2 text-center font-normal">
|
<span className="block w-full p-2 text-center font-normal">
|
||||||
Dark
|
Dark
|
||||||
</span>
|
</span>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroupItem
|
||||||
|
value="system"
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer">
|
||||||
|
<img src="/images/theme-system.svg" alt="system" />
|
||||||
|
</div>
|
||||||
|
<span className="block w-full p-2 text-center font-normal">
|
||||||
|
System
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user