Docker compose support (#111)

* feat(WIP): compose implementation

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

* feat: add compose config test unique

* feat: add tests for each unique config

* feat: implement lodash for docker compose parsing

* feat: add tests for generating compose file

* refactor: implement logs docker compose

* refactor: composeFile set not empty

* feat: implement providers for compose deployments

* feat: add Files volumes to compose

* feat: add stop compose button

* refactor: change strategie of building compose

* feat: create .env file in composepath

* refactor: simplify git and github function

* chore: update deps

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

* chore: update lock yaml

* refactor: use code editor

* feat: add monitoring for app types

* refactor: reset stats on change appName

* refactor: add option to clean monitoring folder

* feat: show current command that will run

* feat: add prefix

* fix: add missing types

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

* refactor: customize error page

* refactor: unified deployments to be a single one

* feat: add vitest to ci/cd

* revert: back to initial version

* refactor: add maxconcurrency vitest

* refactor: add pool forks to vitest

* feat: add pocketbase template

* fix: update path resolution compose

* removed

* feat: add template pocketbase

* feat: add pocketbase template

* feat: add support button

* feat: add plausible template

* feat: add calcom template

* feat: add version to each template

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

* refactor: add loader when download the image

* fix: use base64 to generate keys plausible

* feat: add recognized domain names by enviroment compose

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

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

* chore: add templates section to contributing

* chore: add example contributing
This commit is contained in:
Mauricio Siu
2024-06-02 15:26:28 -06:00
committed by GitHub
parent 1df6db738e
commit 8f9d21c0f8
139 changed files with 16513 additions and 1208 deletions

View File

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

View File

@@ -151,3 +151,89 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.
- Once your pull request is merged, you will be automatically added as a contributor to the project.
Thank you for your contribution!
## Templates
To add a new template, go to `templates` folder and create a new folder with the name of the template.
Let's take the example of `plausible` template.
1. create a folder in `templates/plausible`
2. create a `docker-compose.yml` file inside the folder with the content of compose.
3. create a `index.ts` file inside the folder with the following code as base:
```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 = [
`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.
- Test first on a vps or a server to make sure the template works.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
);
};

View File

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

View File

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

View File

@@ -0,0 +1,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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HeartIcon } from "lucide-react";
export const ShowSupport = () => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="rounded-full">
<span className="text-sm font-semibold">Support </span>
<HeartIcon className="size-4 text-red-500 fill-red-600 animate-heartbeat " />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl ">
<DialogHeader className="text-center flex justify-center items-center">
<DialogTitle>Dokploy Support</DialogTitle>
<DialogDescription>Consider supporting Dokploy</DialogDescription>
</DialogHeader>
<div className="grid w-full gap-4">
<div className="flex flex-col gap-4">
<span className="text-sm font-semibold">Name</span>
</div>
</div>
</DialogContent>
</Dialog>
);
};

10
docker/feat.sh Normal file
View File

@@ -0,0 +1,10 @@
# BUILDER=$(docker buildx create --use)
# docker buildx build --platform linux/amd64,linux/arm64 --pull --rm -t "dokploy/dokploy:feature" -f 'Dockerfile' --push .
docker build --platform linux/amd64 --pull --rm -t "dokploy/dokploy:feature" -f 'Dockerfile' .
# docker build --platform linux/amd64 --pull --rm -t "dokploy/dokploy:feature" -f 'Dockerfile' .

View File

@@ -15,4 +15,5 @@ else
docker buildx build --platform linux/amd64,linux/arm64 --pull --rm -t "dokploy/dokploy:latest" -t "dokploy/dokploy:${VERSION}" -f 'Dockerfile' --push .
fi
docker buildx rm $BUILDER
docker buildx rm $BUILDER

View File

@@ -0,0 +1,57 @@
DO $$ BEGIN
CREATE TYPE "public"."composeType" AS ENUM('docker-compose', 'stack');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."sourceTypeCompose" AS ENUM('git', 'github', 'raw');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TYPE "serviceType" ADD VALUE 'compose';--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "compose" (
"composeId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"appName" text NOT NULL,
"description" text,
"env" text,
"composeFile" text DEFAULT '' NOT NULL,
"refreshToken" text,
"sourceType" "sourceTypeCompose" DEFAULT 'github' NOT NULL,
"composeType" "composeType" DEFAULT 'docker-compose' NOT NULL,
"repository" text,
"owner" text,
"branch" text,
"autoDeploy" boolean,
"customGitUrl" text,
"customGitBranch" text,
"customGitSSHKey" text,
"command" text DEFAULT '' NOT NULL,
"composePath" text DEFAULT './docker-compose.yml' NOT NULL,
"composeStatus" "applicationStatus" DEFAULT 'idle' NOT NULL,
"projectId" text NOT NULL,
"createdAt" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "deployment" ALTER COLUMN "applicationId" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "composeId" text;--> statement-breakpoint
ALTER TABLE "mount" ADD COLUMN "composeId" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "compose" ADD CONSTRAINT "compose_projectId_project_projectId_fk" FOREIGN KEY ("projectId") REFERENCES "public"."project"("projectId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "mount" ADD CONSTRAINT "mount_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,13 @@
"when": 1716076179443,
"tag": "0013_blushing_starjammers",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1716715367982,
"tag": "0014_same_hammerhead",
"breakpoints": true
}
]
}

View File

@@ -3,9 +3,39 @@
* for Docker builds.
*/
import CopyWebpackPlugin from 'copy-webpack-plugin';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/** @type {import("next").NextConfig} */
const nextConfig = {
reactStrictMode: true,
webpack: (config) => {
config.plugins.push(
new CopyWebpackPlugin({
patterns: [
{
from: path.resolve(__dirname, 'templates/**/*.yml'),
to: ({ context, absoluteFilename }) => {
const relativePath = path.relative(
path.resolve(__dirname, 'templates'),
absoluteFilename || context,
);
return path.join(__dirname, '.next', 'templates', relativePath);
},
globOptions: {
ignore: ['**/node_modules/**'],
},
},
],
})
);
return config;
},
/**
* If you are using `appDir` then you must comment the below `i18n` config out.

View File

@@ -12,10 +12,10 @@
"setup": "tsx -r dotenv/config setup.ts && sleep 5 && pnpm run migration:run",
"reset-password": "node dist/reset-password.mjs",
"dev": "tsx watch -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"studio":"drizzle-kit studio --config ./server/db/drizzle.config.ts",
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
"migration:run": "tsx -r dotenv/config migration.ts",
"migration:up":"drizzle-kit up --config ./server/db/drizzle.config.ts",
"migration:up": "drizzle-kit up --config ./server/db/drizzle.config.ts",
"migration:drop": "drizzle-kit drop --config ./server/db/drizzle.config.ts",
"db:push": "drizzle-kit --config ./server/db/drizzle.config.ts",
"db:truncate": "tsx -r dotenv/config ./server/db/reset.ts",
@@ -27,10 +27,13 @@
"docker:push": "./docker/push.sh",
"docker:build:canary": "./docker/build.sh canary",
"docker:push:canary": "./docker/push.sh canary",
"version": "echo $(node -p \"require('./package.json').version\")"
"version": "echo $(node -p \"require('./package.json').version\")",
"test": "vitest --config __test__/vitest.config.ts"
},
"dependencies": {
"@codemirror/language":"^6.10.1",
"@aws-sdk/client-s3": "3.515.0",
"@codemirror/legacy-modes":"6.4.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1",
"@faker-js/faker": "^8.4.1",
@@ -67,6 +70,7 @@
"@xterm/xterm": "^5.4.0",
"bcrypt": "5.1.1",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.4.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
@@ -74,6 +78,7 @@
"copy-to-clipboard": "^3.3.3",
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dockerode-compose": "^1.4.0",
"dockerstats": "2.4.2",
"dotenv": "16.4.5",
"drizzle-orm": "^0.30.8",
@@ -81,6 +86,8 @@
"hi-base32": "^0.5.1",
"input-otp": "^1.2.4",
"js-yaml": "4.1.0",
"k6": "^0.0.0",
"lodash": "4.17.21",
"lucia": "^3.0.1",
"lucide-react": "^0.312.0",
"nanoid": "3",
@@ -106,13 +113,16 @@
"use-resize-observer": "9.1.0",
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"zod": "^3.23.4"
"zod": "^3.23.4",
"copy-webpack-plugin": "^12.0.2"
},
"devDependencies": {
"@biomejs/biome": "1.7.1",
"@types/bcrypt": "5.0.2",
"@types/dockerode": "3.3.23",
"@types/js-yaml": "4.0.9",
"@types/lodash": "4.17.4",
"@types/node": "^18.17.0",
"@types/node-os-utils": "1.3.4",
"@types/node-schedule": "2.1.6",
@@ -131,6 +141,8 @@
"tailwindcss": "^3.4.1",
"tsx": "^4.7.0",
"typescript": "^5.4.2",
"vite-tsconfig-paths": "4.3.2",
"vitest": "^1.6.0",
"xterm-readline": "1.1.1"
},
"ct3aMetadata": {

View File

@@ -1,57 +1,98 @@
import { Logo } from "@/components/shared/logo";
import { buttonVariants } from "@/components/ui/button";
import type { NextPageContext } from "next";
import Link from "next/link";
interface Props {
statusCode: number;
error?: Error;
}
export default function Custom404({ statusCode }: Props) {
export default function Custom404({ statusCode, error }: Props) {
const displayStatusCode = statusCode || 400;
console.log(error, statusCode);
return (
<div className="h-screen">
<section className="relative z-10 bg-background h-screen items-center justify-center">
<div className="container mx-auto h-screen items-center justify-center flex">
<div className="-mx-4 flex">
<div className="w-full px-4">
<div className="mx-auto max-w-[700px] text-center">
<h2 className="mb-2 text-[50px] font-bold leading-none text-white sm:text-[80px]">
{statusCode
? `An error ${statusCode} occurred on server`
: "An error occurred on client"}
</h2>
<h4 className="mb-3 text-[22px] font-semibold leading-tight text-white">
Oops! That page cant be found
</h4>
<p className="mb-8 text-lg text-white">
The page you are looking was not found
</p>
<Link
href="/"
className={buttonVariants({
size: "lg",
})}
>
Go To Home
</Link>
<div className="max-w-[50rem] flex flex-col mx-auto size-full">
<header className="mb-auto flex justify-center z-50 w-full py-4">
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
<Link
href="https://dokploy.com"
target="_blank"
className="flex flex-row items-center gap-2"
>
<Logo />
<span className="font-medium text-sm">Dokploy</span>
</Link>
</nav>
</header>
<main id="content">
<div className="text-center py-10 px-4 sm:px-6 lg:px-8">
<h1 className="block text-7xl font-bold text-primary sm:text-9xl">
{displayStatusCode}
</h1>
{/* <AlertBlock className="max-w-xs mx-auto">
<p className="text-muted-foreground">
Oops, something went wrong.
</p>
<p className="text-muted-foreground">
Sorry, we couldn't find your page.
</p>
</AlertBlock> */}
<p className="mt-3 text-muted-foreground">
{statusCode === 404
? "Sorry, we couldn't find your page."
: "Oops, something went wrong."}
</p>
{error && (
<div className="mt-3 text-red-500">
<p>{error.message}</p>
</div>
)}
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
<Link
href="/dashboard/projects"
className={buttonVariants({
variant: "secondary",
className: "flex flex-row gap-2",
})}
>
<svg
className="flex-shrink-0 size-4"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>
Go to homepage
</Link>
</div>
</div>
</div>
</main>
<div className="absolute left-0 top-0 -z-10 flex h-full w-full items-center justify-between space-x-5 md:space-x-8 lg:space-x-14">
<div className="h-full w-1/3 bg-gradient-to-t from-[#FFFFFF14] to-[#C4C4C400]" />
<div className="flex h-full w-1/3">
<div className="h-full w-1/2 bg-gradient-to-b from-[#FFFFFF14] to-[#C4C4C400]" />
<div className="h-full w-1/2 bg-gradient-to-t from-[#FFFFFF14] to-[#C4C4C400]" />
<footer className="mt-auto text-center py-5">
<div className="max-w-[85rem] mx-auto px-4 sm:px-6 lg:px-8">
<p className="text-sm text-gray-500">
Submit Log in issue on Github
</p>
</div>
<div className="h-full w-1/3 bg-gradient-to-b from-[#FFFFFF14] to-[#C4C4C400]" />
</div>
</section>
</footer>
</div>
</div>
);
}
// @ts-ignore
Error.getInitialProps = ({ res, err }) => {
Error.getInitialProps = ({ res, err, ...rest }: NextPageContext) => {
console.log(err, rest);
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
return { statusCode };
return { statusCode, error: err };
};

View File

@@ -47,13 +47,12 @@ export default async function handler(
webhookDockerTag &&
webhookDockerTag !== applicationDockerTag
) {
res.status(301).json({
res.status(301).json({
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
});
return;
}
}
else if (sourceType === "github") {
} else if (sourceType === "github") {
const branchName = extractBranchName(req.headers, req.body);
if (!branchName || branchName !== application.branch) {
res.status(301).json({ message: "Branch Not Match" });
@@ -77,6 +76,7 @@ export default async function handler(
applicationId: application.applicationId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "application",
};
await myQueue.add(
"deployments",
@@ -118,16 +118,19 @@ function extractImageTag(dockerImage: string | null) {
/**
* @link https://docs.docker.com/docker-hub/webhooks/#example-webhook-payload
*/
function extractImageTagFromRequest(headers: any, body: any): string | null {
export const extractImageTagFromRequest = (
headers: any,
body: any,
): string | null => {
if (headers["user-agent"]?.includes("Go-http-client")) {
if (body.push_data && body.repository) {
return body.push_data.tag;
}
}
return null;
}
};
function extractCommitMessage(headers: any, body: any) {
export const extractCommitMessage = (headers: any, body: any) => {
// GitHub
if (headers["x-github-event"]) {
return body.head_commit ? body.head_commit.message : "NEW COMMIT";
@@ -161,9 +164,9 @@ function extractCommitMessage(headers: any, body: any) {
}
return "NEW CHANGES";
}
};
function extractBranchName(headers: any, body: any) {
export const extractBranchName = (headers: any, body: any) => {
if (headers["x-github-event"] || headers["x-gitea-event"]) {
return body?.ref?.replace("refs/heads/", "");
}
@@ -177,4 +180,4 @@ function extractBranchName(headers: any, body: any) {
}
return null;
}
};

View File

@@ -0,0 +1,83 @@
import { db } from "@/server/db";
import { compose } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/deployments-queue";
import { myQueue } from "@/server/queues/queueSetup";
import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { extractBranchName, extractCommitMessage } from "../[refreshToken]";
import { updateCompose } from "@/server/api/services/compose";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { refreshToken } = req.query;
try {
if (req.headers["x-github-event"] === "ping") {
res.status(200).json({ message: "Ping received, webhook is active" });
return;
}
const composeResult = await db.query.compose.findFirst({
where: eq(compose.refreshToken, refreshToken as string),
with: {
project: true,
},
});
if (!composeResult) {
res.status(404).json({ message: "Compose Not Found" });
return;
}
if (!composeResult?.autoDeploy) {
res.status(400).json({ message: "Compose Not Deployable" });
return;
}
const deploymentTitle = extractCommitMessage(req.headers, req.body);
const sourceType = composeResult.sourceType;
if (sourceType === "github") {
const branchName = extractBranchName(req.headers, req.body);
if (!branchName || branchName !== composeResult.branch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
} else if (sourceType === "git") {
const branchName = extractBranchName(req.headers, req.body);
if (!branchName || branchName !== composeResult.customGitBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
}
try {
await updateCompose(composeResult.composeId, {
composeStatus: "running",
});
const jobData: DeploymentJob = {
composeId: composeResult.composeId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "compose",
};
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
} catch (error) {
res.status(400).json({ message: "Error To Deploy Compose", error });
return;
}
res.status(200).json({ message: "Compose Deployed Succesfully" });
} catch (error) {
console.log(error);
res.status(400).json({ message: "Error To Deploy Compose", error });
}
}

View File

@@ -1,5 +1,7 @@
import { AddApplication } from "@/components/dashboard/project/add-application";
import { AddCompose } from "@/components/dashboard/project/add-compose";
import { AddDatabase } from "@/components/dashboard/project/add-database";
import { AddTemplate } from "@/components/dashboard/project/add-template";
import {
MariadbIcon,
MongodbIcon,
@@ -31,7 +33,7 @@ import type { findProjectById } from "@/server/api/services/project";
import { validateRequest } from "@/server/auth/auth";
import { api } from "@/utils/api";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { FolderInput, GlobeIcon, PlusIcon } from "lucide-react";
import { CircuitBoard, FolderInput, GlobeIcon, PlusIcon } from "lucide-react";
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
@@ -43,7 +45,14 @@ import superjson from "superjson";
export type Services = {
name: string;
type: "mariadb" | "application" | "postgres" | "mysql" | "mongo" | "redis";
type:
| "mariadb"
| "application"
| "postgres"
| "mysql"
| "mongo"
| "redis"
| "compose";
description?: string | null;
id: string;
createdAt: string;
@@ -113,7 +122,24 @@ export const extractServices = (data: Project | undefined) => {
description: item.description,
})) || [];
applications.push(...mysql, ...redis, ...mongo, ...postgres, ...mariadb);
const compose: Services[] =
data?.compose.map((item) => ({
name: item.name,
type: "compose",
id: item.composeId,
createdAt: item.createdAt,
status: item.composeStatus,
description: item.description,
})) || [];
applications.push(
...mysql,
...redis,
...mongo,
...postgres,
...mariadb,
...compose,
);
applications.sort((a, b) => {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
@@ -144,7 +170,8 @@ const Project = (
data?.mysql?.length === 0 &&
data?.postgres?.length === 0 &&
data?.redis?.length === 0 &&
data?.applications?.length === 0;
data?.applications?.length === 0 &&
data?.compose?.length === 0;
const applications = extractServices(data);
@@ -185,6 +212,8 @@ const Project = (
<DropdownMenuSeparator />
<AddApplication projectId={projectId} />
<AddDatabase projectId={projectId} />
<AddCompose projectId={projectId} />
<AddTemplate projectId={projectId} />
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -249,6 +278,9 @@ const Project = (
{service.type === "application" && (
<GlobeIcon className="h-6 w-6" />
)}
{service.type === "compose" && (
<CircuitBoard className="h-6 w-6" />
)}
</span>
</div>
</CardTitle>

View File

@@ -14,7 +14,6 @@ import { ShowGeneralApplication } from "@/components/dashboard/application/gener
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { UpdateApplication } from "@/components/dashboard/application/update-application";
import { DockerMonitoring } from "@/components/dashboard/monitoring/docker/show";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ProjectLayout } from "@/components/layouts/project-layout";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import {

View File

@@ -0,0 +1,245 @@
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { ShowVolumesCompose } from "@/components/dashboard/compose/advanced/show-volumes";
import { DeleteCompose } from "@/components/dashboard/compose/delete-compose";
import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose";
import { ShowEnvironmentCompose } from "@/components/dashboard/compose/enviroment/show";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
import { ShowMonitoringCompose } from "@/components/dashboard/compose/monitoring/show";
import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
import { ProjectLayout } from "@/components/layouts/project-layout";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
} from "@/components/ui/breadcrumb";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { appRouter } from "@/server/api/root";
import { validateRequest } from "@/server/auth/auth";
import { api } from "@/utils/api";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { CircuitBoard } from "lucide-react";
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
import superjson from "superjson";
type TabState =
| "projects"
| "settings"
| "advanced"
| "deployments"
| "monitoring";
const Service = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
) => {
const { composeId, activeTab } = props;
const router = useRouter();
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.compose.one.useQuery(
{ composeId },
{
refetchInterval: 5000,
},
);
const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
return (
<div className="pb-10">
<div className="flex flex-col gap-4">
<Breadcrumb>
<BreadcrumbItem>
<BreadcrumbLink as={Link} href="/dashboard/projects">
Projects
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink
as={Link}
href={`/dashboard/project/${data?.project.projectId}`}
>
{data?.project.name}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
<h1 className="flex items-center gap-2 text-xl font-bold lg:text-3xl">
{data?.name}
</h1>
<span className="text-sm">{data?.appName}</span>
</div>
{data?.description && (
<p className="text-sm text-muted-foreground max-w-6xl">
{data?.description}
</p>
)}
</div>
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.composeStatus} />
</div>
<CircuitBoard className="h-6 w-6 text-muted-foreground" />
</div>
</header>
</div>
<Tabs
value={tab}
defaultValue="general"
className="w-full"
onValueChange={(e) => {
setSab(e as TabState);
const newPath = `/dashboard/project/${projectId}/services/compose/${composeId}?tab=${e}`;
router.push(newPath, undefined, { shallow: true });
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4">
<TabsList className="md:grid md:w-fit md:grid-cols-6 max-md:overflow-y-scroll justify-start">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
<div className="flex flex-row gap-2">
<UpdateCompose composeId={composeId} />
{(auth?.rol === "admin" || user?.canDeleteServices) && (
<DeleteCompose composeId={composeId} />
)}
</div>
</div>
<TabsContent value="general">
<div className="flex flex-col gap-4 pt-2.5">
<ShowGeneralCompose composeId={composeId} />
</div>
</TabsContent>
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironmentCompose composeId={composeId} />
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="flex flex-col gap-4 pt-2.5">
<ShowMonitoringCompose
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
/>
</div>
</TabsContent>
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogsCompose appName={data?.appName || ""} />
</div>
</TabsContent>
<TabsContent value="deployments">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDeploymentsCompose composeId={composeId} />
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommandCompose composeId={composeId} />
<ShowVolumesCompose composeId={composeId} />
</div>
</TabsContent>
</Tabs>
</div>
);
};
export default Service;
Service.getLayout = (page: ReactElement) => {
return <ProjectLayout>{page}</ProjectLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{
composeId: string;
activeTab: TabState;
}>,
) {
const { query, params, req, res } = ctx;
const activeTab = query.tab;
const { user, session } = await validateRequest(req, res);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
// Fetch data from external API
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
},
transformer: superjson,
});
// Valid project, if not return to initial homepage....
if (typeof params?.composeId === "string") {
try {
await helpers.compose.one.fetch({
composeId: params?.composeId,
});
return {
props: {
trpcState: helpers.dehydrate(),
composeId: params?.composeId,
activeTab: (activeTab || "general") as TabState,
},
};
} catch (error) {
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
},
};
}
}
return {
redirect: {
permanent: false,
destination: "/",
},
};
}

1272
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/templates/calcom.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,9 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="25.536" y="13.4861" width="1.71467" height="16.7338" transform="rotate(45.9772 25.536 13.4861)" fill="white"/>
<path d="M26 14H36.8C37.4628 14 38 14.5373 38 15.2V36.8C38 37.4628 37.4628 38 36.8 38H15.2C14.5373 38 14 37.4628 14 36.8V26" fill="white"/>
<path d="M26 14H36.8C37.4628 14 38 14.5373 38 15.2V36.8C38 37.4628 37.4628 38 36.8 38H15.2C14.5373 38 14 37.4628 14 36.8V26" stroke="#16161a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M26 14V3.2C26 2.53726 25.4628 2 24.8 2H3.2C2.53726 2 2 2.53726 2 3.2V24.8C2 25.4628 2.53726 26 3.2 26H14" fill="white"/>
<path d="M26 14V3.2C26 2.53726 25.4628 2 24.8 2H3.2C2.53726 2 2 2.53726 2 3.2V24.8C2 25.4628 2.53726 26 3.2 26H14" stroke="#16161a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 20C9.44772 20 9 19.5523 9 19V8C9 7.44772 9.44772 7 10 7H13.7531C14.4801 7 15.1591 7.07311 15.7901 7.21932C16.4348 7.35225 16.9904 7.58487 17.4568 7.91718C17.9369 8.2362 18.3141 8.6682 18.5885 9.21319C18.8628 9.74489 19 10.4029 19 11.1871C19 11.9448 18.856 12.6028 18.5679 13.161C18.2936 13.7193 17.9163 14.1779 17.4362 14.5368C16.9561 14.8957 16.4005 15.1616 15.7695 15.3344C15.1385 15.5072 14.4664 15.5936 13.7531 15.5936H13.0247C12.4724 15.5936 12.0247 16.0413 12.0247 16.5936V19C12.0247 19.5523 11.577 20 11.0247 20H10ZM12.0247 12.2607C12.0247 12.813 12.4724 13.2607 13.0247 13.2607H13.5679C15.214 13.2607 16.037 12.5695 16.037 11.1871C16.037 10.5092 15.8244 10.0307 15.3992 9.75153C14.9877 9.47239 14.3772 9.33282 13.5679 9.33282H13.0247C12.4724 9.33282 12.0247 9.78054 12.0247 10.3328V12.2607Z" fill="#16161a"/>
<path d="M22 33C21.4477 33 21 32.5523 21 32V21C21 20.4477 21.4477 20 22 20H25.4877C26.1844 20 26.8265 20.0532 27.4139 20.1595C28.015 20.2526 28.5342 20.4254 28.9713 20.6779C29.4085 20.9305 29.75 21.2628 29.9959 21.6748C30.2555 22.0869 30.3852 22.6053 30.3852 23.2301C30.3852 23.5225 30.3374 23.8149 30.2418 24.1074C30.1598 24.3998 30.0232 24.6723 29.832 24.9248C29.6407 25.1774 29.4016 25.4034 29.1148 25.6028C28.837 25.7958 28.5081 25.939 28.1279 26.0323C28.1058 26.0378 28.0902 26.0575 28.0902 26.0802V26.0802C28.0902 26.1039 28.1073 26.1242 28.1306 26.1286C29.0669 26.3034 29.7774 26.6332 30.2623 27.1181C30.7541 27.6099 31 28.2945 31 29.1718C31 29.8364 30.8702 30.408 30.6107 30.8865C30.3511 31.365 29.9891 31.7638 29.5246 32.0828C29.0601 32.3885 28.5137 32.6212 27.8852 32.7807C27.2705 32.9269 26.6011 33 25.8771 33H22ZM24.0123 24.2239C24.0123 24.7762 24.46 25.2239 25.0123 25.2239H25.3443C26.082 25.2239 26.6148 25.0844 26.9426 24.8052C27.2705 24.5261 27.4344 24.1339 27.4344 23.6288C27.4344 23.1503 27.2637 22.8113 26.9221 22.612C26.5943 22.3993 26.0751 22.2929 25.3648 22.2929H25.0123C24.46 22.2929 24.0123 22.7407 24.0123 23.2929V24.2239ZM24.0123 29.7071C24.0123 30.2593 24.46 30.7071 25.0123 30.7071H25.6311C27.2432 30.7071 28.0492 30.1222 28.0492 28.9525C28.0492 28.3809 27.8511 27.9688 27.4549 27.7163C27.0724 27.4637 26.4645 27.3374 25.6311 27.3374H25.0123C24.46 27.3374 24.0123 27.7851 24.0123 28.3374V29.7071Z" fill="#16161a"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -20,6 +20,7 @@ import { securityRouter } from "./routers/security";
import { portRouter } from "./routers/port";
import { adminRouter } from "./routers/admin";
import { dockerRouter } from "./routers/docker";
import { composeRouter } from "./routers/compose";
import { registryRouter } from "./routers/registry";
import { clusterRouter } from "./routers/cluster";
/**
@@ -49,6 +50,7 @@ export const appRouter = createTRPCRouter({
security: securityRouter,
redirects: redirectsRouter,
port: portRouter,
compose: composeRouter,
registry: registryRouter,
cluster: clusterRouter,
});

View File

@@ -160,6 +160,7 @@ export const applicationRouter = createTRPCRouter({
applicationId: input.applicationId,
titleLog: "Rebuild deployment",
type: "redeploy",
applicationType: "application",
};
await myQueue.add(
"deployments",
@@ -291,6 +292,7 @@ export const applicationRouter = createTRPCRouter({
applicationId: input.applicationId,
titleLog: "Manual deployment",
type: "deploy",
applicationType: "application",
};
await myQueue.add(
"deployments",

View File

@@ -0,0 +1,278 @@
import {
apiCreateCompose,
apiCreateComposeByTemplate,
apiFindCompose,
apiRandomizeCompose,
apiUpdateCompose,
compose,
} from "@/server/db/schema";
import {
createCompose,
createComposeByTemplate,
findComposeById,
loadServices,
removeCompose,
stopCompose,
updateCompose,
} from "../services/compose";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { addNewService, checkServiceAccess } from "../services/user";
import {
cleanQueuesByCompose,
type DeploymentJob,
} from "@/server/queues/deployments-queue";
import { myQueue } from "@/server/queues/queueSetup";
import {
generateSSHKey,
readRSAFile,
removeRSAFiles,
} from "@/server/utils/filesystem/ssh";
import { eq } from "drizzle-orm";
import { db } from "@/server/db";
import { randomizeComposeFile } from "@/server/utils/docker/compose";
import { nanoid } from "nanoid";
import { removeDeploymentsByComposeId } from "../services/deployment";
import { removeComposeDirectory } from "@/server/utils/filesystem/directory";
import { createCommand } from "@/server/utils/builders/compose";
import { loadTemplateModule, readComposeFile } from "@/templates/utils";
import { findAdmin } from "../services/admin";
import { TRPCError } from "@trpc/server";
import { findProjectById, slugifyProjectName } from "../services/project";
import { createMount } from "../services/mount";
import type { TemplatesKeys } from "@/templates/types/templates-data.type";
import { templates } from "@/templates/templates";
export const composeRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateCompose)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.projectId, "create");
}
const newService = await createCompose(input);
if (ctx.user.rol === "user") {
await addNewService(ctx.user.authId, newService.composeId);
}
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the compose",
cause: error,
});
}
}),
one: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.composeId, "access");
}
return await findComposeById(input.composeId);
}),
update: protectedProcedure
.input(apiUpdateCompose)
.mutation(async ({ input }) => {
return updateCompose(input.composeId, input);
}),
delete: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.composeId, "delete");
}
const composeResult = await findComposeById(input.composeId);
const result = await db
.delete(compose)
.where(eq(compose.composeId, input.composeId))
.returning();
const cleanupOperations = [
async () => await removeCompose(composeResult),
async () => await removeDeploymentsByComposeId(composeResult),
async () => await removeComposeDirectory(composeResult.appName),
async () => await removeRSAFiles(composeResult.appName),
];
for (const operation of cleanupOperations) {
try {
await operation();
} catch (error) {}
}
return result[0];
}),
cleanQueues: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
await cleanQueuesByCompose(input.composeId);
}),
allServices: protectedProcedure
.input(apiFindCompose)
.query(async ({ input }) => {
return await loadServices(input.composeId);
}),
randomizeCompose: protectedProcedure
.input(apiRandomizeCompose)
.mutation(async ({ input }) => {
return await randomizeComposeFile(input.composeId, input.prefix);
}),
deploy: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: "Manual deployment",
type: "deploy",
applicationType: "compose",
};
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}),
redeploy: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: "Rebuild deployment",
type: "redeploy",
applicationType: "compose",
};
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}),
stop: protectedProcedure.input(apiFindCompose).mutation(async ({ input }) => {
await stopCompose(input.composeId);
return true;
}),
getDefaultCommand: protectedProcedure
.input(apiFindCompose)
.query(async ({ input }) => {
const compose = await findComposeById(input.composeId);
const command = createCommand(compose);
return `docker ${command}`;
}),
generateSSHKey: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const compose = await findComposeById(input.composeId);
try {
await generateSSHKey(compose.appName);
const file = await readRSAFile(compose.appName);
await updateCompose(input.composeId, {
customGitSSHKey: file,
});
} catch (error) {}
return true;
}),
refreshToken: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
await updateCompose(input.composeId, {
refreshToken: nanoid(),
});
return true;
}),
removeSSHKey: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const compose = await findComposeById(input.composeId);
await removeRSAFiles(compose.appName);
await updateCompose(input.composeId, {
customGitSSHKey: null,
});
return true;
}),
deployTemplate: protectedProcedure
.input(apiCreateComposeByTemplate)
.mutation(async ({ ctx, input }) => {
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.projectId, "create");
}
const composeFile = await readComposeFile(input.id);
const generate = await loadTemplateModule(input.id as TemplatesKeys);
const admin = await findAdmin();
if (!admin.serverIp) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"You need to have a server IP to deploy this template in order to generate domains",
});
}
const project = await findProjectById(input.projectId);
const projectName = slugifyProjectName(`${project.name}-${input.id}`);
const { envs, mounts } = generate({
serverIp: admin.serverIp,
projectName: projectName,
});
const compose = await createComposeByTemplate({
...input,
composeFile: composeFile,
env: envs.join("\n"),
name: input.id,
sourceType: "raw",
});
if (ctx.user.rol === "user") {
await addNewService(ctx.user.authId, compose.composeId);
}
if (mounts && mounts?.length > 0) {
for (const mount of mounts) {
await createMount({
mountPath: mount.mountPath,
content: mount.content,
serviceId: compose.composeId,
serviceType: "compose",
type: "file",
});
}
}
return null;
}),
templates: protectedProcedure.query(async () => {
const templatesData = templates.map((t) => ({
name: t.name,
description: t.description,
id: t.id,
links: t.links,
tags: t.tags,
logo: t.logo,
version: t.version,
}));
return templatesData;
}),
});

View File

@@ -1,11 +1,23 @@
import { apiFindAllByApplication } from "@/server/db/schema";
import { findAllDeploymentsByApplicationId } from "../services/deployment";
import {
apiFindAllByApplication,
apiFindAllByCompose,
} from "@/server/db/schema";
import {
findAllDeploymentsByApplicationId,
findAllDeploymentsByComposeId,
} from "../services/deployment";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const deploymentRouter = createTRPCRouter({
all: protectedProcedure
.input(apiFindAllByApplication)
.query(async ({ input }) => {
return await findAllDeploymentsByApplicationId(input.applicationId);
}),
all: protectedProcedure
.input(apiFindAllByApplication)
.query(async ({ input }) => {
return await findAllDeploymentsByApplicationId(input.applicationId);
}),
allByCompose: protectedProcedure
.input(apiFindAllByCompose)
.query(async ({ input }) => {
return await findAllDeploymentsByComposeId(input.composeId);
}),
});

View File

@@ -1,5 +1,15 @@
import { apiCreateMount, apiRemoveMount } from "@/server/db/schema";
import { createMount, deleteMount } from "../services/mount";
import {
apiCreateMount,
apiFindOneMount,
apiRemoveMount,
apiUpdateMount,
} from "@/server/db/schema";
import {
createMount,
deleteMount,
findMountById,
updateMount,
} from "../services/mount";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const mountRouter = createTRPCRouter({
@@ -14,4 +24,14 @@ export const mountRouter = createTRPCRouter({
.mutation(async ({ input }) => {
return await deleteMount(input.mountId);
}),
one: protectedProcedure.input(apiFindOneMount).query(async ({ input }) => {
return await findMountById(input.mountId);
}),
update: protectedProcedure
.input(apiUpdateMount)
.mutation(async ({ input }) => {
await updateMount(input.mountId, input);
return true;
}),
});

View File

@@ -22,6 +22,7 @@ import {
} from "../services/user";
import {
applications,
compose,
mariadb,
mongo,
mysql,
@@ -64,6 +65,9 @@ export const projectRouter = createTRPCRouter({
const service = await db.query.projects.findFirst({
where: eq(projects.projectId, input.projectId),
with: {
compose: {
where: buildServiceFilter(compose.composeId, accesedServices),
},
applications: {
where: buildServiceFilter(
applications.applicationId,
@@ -135,6 +139,9 @@ export const projectRouter = createTRPCRouter({
redis: {
where: buildServiceFilter(redis.redisId, accesedServices),
},
compose: {
where: buildServiceFilter(compose.composeId, accesedServices),
},
},
orderBy: desc(projects.createdAt),
});
@@ -149,6 +156,7 @@ export const projectRouter = createTRPCRouter({
mysql: true,
postgres: true,
redis: true,
compose: true,
},
orderBy: desc(projects.createdAt),
});

View File

@@ -1,4 +1,4 @@
import { docker, MAIN_TRAEFIK_PATH } from "@/server/constants";
import { docker, MAIN_TRAEFIK_PATH, MONITORING_PATH } from "@/server/constants";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
import {
cleanStoppedContainers,
@@ -40,6 +40,7 @@ import {
readDirectory,
} from "../services/settings";
import { canAccessToTraefikFiles } from "../services/user";
import { recreateDirectory } from "@/server/utils/filesystem/directory";
export const settingsRouter = createTRPCRouter({
reloadServer: adminProcedure.mutation(async () => {
@@ -85,6 +86,10 @@ export const settingsRouter = createTRPCRouter({
await cleanUpSystemPrune();
return true;
}),
cleanMonitoring: adminProcedure.mutation(async () => {
await recreateDirectory(MONITORING_PATH);
return true;
}),
saveSSHPrivateKey: adminProcedure
.input(apiSaveSSHKey)
.mutation(async ({ input, ctx }) => {

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