Merge pull request '15 docker client' (#2) from 15-docker-client into development
Jambotron Deploy / build-and-deploy (push) Waiting to run
Jambotron Deploy / build-and-deploy (push) Waiting to run
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
# This is an example configuration file
|
||||||
|
# To learn more, see the full config.yaml reference: https://docs.continue.dev/reference
|
||||||
|
|
||||||
|
name: Example Config
|
||||||
|
version: 1.0.0
|
||||||
|
schema: v1
|
||||||
|
|
||||||
|
# Define which models can be used
|
||||||
|
# https://docs.continue.dev/customization/models
|
||||||
|
models:
|
||||||
|
- name: my gpt-5
|
||||||
|
provider: openai
|
||||||
|
model: gpt-5
|
||||||
|
apiKey: YOUR_OPENAI_API_KEY_HERE
|
||||||
|
- uses: ollama/qwen2.5-coder-7b
|
||||||
|
- uses: anthropic/claude-4-sonnet
|
||||||
|
with:
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
|
||||||
|
# MCP Servers that Continue can access
|
||||||
|
# https://docs.continue.dev/customization/mcp-tools
|
||||||
|
mcpServers:
|
||||||
|
- uses: anthropic/memory-mcp
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
logs/
|
||||||
|
jambotron_data/
|
||||||
+30
-10
@@ -10,6 +10,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Использование контейнера для изоляции среды сборки
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
@@ -25,43 +26,62 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
# 1. СТРОГОЕ ОПРЕДЕЛЕНИЕ ПЕРЕМЕННЫХ ОКРУЖЕНИЯ
|
# 1. СТРОГОЕ ОПРЕДЕЛЕНИЕ ПЕРЕМЕННЫХ ОКРУЖЕНИЯ
|
||||||
if [[ "${{ github.ref }}" == *"production"* ]]; then
|
if [[ "${{ github.ref }}" == *"production"* ]]; then
|
||||||
D_NAME="jambotron-prod"; D_DB="jambotron_prod"; D_DOMAIN="jambotron.com"; D_TAG="prod-latest"
|
D_NAME="jambotron-prod"
|
||||||
|
D_DB="jambotron_prod"
|
||||||
|
D_DOMAIN="jambotron.com"
|
||||||
|
D_TAG="prod-latest"
|
||||||
|
CADDY_LABELS="$D_DOMAIN, *.$D_DOMAIN"
|
||||||
else
|
else
|
||||||
D_NAME="jambotron-staging"; D_DB="jambotron_stg"; D_DOMAIN="staging.jambotron.com"; D_TAG="stg-latest"
|
D_NAME="jambotron-staging"
|
||||||
|
D_DB="jambotron_stg"
|
||||||
|
D_DOMAIN="staging.jambotron.com"
|
||||||
|
D_TAG="stg-latest"
|
||||||
|
CADDY_LABELS="$D_DOMAIN"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting созидание for $D_NAME with tag $D_TAG"
|
echo "Starting созидание for $D_NAME with tag $D_TAG"
|
||||||
|
|
||||||
# 2. ПОДГОТОВКА ИНФРАСТРУКТУРЫ
|
# 2. ПОДГОТОВКА ИНФРАСТРУКТУРЫ
|
||||||
# Создание базы данных, если она отсутствует
|
# Гарантия существования сети для взаимодействия контейнеров
|
||||||
|
docker network inspect jambotron-net >/dev/null 2>&1 || docker network create jambotron-net
|
||||||
|
|
||||||
|
# Создание базы данных в существующем контейнере jambo-postgres
|
||||||
|
# Используются параметры из сохраненного стека проекта
|
||||||
docker exec jambo-postgres psql -U jambotron_admin -d postgres -tc "SELECT 1 FROM pg_database WHERE datname = '$D_DB'" | grep -q 1 || \
|
docker exec jambo-postgres psql -U jambotron_admin -d postgres -tc "SELECT 1 FROM pg_database WHERE datname = '$D_DB'" | grep -q 1 || \
|
||||||
docker exec jambo-postgres psql -U jambotron_admin -d postgres -c "CREATE DATABASE $D_DB"
|
docker exec jambo-postgres psql -U jambotron_admin -d postgres -c "CREATE DATABASE $D_DB"
|
||||||
|
|
||||||
# ГАРАНТИЯ СУЩЕСТВОВАНИЯ ТОМА
|
# ГАРАНТИЯ СУЩЕСТВОВАНИЯ ТОМА
|
||||||
# Создаем volume, если он еще не был создан вручную или системой
|
|
||||||
docker volume create jambotron_data || true
|
docker volume create jambotron_data || true
|
||||||
|
|
||||||
# 3. СБОРКА ОБРАЗА
|
# 3. СБОРКА ОБРАЗА
|
||||||
|
# Используем network=host для доступа к локальным ресурсам во время билда, если нужно
|
||||||
docker build --network=host -t jambotron-app:$D_TAG .
|
docker build --network=host -t jambotron-app:$D_TAG .
|
||||||
|
|
||||||
# 4. БЕЗОПАСНАЯ ОЧИСТКА
|
# 4. БЕЗОПАСНАЯ ОЧИСТКА ПРЕДЫДУЩЕГО СОСТОЯНИЯ
|
||||||
docker stop $D_NAME || true
|
docker stop $D_NAME || true
|
||||||
docker rm $D_NAME || true
|
docker rm $D_NAME || true
|
||||||
|
|
||||||
# 5. ЗАПУСК СИСТЕМЫ (ЛИНЕЙНАЯ КОМАНДА)
|
# 5. ЗАПУСК ОБНОВЛЕННОЙ СИСТЕМЫ
|
||||||
# В команду добавлен флаг -v для подключения тома jambotron_data к приложению.
|
# Проброс docker.sock необходим для работы оркестратора с Docker API
|
||||||
docker run -d --name $D_NAME --network jambotron-net --restart always \
|
# Переменная DOCKER_HOST указывает приложению путь к сокету внутри контейнера
|
||||||
|
|
||||||
|
docker run -d --name $D_NAME \
|
||||||
|
--network jambotron-net \
|
||||||
|
--restart always \
|
||||||
|
--user root \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-v jambotron_data:/jambotron_data \
|
-v jambotron_data:/jambotron_data \
|
||||||
-e SPRING_PROFILES_ACTIVE=prod \
|
-e SPRING_PROFILES_ACTIVE=prod \
|
||||||
|
-e DOCKER_HOST=unix:///var/run/docker.sock \
|
||||||
-e SPRING_DATASOURCE_URL=jdbc:postgresql://jambo-postgres:5432/$D_DB \
|
-e SPRING_DATASOURCE_URL=jdbc:postgresql://jambo-postgres:5432/$D_DB \
|
||||||
-e SPRING_FLYWAY_URL=jdbc:postgresql://jambo-postgres:5432/$D_DB \
|
-e SPRING_FLYWAY_URL=jdbc:postgresql://jambo-postgres:5432/$D_DB \
|
||||||
-e SPRING_DATASOURCE_USERNAME=jambotron_admin \
|
-e SPRING_DATASOURCE_USERNAME=jambotron_admin \
|
||||||
-e SPRING_DATASOURCE_PASSWORD=jambo_secret_pass \
|
-e SPRING_DATASOURCE_PASSWORD=jambo_secret_pass \
|
||||||
-e SPRING_FLYWAY_USER=jambotron_admin \
|
-e SPRING_FLYWAY_USER=jambotron_admin \
|
||||||
-e SPRING_FLYWAY_PASSWORD=jambo_secret_pass \
|
-e SPRING_FLYWAY_PASSWORD=jambo_secret_pass \
|
||||||
-l "caddy=$D_DOMAIN, *.$D_DOMAIN, *.*.$D_DOMAIN" \
|
-e SPRING_DATA_MONGODB_URI=mongodb://jambotron_admin:jambo_mongo_secret@jambo-mongo:27017/jambotron?authSource=admin \
|
||||||
|
-l "caddy=$CADDY_LABELS" \
|
||||||
-l "caddy.reverse_proxy={{upstreams 8081}}" \
|
-l "caddy.reverse_proxy={{upstreams 8081}}" \
|
||||||
jambotron-app:$D_TAG
|
jambotron-app:$D_TAG
|
||||||
|
|
||||||
echo "Созидание $D_NAME успешно завершено. Система активна и доступна по сети."
|
echo "Созидание $D_NAME успешно завершено. Система активна и доступна по адресу $D_DOMAIN."
|
||||||
Generated
+11
-1
@@ -1,7 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="CompilerConfiguration">
|
<component name="CompilerConfiguration">
|
||||||
<bytecodeTargetLevel target="17" />
|
<annotationProcessing>
|
||||||
|
<profile name="Gradle Imported" enabled="true">
|
||||||
|
<outputRelativeToContentRoot value="true" />
|
||||||
|
<processorPath useClasspath="false">
|
||||||
|
<entry name="$USER_HOME$/.gradle/caches/modules-2/files-2.1/org.projectlombok/lombok/1.18.34/ec547ef414ab1d2c040118fb9c1c265ada63af14/lombok-1.18.34.jar" />
|
||||||
|
</processorPath>
|
||||||
|
<module name="jambotron.main" />
|
||||||
|
<module name="jambotron.test" />
|
||||||
|
</profile>
|
||||||
|
</annotationProcessing>
|
||||||
|
<bytecodeTargetLevel target="21" />
|
||||||
</component>
|
</component>
|
||||||
<component name="JavacSettings">
|
<component name="JavacSettings">
|
||||||
<option name="ADDITIONAL_OPTIONS_STRING" value="-parameters" />
|
<option name="ADDITIONAL_OPTIONS_STRING" value="-parameters" />
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="18" project-jdk-type="JavaSDK" />
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="18" project-jdk-type="JavaSDK" />
|
||||||
</project>
|
</project>
|
||||||
+12
-2
@@ -1,5 +1,7 @@
|
|||||||
# 1. Этап сборки (Build stage)
|
# 1. Этап сборки (Build stage)
|
||||||
FROM eclipse-temurin:17-jdk-jammy AS build
|
# ОБНОВЛЕНО: Переходим на JDK 21
|
||||||
|
FROM eclipse-temurin:21-jdk-jammy AS build
|
||||||
|
|
||||||
|
|
||||||
# Устанавливаем Node.js v20 (требование Angular CLI)
|
# Устанавливаем Node.js v20 (требование Angular CLI)
|
||||||
RUN apt-get update && apt-get install -y curl \
|
RUN apt-get update && apt-get install -y curl \
|
||||||
@@ -7,6 +9,9 @@ RUN apt-get update && apt-get install -y curl \
|
|||||||
&& apt-get install -y nodejs \
|
&& apt-get install -y nodejs \
|
||||||
&& apt-get clean
|
&& apt-get clean
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y docker.io
|
||||||
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Копируем всё содержимое проекта
|
# Копируем всё содержимое проекта
|
||||||
@@ -24,7 +29,12 @@ RUN ./gradlew copyAngularBuild_prod --no-daemon
|
|||||||
RUN ./gradlew bootJar -x test --no-daemon
|
RUN ./gradlew bootJar -x test --no-daemon
|
||||||
|
|
||||||
# 2. Финальный образ (Runtime stage)
|
# 2. Финальный образ (Runtime stage)
|
||||||
FROM eclipse-temurin:17-jre-jammy
|
FROM eclipse-temurin:21-jdk-jammy
|
||||||
|
# Устанавливаем и докер, и плагин компоуза
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y docker.io docker-compose-v2 && \
|
||||||
|
apt-get clean \
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/build/libs/*.jar app.jar
|
COPY --from=build /app/build/libs/*.jar app.jar
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Используем builder для сборки кастомного бинарника
|
||||||
|
FROM caddy:2.7-builder AS builder
|
||||||
|
|
||||||
|
RUN xcaddy build \
|
||||||
|
--with github.com/caddy-dns/spaceship
|
||||||
|
|
||||||
|
# Финальный легковесный образ
|
||||||
|
FROM caddy:2.7-alpine
|
||||||
|
|
||||||
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
Для того чтобы найти и завершить процесс, занимающий определенный порт (в данном случае 8082), в большинстве операционных систем используются стандартные терминальные команды.
|
||||||
|
|
||||||
|
Вот пошаговая инструкция для Linux/macOS и Windows.
|
||||||
|
|
||||||
|
🐧 Linux и macOS
|
||||||
|
Самый быстрый способ — использовать утилиту lsof или fuser.
|
||||||
|
|
||||||
|
1. Поиск процесса
|
||||||
|
Выполните команду в терминале, чтобы узнать PID (идентификатор процесса):
|
||||||
|
|
||||||
|
Bash
|
||||||
|
sudo lsof -i :8082
|
||||||
|
PID — это число в соответствующей колонке, которое нам понадобится.
|
||||||
|
|
||||||
|
Или с помощью fuser:
|
||||||
|
|
||||||
|
Bash
|
||||||
|
sudo fuser 8082/tcp
|
||||||
|
2. Завершение процесса
|
||||||
|
Используйте команду kill, подставив найденный PID:
|
||||||
|
|
||||||
|
Bash
|
||||||
|
kill -9 PID
|
||||||
|
Примечание: Флаг -9 (SIGKILL) принудительно завершает процесс. Если вы хотите закрыть его «мягко», используйте kill PID без флага.
|
||||||
|
|
||||||
|
Лайфхак (одной командой):
|
||||||
|
Если нужно найти и сразу убить:
|
||||||
|
|
||||||
|
Bash
|
||||||
|
sudo kill -9 $(sudo lsof -t -i:8082)
|
||||||
|
🪟 Windows
|
||||||
|
В Windows это делается через командную строку (CMD) или PowerShell.
|
||||||
|
|
||||||
|
1. Поиск процесса
|
||||||
|
Откройте терминал и введите:
|
||||||
|
|
||||||
|
DOS
|
||||||
|
```bash
|
||||||
|
netstat -ano | findstr :8082
|
||||||
|
```
|
||||||
|
В последней колонке вы увидите PID. Например: 1234.
|
||||||
|
|
||||||
|
2. Завершение процесса
|
||||||
|
Используйте команду taskkill:
|
||||||
|
|
||||||
|
DOS
|
||||||
|
```bash
|
||||||
|
taskkill /PID 1234 /F
|
||||||
|
```
|
||||||
|
/F — принудительное завершение.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 🛠 Reset and Cleanup Infrastructure of Jambotron
|
||||||
|
|
||||||
|
Этот протокол описывает процесс экстренного восстановления системы **Jambotron** при критическом заполнении диска и аномальной нагрузке на CPU, вызванной штормом контейнеров.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Фаза Остановки (Purge)
|
||||||
|
|
||||||
|
Когда система «захлебывается», необходимо прервать циклы бесконечных перезагрузок, которые блокируют ресурсы процессора.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Остановить процессы, пожирающие ресурсы
|
||||||
|
docker stop jambotron-staging jambotron-app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Фаза Глубокой Очистки (Space Recovery)
|
||||||
|
|
||||||
|
Освобождение диска от «темной материи» — кэша сборок и неиспользуемых слоев.
|
||||||
|
|
||||||
|
| Тип данных | Команда очистки | Эффект |
|
||||||
|
|--------------|------------------------------------|----------------------------------------------------|
|
||||||
|
| Build Cache | `docker builder prune -a -f` | Удаление промежуточных слоев (~44 ГБ) |
|
||||||
|
| Images | `docker image prune -a -f` | Удаление неиспользуемых и старых образов |
|
||||||
|
| Containers | `docker rm jambotron-staging test-app1 whoami` | Удаление лишних или битых контейнеров |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Фаза Оживления (Resurrection)
|
||||||
|
|
||||||
|
Последовательный запуск компонентов системы. Начинаем с фундамента (баз данных).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Шаг 1: Запуск баз данных и хранилища кода
|
||||||
|
docker start jambo-postgres jambo-mongo gitea-db-1 gitea
|
||||||
|
|
||||||
|
# Шаг 2: Запуск сетевого шлюза и мониторинга
|
||||||
|
docker start caddy jambo-loki prometheus grafana portainer
|
||||||
|
|
||||||
|
# Шаг 3: Запуск основного приложения (Production)
|
||||||
|
docker start jambotron-app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Контроль Инженерной Системы
|
||||||
|
|
||||||
|
После ресета необходимо убедиться, что параметры системы вернулись в норму:
|
||||||
|
|
||||||
|
- `df -h` — Проверка свободного места на диске
|
||||||
|
- `docker system df` — Проверка очистки Build Cache
|
||||||
|
- `docker ps` — Проверка, что все критические контейнеры в статусе **Up**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Предотвращение Деградации
|
||||||
|
|
||||||
|
Чтобы система оставалась стабильной, придерживайся правил в CI/CD:
|
||||||
|
|
||||||
|
- **Network Logic:** всегда используй имя контейнера базы (`jambo-mongo`), а не `localhost`
|
||||||
|
- **Clean Build:** периодически очищай образы, чтобы старые версии не накапливались
|
||||||
|
- **Мониторинг:** следи за ресурсами и состоянием контейнеров
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
- **Статус системы:** Очищена
|
||||||
|
- **Направление:** Задано
|
||||||
|
- **Цель:** Созидание
|
||||||
+13
-2
@@ -26,13 +26,21 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
|
implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||||
// --- НОВОЕ: Реактивная MongoDB 6.0 для метаданных PaaS ---
|
// --- НОВОЕ: Реактивная MongoDB 6.0 для метаданных PaaS ---
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
|
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
|
||||||
|
|
||||||
// --- НОВОЕ: Docker API для управления контейнерами (Созидание) ---
|
// --- НОВОЕ: Docker API для управления контейнерами (Созидание) ---
|
||||||
|
// Ядро и API
|
||||||
|
implementation 'com.github.docker-java:docker-java-api:3.3.6'
|
||||||
implementation 'com.github.docker-java:docker-java-core:3.3.6'
|
implementation 'com.github.docker-java:docker-java-core:3.3.6'
|
||||||
implementation 'com.github.docker-java:docker-java-transport-httpclient5:3.3.6'
|
|
||||||
|
// Тот самый транспорт, который решит проблему с unix-сокетом
|
||||||
|
implementation 'com.github.docker-java:docker-java-transport-zerodep:3.4.0'
|
||||||
|
|
||||||
|
// Spring Boot Starter для STOMP WebSockets
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||||
|
|
||||||
|
|
||||||
// Работа с SQL и PostgreSQL[cite: 3]
|
// Работа с SQL и PostgreSQL[cite: 3]
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
@@ -41,6 +49,9 @@ dependencies {
|
|||||||
implementation "org.flywaydb:flyway-database-postgresql:10.4.1"
|
implementation "org.flywaydb:flyway-database-postgresql:10.4.1"
|
||||||
implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0'
|
implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0'
|
||||||
|
|
||||||
|
// MongoDB
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
|
||||||
|
|
||||||
// Безопасность и JWT
|
// Безопасность и JWT
|
||||||
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
|
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
|
||||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
|
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ services:
|
|||||||
jambotron-mongo:
|
jambotron-mongo:
|
||||||
image: mongo:6.0
|
image: mongo:6.0
|
||||||
container_name: jambotron-mongo
|
container_name: jambotron-mongo
|
||||||
|
environment:
|
||||||
|
- MONGO_INITDB_ROOT_USERNAME=root
|
||||||
|
- MONGO_INITDB_ROOT_PASSWORD=password
|
||||||
ports:
|
ports:
|
||||||
- "27017:27017"
|
- "27017:27017"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -27,6 +30,18 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- jambotron-net
|
- jambotron-net
|
||||||
|
|
||||||
|
mongo-express:
|
||||||
|
image: mongo-express
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8083:8081"
|
||||||
|
environment:
|
||||||
|
ME_CONFIG_MONGODB_URL: mongodb://root:password@mongodb:27017/
|
||||||
|
ME_CONFIG_BASICAUTH_USERNAME: admin
|
||||||
|
ME_CONFIG_BASICAUTH_PASSWORD: pass
|
||||||
|
networks:
|
||||||
|
- jambotron-net
|
||||||
|
|
||||||
# UI для управления БД (Postgres)[cite: 3]
|
# UI для управления БД (Postgres)[cite: 3]
|
||||||
jambo-pgadmin:
|
jambo-pgadmin:
|
||||||
image: dpage/pgadmin4
|
image: dpage/pgadmin4
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- jambotron-staging-net
|
- jambotron-staging-net
|
||||||
- jambotron-net # Для связи с Caddy
|
- jambotron-net # Для связи с Caddy
|
||||||
|
volumes:
|
||||||
|
- jambotron_data:/jambotron_data
|
||||||
environment:
|
environment:
|
||||||
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE}
|
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE}
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://jambo-db-staging:5432/${STAGING_DB_NAME}
|
SPRING_DATASOURCE_URL: jdbc:postgresql://jambo-db-staging:5432/${STAGING_DB_NAME}
|
||||||
@@ -43,3 +45,5 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
jambo_db_staging_data:
|
jambo_db_staging_data:
|
||||||
|
jambotron_data:
|
||||||
|
name: jambotron_data
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Используем специальный образ для сборки
|
||||||
|
FROM lucaslorentz/caddy-docker-proxy:2.8-builder AS builder
|
||||||
|
|
||||||
|
RUN xcaddy build \
|
||||||
|
--with github.com/caddy-dns/spaceship
|
||||||
|
|
||||||
|
# Финальный образ
|
||||||
|
FROM lucaslorentz/caddy-docker-proxy:2.8-alpine
|
||||||
|
|
||||||
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
services:
|
||||||
|
# --- ЯДРО: Caddy с авто-лейблами ---
|
||||||
|
caddy:
|
||||||
|
image: lucaslorentz/caddy-docker-proxy:latest
|
||||||
|
container_name: caddy
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
|
- 443:443
|
||||||
|
environment:
|
||||||
|
- CADDY_INGRESS_NETWORKS=jambotron-net
|
||||||
|
networks:
|
||||||
|
- jambotron-net
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
# --- МОНИТОРИНГ: Prometheus ---
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
container_name: prometheus
|
||||||
|
volumes:
|
||||||
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
networks:
|
||||||
|
- jambotron-net
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
# --- МОНИТОРИНГ: Grafana ---
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
container_name: grafana
|
||||||
|
networks:
|
||||||
|
- jambotron-net
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=monitorPWD
|
||||||
|
- GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s:%(http_port)s/monitor/
|
||||||
|
- GF_SERVER_SERVE_FROM_SUB_PATH=true
|
||||||
|
labels:
|
||||||
|
caddy: jambotron.com
|
||||||
|
caddy.handle: /monitor/*
|
||||||
|
caddy.handle.reverse_proxy: "{{upstreams 3000}}"
|
||||||
|
volumes:
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
# --- ДАТЧИКИ ---
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:latest
|
||||||
|
container_name: node-exporter
|
||||||
|
volumes:
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /:/rootfs:ro
|
||||||
|
command:
|
||||||
|
- '--path.procfs=/host/proc'
|
||||||
|
- '--path.sysfs=/host/sys'
|
||||||
|
networks:
|
||||||
|
- jambotron-net
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
cadvisor:
|
||||||
|
image: gcr.io/cadvisor/cadvisor:latest
|
||||||
|
container_name: cadvisor
|
||||||
|
volumes:
|
||||||
|
- /:/rootfs:ro
|
||||||
|
- /var/run:/var/run:ro
|
||||||
|
- /sys:/sys:ro
|
||||||
|
- /var/lib/docker/:/var/lib/docker:ro
|
||||||
|
- /dev/disk/:/dev/disk:ro
|
||||||
|
networks:
|
||||||
|
- jambotron-net
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
# --- ТЕСТОВЫЙ ПОДДОМЕН (Wildcard DNS должен быть включен) ---
|
||||||
|
test-app1:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: test-app1
|
||||||
|
networks:
|
||||||
|
- jambotron-net
|
||||||
|
labels:
|
||||||
|
caddy: test-app1.jambotron.com
|
||||||
|
caddy.reverse_proxy: "{{upstreams 80}}"
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
networks:
|
||||||
|
jambotron-net:
|
||||||
|
name: jambotron-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
prometheus_data:
|
||||||
|
grafana_data:
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'node'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['node-exporter:9100']
|
||||||
|
|
||||||
|
- job_name: 'cadvisor'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['cadvisor:8080']
|
||||||
|
|
||||||
|
- job_name: 'mongodb'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['mongodb-exporter:9216']
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# База данных для стейджинга
|
||||||
|
jambo-db-staging:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: jambo-db-staging
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- jambotron-staging-net
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${STAGING_DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${STAGING_DB_PASSWORD}
|
||||||
|
POSTGRES_DB: ${STAGING_DB_NAME}
|
||||||
|
volumes:
|
||||||
|
- jambo_db_staging_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
# Backend-приложение (Staging версия)
|
||||||
|
jambotron-app-stg:
|
||||||
|
image: jambotron-app:stg-latest
|
||||||
|
container_name: jambotron-app-stg
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- jambo-db-staging
|
||||||
|
volumes:
|
||||||
|
- jambotron_data:/jambotron_data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
networks:
|
||||||
|
- jambotron-staging-net
|
||||||
|
- jambotron-net # Для связи с Caddy
|
||||||
|
environment:
|
||||||
|
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE}
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://jambo-db-staging:5432/${STAGING_DB_NAME}
|
||||||
|
SPRING_DATASOURCE_USERNAME: ${STAGING_DB_USER}
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${STAGING_DB_PASSWORD}
|
||||||
|
labels:
|
||||||
|
# Настройки для caddy-docker-proxy
|
||||||
|
caddy: ${STAGING_DOMAIN}
|
||||||
|
caddy.reverse_proxy: "{{upstreams 8080}}"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
jambotron-staging-net:
|
||||||
|
driver: bridge
|
||||||
|
jambotron-net:
|
||||||
|
external: true # Используем существующую сеть
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jambo_db_staging_data:
|
||||||
|
jambotron_data:
|
||||||
|
name: jambotron_data
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# База данных Staging
|
||||||
|
STAGING_DB_USER=jambotron_stg_admin
|
||||||
|
STAGING_DB_PASSWORD=your_secure_stg_pass
|
||||||
|
STAGING_DB_NAME=jambotron_staging
|
||||||
|
|
||||||
|
# Настройки приложения
|
||||||
|
SPRING_PROFILES_ACTIVE=staging
|
||||||
|
STAGING_DOMAIN=staging.jambotron.com
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# База данных для стейджинга
|
||||||
|
jambo-db-staging:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: jambo-db-staging
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- jambotron-staging-net
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${STAGING_DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${STAGING_DB_PASSWORD}
|
||||||
|
POSTGRES_DB: ${STAGING_DB_NAME}
|
||||||
|
volumes:
|
||||||
|
- jambo_db_staging_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
# Backend-приложение (Staging версия)
|
||||||
|
jambotron-app-stg:
|
||||||
|
image: jambotron-app:stg-latest
|
||||||
|
container_name: jambotron-app-stg
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- jambo-db-staging
|
||||||
|
volumes:
|
||||||
|
- jambotron_staging_data:/jambotron_data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
networks:
|
||||||
|
- jambotron-staging-net
|
||||||
|
- jambotron-net # Для связи с Caddy
|
||||||
|
environment:
|
||||||
|
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE}
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://jambo-db-staging:5432/${STAGING_DB_NAME}
|
||||||
|
SPRING_DATASOURCE_USERNAME: ${STAGING_DB_USER}
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${STAGING_DB_PASSWORD}
|
||||||
|
labels:
|
||||||
|
# Настройки для caddy-docker-proxy
|
||||||
|
caddy: ${STAGING_DOMAIN}
|
||||||
|
caddy.reverse_proxy: "{{upstreams 8080}}"
|
||||||
|
# Добавь это для стабильности WS:
|
||||||
|
caddy.header_up_1: "Upgrade {>Upgrade}"
|
||||||
|
caddy.header_up_2: "Connection {>Connection}"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
jambotron-staging-net:
|
||||||
|
driver: bridge
|
||||||
|
jambotron-net:
|
||||||
|
external: true # Используем существующую сеть
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jambo_db_staging_data:
|
||||||
|
jambotron_staging_data:
|
||||||
|
name: jambotron_staging_data
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Сервер базы данных
|
||||||
|
jambotron-db-server:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: jambo-postgres
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=jambotron_prod
|
||||||
|
- POSTGRES_USER=jambotron_admin
|
||||||
|
- POSTGRES_PASSWORD=jambo_secret_pass
|
||||||
|
volumes:
|
||||||
|
- jambo_db_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- jambotron-net
|
||||||
|
|
||||||
|
# Панель управления базой
|
||||||
|
jambo-pgadmin:
|
||||||
|
image: dpage/pgadmin4
|
||||||
|
container_name: jambo-pgadmin
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- PGADMIN_DEFAULT_EMAIL=admin@jambotron.com
|
||||||
|
- PGADMIN_DEFAULT_PASSWORD=your_secure_password
|
||||||
|
networks:
|
||||||
|
- jambotron-net
|
||||||
|
labels:
|
||||||
|
- "caddy=pgadmin.jambotron.com"
|
||||||
|
- "caddy.reverse_proxy={{upstreams 80}}"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
jambotron-net:
|
||||||
|
external: true # Указываем, что сеть уже создана (в ней же живет Caddy и Gitea)
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jambo_db_data:
|
||||||
Generated
+759
-969
File diff suppressed because it is too large
Load Diff
@@ -28,14 +28,17 @@
|
|||||||
"@material/material-color-utilities": "^0.3.0",
|
"@material/material-color-utilities": "^0.3.0",
|
||||||
"@ng-bootstrap/ng-bootstrap": "19.0.0",
|
"@ng-bootstrap/ng-bootstrap": "19.0.0",
|
||||||
"@popperjs/core": "2.11.8",
|
"@popperjs/core": "2.11.8",
|
||||||
|
"@stomp/stompjs": "^7.3.0",
|
||||||
"angular-markdown-editor": "^3.1.1",
|
"angular-markdown-editor": "^3.1.1",
|
||||||
"bootstrap": "5.3.6",
|
"bootstrap": "5.3.6",
|
||||||
|
"marked": "^18.0.3",
|
||||||
"ngx-color-picker": "^20.1.0",
|
"ngx-color-picker": "^20.1.0",
|
||||||
"ngx-colors": "^3.6.0",
|
"ngx-colors": "^3.6.0",
|
||||||
"ngx-image-cropper": "^9.1.5",
|
"ngx-image-cropper": "^9.1.5",
|
||||||
"ngx-markdown": "^20.0.0",
|
"ngx-markdown": "^20.0.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
|
"sockjs-client": "^1.6.1",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"viewerjs": "^1.11.7",
|
"viewerjs": "^1.11.7",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
@@ -47,7 +50,9 @@
|
|||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"@types/marked": "^5.0.2",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
|
"@types/sockjs-client": "^1.5.4",
|
||||||
"jasmine-core": "~5.7.0",
|
"jasmine-core": "~5.7.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export class GlobalConstants {
|
|||||||
return environment.default_page;
|
return environment.default_page;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
public static readonly HOST_NAME = (() => {
|
||||||
|
return environment.host_name;
|
||||||
|
})();
|
||||||
|
|
||||||
public static readonly API_URL = (() => {
|
public static readonly API_URL = (() => {
|
||||||
// ... calculate the value and return it
|
// ... calculate the value and return it
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,9 @@ export class CustomHttpInterceptor implements HttpInterceptor {
|
|||||||
return next.handle(req)
|
return next.handle(req)
|
||||||
.pipe(tap((event: HttpEvent<any>) => {
|
.pipe(tap((event: HttpEvent<any>) => {
|
||||||
if (event instanceof HttpResponse) {
|
if (event instanceof HttpResponse) {
|
||||||
|
console.log('Interceptor увидел ответ:', event.url, 'status:', event.status, 'ok:', event.ok);
|
||||||
this.spinnerService.hide();
|
this.spinnerService.hide();
|
||||||
}
|
}
|
||||||
}, (error) => {
|
|
||||||
this.spinnerService.hide();
|
|
||||||
}))
|
}))
|
||||||
;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "@angular/forms";
|
} from "@angular/forms";
|
||||||
import {MarkdownComponent} from "ngx-markdown";
|
import {MarkdownComponent} from "ngx-markdown";
|
||||||
import {MatButton} from "@angular/material/button";
|
import {MatButton} from "@angular/material/button";
|
||||||
import {MatCard, MatCardActions, MatCardContent, MatCardHeader} from "@angular/material/card";
|
import {MatCard, MatCardActions, MatCardContent} from "@angular/material/card";
|
||||||
import {MatError, MatFormField, MatInput, MatLabel} from "@angular/material/input";
|
import {MatError, MatFormField, MatInput, MatLabel} from "@angular/material/input";
|
||||||
import {MatTab, MatTabGroup} from "@angular/material/tabs";
|
import {MatTab, MatTabGroup} from "@angular/material/tabs";
|
||||||
import {ActivatedRoute, RouterLink} from "@angular/router";
|
import {ActivatedRoute, RouterLink} from "@angular/router";
|
||||||
@@ -47,7 +47,6 @@ type TutorialFormValue = {
|
|||||||
MatCard,
|
MatCard,
|
||||||
MatCardActions,
|
MatCardActions,
|
||||||
MatCardContent,
|
MatCardContent,
|
||||||
MatCardHeader,
|
|
||||||
MatError,
|
MatError,
|
||||||
MatFormField,
|
MatFormField,
|
||||||
MatInput,
|
MatInput,
|
||||||
|
|||||||
-2
@@ -2,7 +2,6 @@ import { Component } from '@angular/core';
|
|||||||
import {Tutorial} from '../../../models/tutorial.model';
|
import {Tutorial} from '../../../models/tutorial.model';
|
||||||
import {MatCard, MatCardActions, MatCardContent, MatCardImage} from '@angular/material/card';
|
import {MatCard, MatCardActions, MatCardContent, MatCardImage} from '@angular/material/card';
|
||||||
import {RouterLink} from '@angular/router';
|
import {RouterLink} from '@angular/router';
|
||||||
import {MatButton} from '@angular/material/button';
|
|
||||||
import {TutorialsApiService} from '../tutorials-api.service';
|
import {TutorialsApiService} from '../tutorials-api.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -11,7 +10,6 @@ import {TutorialsApiService} from '../tutorials-api.service';
|
|||||||
MatCard,
|
MatCard,
|
||||||
MatCardActions,
|
MatCardActions,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
MatButton,
|
|
||||||
MatCardImage,
|
MatCardImage,
|
||||||
MatCardContent
|
MatCardContent
|
||||||
],
|
],
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
<div class="add-project-container p-4">
|
||||||
|
<mat-card>
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Create New SaaS Project</mat-card-title>
|
||||||
|
<mat-card-subtitle>Define your container infrastructure</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content class="mt-4">
|
||||||
|
<form [formGroup]="projectForm" (ngSubmit)="onSubmit()">
|
||||||
|
<!-- Имя проекта -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Project Name</mat-label>
|
||||||
|
<input matInput formControlName="name" placeholder="e.g. My-Awesome-App">
|
||||||
|
<mat-icon matSuffix>label</mat-icon>
|
||||||
|
<mat-error *ngIf="projectForm.get('name')?.hasError('required')">Name is required</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Конфигурация Docker Compose -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width mt-2">
|
||||||
|
<mat-label>Docker Compose YAML</mat-label>
|
||||||
|
<textarea matInput formControlName="rawComposeContent"
|
||||||
|
rows="15"
|
||||||
|
placeholder="version: '3.8'..."
|
||||||
|
class="yaml-editor"></textarea>
|
||||||
|
<mat-hint>Paste your docker-compose.yml content here</mat-hint>
|
||||||
|
<mat-error *ngIf="projectForm.get('rawComposeContent')?.hasError('required')">
|
||||||
|
Configuration content is mandatory
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="actions mt-4 d-flex justify-content-end gap-2">
|
||||||
|
<button mat-button type="button" routerLink="../">Cancel</button>
|
||||||
|
<button mat-raised-button color="primary" type="submit" [disabled]="projectForm.invalid || isSubmitting">
|
||||||
|
<mat-icon *ngIf="!isSubmitting">save</mat-icon>
|
||||||
|
<mat-spinner diameter="20" *ngIf="isSubmitting"></mat-spinner>
|
||||||
|
Create & Initialize
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaml-editor {
|
||||||
|
font-family: 'Fira Code', 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
color: #2c3e50;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-card {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ProjectAddComponent } from './project-add.component';
|
||||||
|
|
||||||
|
describe('ProjectAddComponent', () => {
|
||||||
|
let component: ProjectAddComponent;
|
||||||
|
let fixture: ComponentFixture<ProjectAddComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ProjectAddComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ProjectAddComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import {Component, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
|
||||||
|
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||||
|
import {ProjectService} from '../../../services/project.service';
|
||||||
|
import {Router, RouterLink} from '@angular/router';
|
||||||
|
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardSubtitle, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {MatError, MatFormField, MatHint, MatInputModule, MatLabel} from '@angular/material/input';
|
||||||
|
import {MatIcon} from '@angular/material/icon';
|
||||||
|
import {NgIf} from '@angular/common';
|
||||||
|
import {MatProgressSpinner} from '@angular/material/progress-spinner';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-project-add.component',
|
||||||
|
imports: [
|
||||||
|
MatInputModule,
|
||||||
|
MatCard,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
MatCardSubtitle,
|
||||||
|
MatCardContent,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatFormField,
|
||||||
|
MatLabel,
|
||||||
|
MatIcon,
|
||||||
|
MatError,
|
||||||
|
MatHint,
|
||||||
|
NgIf,
|
||||||
|
MatProgressSpinner,
|
||||||
|
MatButton,
|
||||||
|
RouterLink
|
||||||
|
],
|
||||||
|
schemas:[CUSTOM_ELEMENTS_SCHEMA],
|
||||||
|
templateUrl: './project-add.component.html',
|
||||||
|
styleUrl: './project-add.component.scss',
|
||||||
|
})
|
||||||
|
export class ProjectAddComponent {
|
||||||
|
projectForm: FormGroup;
|
||||||
|
isSubmitting = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private projectService: ProjectService,
|
||||||
|
private router: Router,
|
||||||
|
private snackBar: MatSnackBar
|
||||||
|
) {
|
||||||
|
this.projectForm = this.fb.group({
|
||||||
|
name: ['', [Validators.required, Validators.minLength(3)]],
|
||||||
|
rawComposeContent: ['', Validators.required]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.projectForm.valid) {
|
||||||
|
this.isSubmitting = true;
|
||||||
|
// Отправляем запрос согласно ProjectCreateRequest в Java[cite: 1, 4]
|
||||||
|
this.projectService.createProject(this.projectForm.value).subscribe({
|
||||||
|
next: (project) => {
|
||||||
|
this.snackBar.open(`Project ${project.name} created!`, 'OK', { duration: 3000 });
|
||||||
|
this.router.navigate(['/main/user/projects']).then(r => console.log('Navigation result:', r)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
console.error('Creation error:', err);
|
||||||
|
this.snackBar.open('Failed to create project. Check YAML syntax.', 'Close');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
<div class="project-details-wrapper">
|
||||||
|
<div class="main-header">
|
||||||
|
<h1>{{ project?.name || 'nt6' }}</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="deploy()">▶ DEPLOY</button>
|
||||||
|
<button mat-flat-button (click)="stop()">■ STOP</button>
|
||||||
|
<button mat-flat-button color="warn" (click)="confirmDelete()">🗑 DELETE PROJECT</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-divider">
|
||||||
|
<mat-icon>layers</mat-icon>
|
||||||
|
<h2>CONTAINERS</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="containers-grid">
|
||||||
|
@for (container of containers; track container.name) {
|
||||||
|
<mat-card class="container-card">
|
||||||
|
<mat-card-content class="flex flex-col p-4">
|
||||||
|
|
||||||
|
<div class="card-layout flex w-full justify-between items-start gap-4">
|
||||||
|
|
||||||
|
<div class="card-left flex-1 min-w-0">
|
||||||
|
<span class="c-name block text-base font-bold text-gray-150 truncate" [title]="container.name">
|
||||||
|
{{ container.name }}
|
||||||
|
</span>
|
||||||
|
<span class="c-image block text-xs text-gray-500 font-mono mt-0.5 truncate" [title]="container.image">
|
||||||
|
{{ container.image }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (containerStats.get(container.id); as stats) {
|
||||||
|
<div class="stats-row" style="font-size: 11px; color: #888; margin-top: 6px;">
|
||||||
|
CPU: {{ stats.cpuUsage | number:'1.1-1' }}% | RAM: {{ stats.memoryPercent | number:'1.0-0' }}%
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-right flex flex-col align-items-end justify-between items-end gap-2 shrink-0">
|
||||||
|
<div class="actions-row flex gap-1">
|
||||||
|
<button mat-icon-button (click)="toggleContainer(container)"
|
||||||
|
[color]="container.state === 'running' ? 'warn' : 'primary'" class="!w-8 !h-8 !line-height-[32px]">
|
||||||
|
<mat-icon class="!text-lg">{{ container.state === 'running' ? 'stop' : 'play_arrow' }}</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button (click)="restartContainer(container.id)" style="color: #ffbd2e;" class="!w-8 !h-8 !line-height-[32px]">
|
||||||
|
<mat-icon class="!text-lg">refresh</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<div class="state-dot" [class.running]="container.state === 'running'"></div>
|
||||||
|
<span class="uptime text-xs text-gray-400 font-mono">{{ container.status }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full mt-4 block clear-both">
|
||||||
|
<div class="p-3 bg-gray-900/30 rounded-lg border border-gray-800 flex flex-col justify-center min-h-[64px]">
|
||||||
|
|
||||||
|
<div *ngIf="!editingDomains[container.id]" class="flex items-center justify-between gap-4 w-full">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[10px] uppercase tracking-wider font-bold text-gray-500">Маршрутизация сети:</span>
|
||||||
|
|
||||||
|
<ng-container *ngIf="getContainerDomain(container) as domain; else noDomain">
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<a [href]="'https://' + domain" target="_blank" class="text-xs font-semibold text-blue-400 hover:text-blue-300 hover:underline flex items-center gap-1 font-mono">
|
||||||
|
<mat-icon class="!w-3.5 !h-3.5 !text-sm text-blue-400 shrink-0">open_in_new</mat-icon>
|
||||||
|
<span class="truncate max-w-[200px]">{{ domain }}</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-[10px] text-gray-400 font-mono bg-gray-950 px-2 py-0.5 rounded border border-gray-850 shrink-0">
|
||||||
|
порт {{ getContainerPort(container) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #noDomain>
|
||||||
|
<span class="text-xs text-gray-600 italic mt-1">Локальный стек (нет публичного прокси)</span>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button mat-icon-button (click)="startEditDomain(container)" class="!w-8 !h-8 text-gray-500 hover:text-blue-400 transition shrink-0" title="Изменить настройки Caddy">
|
||||||
|
<mat-icon class="!text-lg">edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="editingDomains[container.id]" class="w-full flex flex-col gap-2 pt-1">
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-800 pb-2 mb-1">
|
||||||
|
<span class="text-[10px] uppercase tracking-wider font-bold text-blue-400 flex items-center gap-1">
|
||||||
|
<mat-icon class="!w-3.5 !h-3.5 !text-sm">router</mat-icon> Настройка шлюза шлюза
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button mat-flat-button color="primary" (click)="saveDomainConfig(container)" class="!min-w-0 !px-3 !h-7 !line-height-[28px] !text-xs font-bold">
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
<button mat-button (click)="cancelEditDomain(container.id)" class="!min-w-0 !px-3 !h-7 !line-height-[28px] !text-xs !text-gray-400">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 w-full pt-1">
|
||||||
|
<mat-form-field appearance="outline" class="flex-[2] !text-xs font-mono" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Поддомен</mat-label>
|
||||||
|
<input matInput type="text" [(ngModel)]="tempSubdomain" placeholder="example" class="lowercase" />
|
||||||
|
<span matSuffix class="text-gray-600 pr-1 text-[11px] font-mono pointer-events-none">.jambotron.com</span>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="flex-1 !text-xs font-mono" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Порт</mat-label>
|
||||||
|
<input matInput type="number" [(ngModel)]="tempPort" placeholder="80" min="1" max="65535" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-section-terminal">
|
||||||
|
<div class="terminal-header">
|
||||||
|
<h3>>_ DOCKER COMPOSE OUTPUT</h3>
|
||||||
|
<button mat-icon-button (click)="logs = []"><mat-icon>delete_sweep</mat-icon></button>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-window" #consoleScroll>
|
||||||
|
@for (line of logs; track $index) {
|
||||||
|
<div class="log-line" [innerHTML]="formatLogLine(line)"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
.project-details-wrapper {
|
||||||
|
padding: 24px;
|
||||||
|
background-color: #fff; // Основной фон страницы как на скрине
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h1 { font-size: 48px; font-weight: 900; margin: 0; text-transform: uppercase; }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 0; // На скрине кнопки острые
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 40px;
|
||||||
|
border-bottom: 2px solid #000;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
|
||||||
|
h2 { font-size: 24px; font-weight: 900; text-transform: uppercase; margin: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Исправляем карточки под скриншот */
|
||||||
|
.containers-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-card {
|
||||||
|
background: #f0f0f0 !important; // Серый фон карточки
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
border-bottom: 4px solid #333 !important; // Твоя фирменная черта
|
||||||
|
border-radius: 4px !important;
|
||||||
|
box-shadow: none !important; // Убираем Material-тень
|
||||||
|
|
||||||
|
.card-layout {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
.card-left {
|
||||||
|
.c-name { font-size: 20px; font-weight: 800; display: block; }
|
||||||
|
.c-image { font-size: 12px; color: #666; font-family: 'Fira Code', monospace; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.state-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ccc;
|
||||||
|
&.running {
|
||||||
|
background: #00ff41;
|
||||||
|
box-shadow: 0 0 8px #00ff41;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.uptime { font-size: 10px; color: #999; text-transform: uppercase; margin-top: 30px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Терминал — черная бездна */
|
||||||
|
.content-section-terminal {
|
||||||
|
margin-top: 50px;
|
||||||
|
|
||||||
|
.terminal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
h3 { font-size: 22px; font-weight: 800; text-transform: uppercase; margin: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-window {
|
||||||
|
background: #000; // Чистый черный
|
||||||
|
color: #00ff41; // Терминальный зеленый
|
||||||
|
padding: 20px;
|
||||||
|
font-family: 'Fira Code', 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
border-radius: 0;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field {
|
||||||
|
width: 100%;
|
||||||
|
--mdc-outlined-text-field-container-shape: 6px;
|
||||||
|
--mdc-outlined-text-field-input-text-color: #e5e7eb;
|
||||||
|
--mdc-outlined-text-field-focus-input-text-color: #ffffff;
|
||||||
|
--mdc-outlined-text-field-label-text-color: #6b7280;
|
||||||
|
--mdc-outlined-text-field-focus-label-text-color: #3b82f6;
|
||||||
|
--mdc-outlined-text-field-outline-color: #1f2937;
|
||||||
|
--mdc-outlined-text-field-focus-outline-color: #2563eb;
|
||||||
|
|
||||||
|
.mat-mdc-text-field-wrapper {
|
||||||
|
background-color: rgba(3, 7, 18, 0.6) !important; // Глубокий тёмный фон bg-gray-950
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ProjectDetailsComponent } from './project-details.component';
|
||||||
|
|
||||||
|
describe('ProjectDetailsComponent', () => {
|
||||||
|
let component: ProjectDetailsComponent;
|
||||||
|
let fixture: ComponentFixture<ProjectDetailsComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ProjectDetailsComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ProjectDetailsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+395
@@ -0,0 +1,395 @@
|
|||||||
|
// project-details.component.ts
|
||||||
|
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Subscription, timer, switchMap, catchError, of } from 'rxjs';
|
||||||
|
import {
|
||||||
|
ProjectService,
|
||||||
|
Project,
|
||||||
|
DockerContainer,
|
||||||
|
ProjectDetailResponse,
|
||||||
|
ContainerStats
|
||||||
|
} from '../../../services/project.service';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import { MatCard, MatCardContent } from '@angular/material/card';
|
||||||
|
import {FormsModule} from '@angular/forms';
|
||||||
|
import {MatInputModule} from '@angular/material/input';
|
||||||
|
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-project-details',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatDividerModule,
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatFormFieldModule, // <-- Включаем в сборку созидания
|
||||||
|
MatInputModule // <-- Включаем в сборку созидания
|
||||||
|
],
|
||||||
|
templateUrl: './project-details.component.html',
|
||||||
|
styleUrls: ['./project-details.component.scss']
|
||||||
|
})
|
||||||
|
export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChecked {
|
||||||
|
@ViewChild('consoleScroll') private consoleContainer!: ElementRef; // Прив'язка до вікна терміналу
|
||||||
|
|
||||||
|
project?: Project;
|
||||||
|
config?: any;
|
||||||
|
containers: DockerContainer[] = [];
|
||||||
|
logs: string[] = []; // Масив для зберігання рядків логів
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
private pollingSub?: Subscription; // Підписка на моніторинг контейнерів
|
||||||
|
private logSub?: Subscription; // Підписка на потік логів через STOMP
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private projectService: ProjectService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const id = this.route.snapshot.paramMap.get('id');
|
||||||
|
if (id) {
|
||||||
|
this.loadData(id);
|
||||||
|
this.startMonitoring(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Завантаження основних даних проекту
|
||||||
|
private loadData(id: string): void {
|
||||||
|
this.projectService.getProjectDetails(id).subscribe({
|
||||||
|
next: (data: ProjectDetailResponse) => {
|
||||||
|
this.project = data.project;
|
||||||
|
this.config = data.config;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Помилка завантаження проекту:', err);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startMonitoring(id: string): void {
|
||||||
|
this.pollingSub = timer(0, 5000).pipe(
|
||||||
|
switchMap(() => this.projectService.getProjectContainers(id).pipe(
|
||||||
|
catchError(() => of([]))
|
||||||
|
))
|
||||||
|
).subscribe({
|
||||||
|
next: (data: DockerContainer[]) => {
|
||||||
|
const activeIds = data.map(c => c.id);
|
||||||
|
|
||||||
|
// Очистка подписок для исчезнувших контейнеров
|
||||||
|
this.statsSubs.forEach((sub, containerId) => {
|
||||||
|
if (!activeIds.includes(containerId)) {
|
||||||
|
sub.unsubscribe();
|
||||||
|
this.statsSubs.delete(containerId);
|
||||||
|
this.containerStats.delete(containerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.containers = data;
|
||||||
|
|
||||||
|
// Инициация подписок для новых запущенных контейнеров
|
||||||
|
this.containers.forEach(container => {
|
||||||
|
if (container.state === 'running' && !this.statsSubs.has(container.id)) {
|
||||||
|
this.subscribeToContainerStats(container.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Логика подключения к логам (сохранена)
|
||||||
|
if (this.containers.length > 0 && !this.logSub) {
|
||||||
|
const mainContainer = this.containers.find(c => c.state === 'running') || this.containers[0];
|
||||||
|
this.connectLogs(mainContainer.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Підключення до трансляції логів
|
||||||
|
connectLogs(id: string): void {
|
||||||
|
console.log(`Підключення до логів контейнера: ${id}`);
|
||||||
|
|
||||||
|
// Очищаємо попередню підписку перед створенням нової
|
||||||
|
if (this.logSub) {
|
||||||
|
this.logSub.unsubscribe();
|
||||||
|
this.projectService.disconnectLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logs = []; // Очищаємо консоль при зміні контейнера
|
||||||
|
|
||||||
|
this.logSub = this.projectService.connectToDockerLogs(id).subscribe({
|
||||||
|
next: (msg) => {
|
||||||
|
this.logs.push(msg);
|
||||||
|
// Обмежуємо кількість рядків у пам'яті (останні 200)
|
||||||
|
if (this.logs.length > 200) this.logs.shift();
|
||||||
|
},
|
||||||
|
error: (err) => console.error('STOMP Error:', err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy(): void {
|
||||||
|
if (this.project) {
|
||||||
|
this.loading = true;
|
||||||
|
this.logs = []; // Очищаем окно перед новым деплоем
|
||||||
|
|
||||||
|
// Подписываемся на системный поток деплоя
|
||||||
|
this.projectService.connectToDeployLogs(this.project.id).subscribe({
|
||||||
|
next: (line) => {
|
||||||
|
this.logs.push(line);
|
||||||
|
if (this.logs.length > 200) this.logs.shift();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.projectService.deployProject(this.project.id).subscribe({
|
||||||
|
next: () => this.loading = false,
|
||||||
|
error: () => this.loading = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.project) this.projectService.stopProject(this.project.id).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete() {
|
||||||
|
if (confirm("Знищити проект назавжди?")) {
|
||||||
|
this.pollingSub?.unsubscribe(); // Зупиняємо оновлення інтерфейсу
|
||||||
|
|
||||||
|
if (this.project) {
|
||||||
|
this.projectService.deleteProject(this.project.id).subscribe({
|
||||||
|
next: () => this.router.navigate(['/main/user/projects']),
|
||||||
|
error: () => this.router.navigate(['/main/user/projects'])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewChecked(): void {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollToBottom(): void {
|
||||||
|
try {
|
||||||
|
this.consoleContainer.nativeElement.scrollTop = this.consoleContainer.nativeElement.scrollHeight;
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.pollingSub?.unsubscribe();
|
||||||
|
this.logSub?.unsubscribe();
|
||||||
|
|
||||||
|
// Очищаем все активные подписки на метрики контейнеров
|
||||||
|
this.statsSubs.forEach(sub => sub.unsubscribe());
|
||||||
|
this.statsSubs.clear();
|
||||||
|
|
||||||
|
this.projectService.disconnectLogs(); // Закрываем основное STOMP соединение
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматування для відображення кольорів у терміналі
|
||||||
|
formatLogLine(line: string): string {
|
||||||
|
if (!line) return '';
|
||||||
|
let formatted = line;
|
||||||
|
|
||||||
|
if (line.includes('ERROR') || line.includes('Error') || line.includes('failed')) {
|
||||||
|
formatted = `<span style="color: #ff5f56; font-weight: bold;">${line}</span>`;
|
||||||
|
} else if (line.includes('WARN') || line.includes('Warning')) {
|
||||||
|
formatted = `<span style="color: #ffbd2e;">${line}</span>`;
|
||||||
|
} else if (line.includes('INFO') || line.includes('success')) {
|
||||||
|
formatted = `<span style="color: #27c93f;">${line}</span>`;
|
||||||
|
} else if (line.includes('DEBUG')) {
|
||||||
|
formatted = `<span style="color: #ae81ff;">${line}</span>`;
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
containerStats: Map<string, ContainerStats> = new Map();
|
||||||
|
private statsSubs: Map<string, Subscription> = new Map();
|
||||||
|
|
||||||
|
// Новый метод для живых метрик
|
||||||
|
private subscribeToContainerStats(containerId: string): void {
|
||||||
|
const sub = this.projectService.connectToStats(containerId).subscribe({
|
||||||
|
next: (stats) => {
|
||||||
|
this.containerStats.set(containerId, stats);
|
||||||
|
},
|
||||||
|
error: (err) => console.error(`Stats error for ${containerId}:`, err)
|
||||||
|
});
|
||||||
|
this.statsSubs.set(containerId, sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключение состояния (Start/Stop)
|
||||||
|
toggleContainer(container: DockerContainer): void {
|
||||||
|
if (!this.project) return;
|
||||||
|
|
||||||
|
const obs = container.state === 'running'
|
||||||
|
? this.projectService.stopContainer(this.project.id, container.id)
|
||||||
|
: this.projectService.startContainer(this.project.id, container.id);
|
||||||
|
|
||||||
|
obs.subscribe({
|
||||||
|
next: () => console.log(`Статус контейнера ${container.id} изменен`),
|
||||||
|
error: (err) => console.error('Ошибка управления:', err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рестарт
|
||||||
|
restartContainer(containerId: string): void {
|
||||||
|
if (this.project) {
|
||||||
|
this.projectService.restartContainer(this.project.id, containerId).subscribe({
|
||||||
|
next: () => console.log(`Контейнер ${containerId} перезапущен`),
|
||||||
|
error: (err) => console.error('Ошибка рестарта:', err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ==========================================
|
||||||
|
// CADDY ROUTING & INLINE EDITING MANAGEMENT
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Хранит состояние редактирования для каждого контейнера: { [containerId]: boolean }
|
||||||
|
editingDomains: { [key: string]: boolean } = {};
|
||||||
|
|
||||||
|
// Временные переменные для хранения ввода пользователя во время инлайн-редактирования
|
||||||
|
tempSubdomain: string = '';
|
||||||
|
tempPort: number = 80;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Безопасно извлекает имя сервиса (например, 'web') из имени Docker-контейнера.
|
||||||
|
* Проверяет префиксы как по ID, так и по Name проекта.
|
||||||
|
*/
|
||||||
|
getServiceName(container: any): string | null {
|
||||||
|
if (!container) return null;
|
||||||
|
|
||||||
|
// 1. Идеальный путь созидания: берем стабильный ID из меток Docker
|
||||||
|
if (container.labels && container.labels['jambotron.service.id']) {
|
||||||
|
return container.labels['jambotron.service.id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Если меток нет, переходим к парсингу имени контейнера
|
||||||
|
const containerName = container.name || '';
|
||||||
|
|
||||||
|
// Проверяем базовый маркер нашей системы
|
||||||
|
if (!containerName.startsWith('jambotron-')) {
|
||||||
|
console.warn(`[Jambotron] Контейнер не принадлежит системе Jambotron: ${containerName}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отрезаем индекс реплики (-1, -2) и префикс "jambotron-"
|
||||||
|
let cleanName = containerName.replace(/-\d+$/, '').replace(/^jambotron-/, '');
|
||||||
|
|
||||||
|
// 3. Проверяем массив известных имен сервисов из YAML
|
||||||
|
const validServices: string[] = this.config?.serviceNames || [];
|
||||||
|
if (validServices.length > 0) {
|
||||||
|
const match = validServices.find((service: string) => cleanName.endsWith(service) || cleanName.includes(service));
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Финальный пуленепробиваемый фоллбэк для старых контейнеров (вырезаем имя проекта)
|
||||||
|
// Для "test-web" -> cleanName = "test-web" -> parts = ['test', 'web'] -> возвращает "web"
|
||||||
|
// const parts = cleanName.split('-');
|
||||||
|
// if (parts.length >= 2) {
|
||||||
|
// return parts.slice(1).join('-');
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
// Только если вообще ничего не получилось извлечь — выводим реальное предупреждение
|
||||||
|
if (!cleanName) {
|
||||||
|
console.warn(`[Jambotron] Не удалось определить имя сервиса для: ${containerName}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает текущий сформированный домен для вывода в UI
|
||||||
|
*/
|
||||||
|
getContainerDomain(container: any): string | null {
|
||||||
|
const serviceId = this.getServiceName(container);
|
||||||
|
|
||||||
|
if (!serviceId || !this.config?.serviceConfigs) return null;
|
||||||
|
|
||||||
|
// Ищем настройки в мапе по системному ID (например, srv-0)
|
||||||
|
const serviceConfig = this.config.serviceConfigs[serviceId];
|
||||||
|
return serviceConfig && serviceConfig.subdomain ? `${serviceConfig.subdomain}.jambotron.com` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает порт сервиса из конфигурации MongoDB
|
||||||
|
*/
|
||||||
|
getContainerPort(container: any): number {
|
||||||
|
const serviceId = this.getServiceName(container);
|
||||||
|
if (!serviceId || !this.config?.serviceConfigs) return 80;
|
||||||
|
|
||||||
|
// Ищем настройки в мапе по системному ID (например, srv-0)
|
||||||
|
const serviceConfig = this.config.serviceConfigs[serviceId];
|
||||||
|
return serviceConfig && serviceConfig.port ? serviceConfig.port : 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Активация режима редактирования.
|
||||||
|
*/
|
||||||
|
startEditDomain(container: any) {
|
||||||
|
// ПЕРЕДАЕМ КОНТЕЙНЕР ЦЕЛИКОМ, а не container.name
|
||||||
|
const serviceName = this.getServiceName(container);
|
||||||
|
if (!serviceName) {
|
||||||
|
console.warn(`[Jambotron] Не удалось определить имя сервиса для:`, container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключаем реактивный флаг видимости формы в шаблоне
|
||||||
|
this.editingDomains[container.id] = true;
|
||||||
|
|
||||||
|
// Подтягиваем текущие сохранённые значения из локального объекта конфигурации config
|
||||||
|
const currentConfig = this.config?.serviceConfigs?.[serviceName];
|
||||||
|
this.tempSubdomain = currentConfig?.subdomain || '';
|
||||||
|
this.tempPort = currentConfig?.port || 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранение изменённого поддомена и порта в MongoDB базы данных
|
||||||
|
*/
|
||||||
|
saveDomainConfig(container: any) {
|
||||||
|
// ПЕРЕДАЕМ КОНТЕЙНЕР ЦЕЛИКОМ, а не container.name
|
||||||
|
const serviceName = this.getServiceName(container);
|
||||||
|
if (!serviceName || !this.project?.id || !this.config) return;
|
||||||
|
|
||||||
|
if (!this.config.serviceConfigs) {
|
||||||
|
this.config.serviceConfigs = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const subdomainClean = this.tempSubdomain.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!subdomainClean) {
|
||||||
|
// Если поле очищено — удаляем проксирование
|
||||||
|
delete this.config.serviceConfigs[serviceName];
|
||||||
|
} else {
|
||||||
|
this.config.serviceConfigs[serviceName] = {
|
||||||
|
subdomain: subdomainClean,
|
||||||
|
port: this.tempPort || 80
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка монолитного конфига на бэкенд
|
||||||
|
this.projectService.updateProjectConfig(this.project.id, this.config).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.editingDomains[container.id] = false;
|
||||||
|
console.log(`[Caddy] Маршрут для сервиса ${serviceName} успешно сохранён.`);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Ошибка обновления конфигурации проекта:', err);
|
||||||
|
alert(err.error?.message || 'Не удалось применить конфигурацию шлюза.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cancelEditDomain(containerId: string) {
|
||||||
|
this.editingDomains[containerId] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
<p>project-edit.component works!</p>
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ProjectEditComponent } from './project-edit.component';
|
||||||
|
|
||||||
|
describe('ProjectEditComponent', () => {
|
||||||
|
let component: ProjectEditComponent;
|
||||||
|
let fixture: ComponentFixture<ProjectEditComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ProjectEditComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ProjectEditComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-project-edit.component',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './project-edit.component.html',
|
||||||
|
styleUrl: './project-edit.component.scss',
|
||||||
|
})
|
||||||
|
export class ProjectEditComponent {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<div class="dashboard-container">
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<h1>Jumbotron Dashboard</h1>
|
||||||
|
<button mat-flat-button color="primary" class="new-project-btn" (click)='createNewProject()'>
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
NEW PROJECT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isLoading) {
|
||||||
|
<div class="loader-container">
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="projects-grid">
|
||||||
|
@for (project of projects; track project.id) {
|
||||||
|
<mat-card class="project-card" (click)="openProjectDetails(project.id)">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ project.name }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="status-row">
|
||||||
|
<mat-icon [style.color]="getStatusColor(project.status)">circle</mat-icon>
|
||||||
|
<span class="status-text">{{ project.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-info">
|
||||||
|
<span class="date-label">Создан:</span>
|
||||||
|
<span class="date-value">{{ project.createdAt | date:'dd.MM.yyyy HH:mm' }}</span>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
} @empty {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon>folder_open</mat-icon>
|
||||||
|
<p>Проектов пока нет. Начните созидание!</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
.dashboard-container {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
color: #3e87d9; // Белый для контраста с темным фоном
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-project-btn {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
background: rgba(255, 255, 255, 0.95) !important;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(0,0,0,0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
box-shadow: 0 12px 20px rgba(0,0,0,0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-card-header {
|
||||||
|
padding: 16px 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-card-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
|
||||||
|
.date-label { color: #888; }
|
||||||
|
.date-value { color: #444; font-weight: 500; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-container {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ProjectsComponent } from './projects.component';
|
||||||
|
|
||||||
|
describe('ProjectsComponent', () => {
|
||||||
|
let component: ProjectsComponent;
|
||||||
|
let fixture: ComponentFixture<ProjectsComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ProjectsComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ProjectsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { Project, ProjectService } from '../../../services/project.service';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { MatCard, MatCardContent, MatCardHeader, MatCardTitle } from '@angular/material/card';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-projects',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
MatIcon,
|
||||||
|
MatProgressBar,
|
||||||
|
MatButton,
|
||||||
|
MatCard,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
MatCardContent,
|
||||||
|
DatePipe
|
||||||
|
],
|
||||||
|
templateUrl: './projects.component.html',
|
||||||
|
styleUrl: './projects.component.scss',
|
||||||
|
})
|
||||||
|
export class ProjectsComponent implements OnInit {
|
||||||
|
projects: Project[] = [];
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private projectService: ProjectService,
|
||||||
|
private router: Router
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProjects(): void {
|
||||||
|
this.projectService.getUserProjects().subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
// Фикс бага с JSON в имени
|
||||||
|
this.projects = data.map(p => {
|
||||||
|
if (p.name && p.name.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(p.name);
|
||||||
|
p.name = parsed.name || 'Untitled Project';
|
||||||
|
} catch (e) {
|
||||||
|
p.name = 'Invalid Data';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
this.isLoading = false;
|
||||||
|
},
|
||||||
|
error: () => this.isLoading = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openProjectDetails(projectId: string): void {
|
||||||
|
this.router.navigate(['/main/user/projects', projectId, 'details']);
|
||||||
|
}
|
||||||
|
|
||||||
|
createNewProject(): void {
|
||||||
|
this.router.navigate(['/main/user/projects/add']);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'RUNNING': return '#4caf50';
|
||||||
|
case 'STARTING': return '#ffc107';
|
||||||
|
case 'STOPPED': return '#9e9e9e';
|
||||||
|
case 'ERROR': return '#f44336';
|
||||||
|
default: return '#bdbdbd';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-10
@@ -1,20 +1,15 @@
|
|||||||
import {Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit} from '@angular/core';
|
import {Component, CUSTOM_ELEMENTS_SCHEMA, inject} from '@angular/core';
|
||||||
import {UserApiService} from '../user-api.service';
|
import {UserApiService} from '../user-api.service';
|
||||||
import {TokenStorageService} from '../../../services/token-storage.service';
|
import {RouterLink} from '@angular/router';
|
||||||
import {EventBusService} from '../../../_shared/event-bus.service';
|
|
||||||
import {Router, RouterLink} from '@angular/router';
|
|
||||||
import {AuthService} from '../../../services/auth.service';
|
|
||||||
import {Tutorial} from '../../../models/tutorial.model';
|
import {Tutorial} from '../../../models/tutorial.model';
|
||||||
import {MarkdownComponent, MarkdownService} from 'ngx-markdown';
|
import {MarkdownComponent, MarkdownService} from 'ngx-markdown';
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {MatCard, MatCardActions, MatCardContent, MatCardHeader} from '@angular/material/card';
|
import {MatCard, MatCardActions, MatCardContent} from '@angular/material/card';
|
||||||
import {MatError, MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
import {MatError, MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
||||||
import {MatTab, MatTabGroup} from '@angular/material/tabs';
|
import {MatTab, MatTabGroup} from '@angular/material/tabs';
|
||||||
import {
|
import {
|
||||||
AbstractControl,
|
AbstractControl,
|
||||||
FormBuilder,
|
FormBuilder,
|
||||||
FormControl,
|
|
||||||
FormGroup,
|
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule, ValidationErrors,
|
ReactiveFormsModule, ValidationErrors,
|
||||||
ValidatorFn,
|
ValidatorFn,
|
||||||
@@ -24,7 +19,6 @@ import {AngularMarkdownEditorModule, EditorInstance, EditorOption} from 'angular
|
|||||||
import {MatDialog} from '@angular/material/dialog';
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
import {DialogSelectImageComponent} from '../dialog-select-image.component/dialog-select-image.component';
|
import {DialogSelectImageComponent} from '../dialog-select-image.component/dialog-select-image.component';
|
||||||
import {DialogUploadImageComponent} from '../dialog-upload-image.component/dialog-upload-image.component';
|
import {DialogUploadImageComponent} from '../dialog-upload-image.component/dialog-upload-image.component';
|
||||||
import {FileUploadComponent} from '../../../components/file-upload.component/file-upload.component';
|
|
||||||
import {MatDivider} from '@angular/material/divider';
|
import {MatDivider} from '@angular/material/divider';
|
||||||
import {GlobalConstants} from '../../../global-constants';
|
import {GlobalConstants} from '../../../global-constants';
|
||||||
|
|
||||||
@@ -49,7 +43,6 @@ type TutorialFormValue = {
|
|||||||
MatCard,
|
MatCard,
|
||||||
MatCardActions,
|
MatCardActions,
|
||||||
MatCardContent,
|
MatCardContent,
|
||||||
MatCardHeader,
|
|
||||||
MatFormField,
|
MatFormField,
|
||||||
MatInput,
|
MatInput,
|
||||||
MatLabel,
|
MatLabel,
|
||||||
|
|||||||
-1
@@ -42,7 +42,6 @@ type TutorialFormValue = {
|
|||||||
MatCard,
|
MatCard,
|
||||||
MatCardActions,
|
MatCardActions,
|
||||||
MatCardContent,
|
MatCardContent,
|
||||||
MatCardHeader,
|
|
||||||
MatFormField,
|
MatFormField,
|
||||||
MatInput,
|
MatInput,
|
||||||
MatLabel,
|
MatLabel,
|
||||||
|
|||||||
+2
-3
@@ -22,7 +22,6 @@ import {
|
|||||||
import {DatePipe} from '@angular/common';
|
import {DatePipe} from '@angular/common';
|
||||||
import {MatCheckbox} from '@angular/material/checkbox';
|
import {MatCheckbox} from '@angular/material/checkbox';
|
||||||
import {MatSlideToggle} from '@angular/material/slide-toggle';
|
import {MatSlideToggle} from '@angular/material/slide-toggle';
|
||||||
import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/breadcrumbs.component';
|
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -44,8 +43,8 @@ import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/br
|
|||||||
MatCheckbox,
|
MatCheckbox,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
MatCell,
|
MatCell,
|
||||||
MatSlideToggle,
|
MatSlideToggle
|
||||||
BreadcrumbsComponent
|
|
||||||
],
|
],
|
||||||
schemas:[CUSTOM_ELEMENTS_SCHEMA],
|
schemas:[CUSTOM_ELEMENTS_SCHEMA],
|
||||||
templateUrl: './tutorials-list.component.html',
|
templateUrl: './tutorials-list.component.html',
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import {Component, CUSTOM_ELEMENTS_SCHEMA, OnInit, ViewChild} from '@angular/core';
|
import {Component, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
|
||||||
import {RouterOutlet} from '@angular/router';
|
|
||||||
import {MatSidenav} from '@angular/material/sidenav';
|
|
||||||
import {BreakpointObserver} from '@angular/cdk/layout';
|
|
||||||
import {SideBarUserComponent} from '../side-bar-user.component/side-bar-user.component';
|
|
||||||
import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/breadcrumbs.component';
|
|
||||||
import {InnerLayoutComponent} from '../../../components/inner-layout.component/inner-layout.component';
|
import {InnerLayoutComponent} from '../../../components/inner-layout.component/inner-layout.component';
|
||||||
import {MODERATOR_ROUTS} from '../../moderator-module/moderator.routing';
|
|
||||||
import {USER_ROUTS} from '../user.routing';
|
import {USER_ROUTS} from '../user.routing';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user.component',
|
selector: 'app-user.component',
|
||||||
imports: [
|
imports: [
|
||||||
RouterOutlet,
|
|
||||||
SideBarUserComponent,
|
|
||||||
BreadcrumbsComponent,
|
|
||||||
InnerLayoutComponent
|
InnerLayoutComponent
|
||||||
],
|
],
|
||||||
templateUrl: './user.component.html',
|
templateUrl: './user.component.html',
|
||||||
|
|||||||
@@ -50,6 +50,21 @@ export const USER_ROUTS: Routes = [
|
|||||||
path:'docker',
|
path:'docker',
|
||||||
loadComponent: () => import('../user-module/docker-management-component/docker-management-component').then((c)=>c.DockerManagementComponent),
|
loadComponent: () => import('../user-module/docker-management-component/docker-management-component').then((c)=>c.DockerManagementComponent),
|
||||||
data:{title: 'Docker', icon: 'docker', nav:true}
|
data:{title: 'Docker', icon: 'docker', nav:true}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects',
|
||||||
|
loadComponent: () => import('../user-module/projects.component/projects.component').then((c) => c.ProjectsComponent),
|
||||||
|
data: { title: 'Dashboard', icon: 'dashboard', nav: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects/:id/details',
|
||||||
|
loadComponent: () => import('../user-module/project-details.component/project-details.component').then((c) => c.ProjectDetailsComponent),
|
||||||
|
data: { title: 'Project Details' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects/add',
|
||||||
|
loadComponent: () => import('../user-module/project-add.component/project-add.component').then((c) => c.ProjectAddComponent),
|
||||||
|
data: { title: 'New Project' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import {Observable, Subject} from 'rxjs';
|
||||||
|
import { GlobalConstants } from '../global-constants';
|
||||||
|
|
||||||
|
import SockJS from 'sockjs-client';
|
||||||
|
import { Client, IMessage } from '@stomp/stompjs';
|
||||||
|
|
||||||
|
// Унифицируем название интерфейса для списка контейнеров
|
||||||
|
export interface DockerContainer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
state: string; // running, exited, etc.
|
||||||
|
status: string; // Up 2 hours, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерфейс Project в соответствии с Project.java[cite: 3]
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
user?: any; // Соответствует полю private User user в Java[cite: 3]
|
||||||
|
status: string;
|
||||||
|
localPath: string;
|
||||||
|
createdAt: Date;
|
||||||
|
containers?: DockerContainer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCreateRequest {
|
||||||
|
name: string;
|
||||||
|
rawComposeContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDetailResponse {
|
||||||
|
project: Project;
|
||||||
|
config: {
|
||||||
|
projectId: string;
|
||||||
|
rawComposeContent: string;
|
||||||
|
envVariables: { [key: string]: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerStats {
|
||||||
|
containerId: string;
|
||||||
|
cpuUsage: number;
|
||||||
|
memoryUsage: number;
|
||||||
|
memoryLimit: number;
|
||||||
|
memoryPercent: number;
|
||||||
|
}
|
||||||
|
// Описание структуры конфигурации доменов созидания
|
||||||
|
export interface ServiceSettings {
|
||||||
|
subdomain?: string;
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectConfig {
|
||||||
|
id?: string;
|
||||||
|
projectId: string;
|
||||||
|
rawComposeContent: string;
|
||||||
|
serviceNames: string[];
|
||||||
|
serviceConfigs: { [key: string]: ServiceSettings };
|
||||||
|
version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ProjectService {
|
||||||
|
// Используем baseUrl, как определено в начале класса
|
||||||
|
private baseUrl = GlobalConstants.API_URL;
|
||||||
|
private stompClient: Client | null = null;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
// Создание нового проекта[cite: 4, 5]
|
||||||
|
createProject(request: ProjectCreateRequest): Observable<Project> {
|
||||||
|
return this.http.post<Project>(`${this.baseUrl}/user/projects`, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Методы для Dashboard[cite: 4, 5]
|
||||||
|
getUserProjects(): Observable<Project[]> {
|
||||||
|
return this.http.get<Project[]>(`${this.baseUrl}/user/projects`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProjectDetails(id: string): Observable<ProjectDetailResponse> {
|
||||||
|
return this.http.get<ProjectDetailResponse>(`${this.baseUrl}/user/projects/${id}/details`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Управление жизненным циклом (Docker Compose)[cite: 3, 4]
|
||||||
|
deployProject(id: string): Observable<string> {
|
||||||
|
return this.http.post(`${this.baseUrl}/user/projects/${id}/deploy`, {}, { responseType: 'text' });
|
||||||
|
}
|
||||||
|
|
||||||
|
stopProject(id: string): Observable<string> {
|
||||||
|
return this.http.post(`${this.baseUrl}/user/projects/${id}/stop`, {}, { responseType: 'text' });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteProject(id: string): Observable<any> {
|
||||||
|
return this.http.delete(`${this.baseUrl}/user/projects/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Администрирование[cite: 4]
|
||||||
|
getAllProjectsAdmin(): Observable<Project[]> {
|
||||||
|
return this.http.get<Project[]>(`${this.baseUrl}/admin/projects/all`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket для трансляции логов в реальном времени[cite: 3, 5]
|
||||||
|
public connectToDockerLogs(containerId: string): Observable<string> {
|
||||||
|
const logSubject = new Subject<string>();
|
||||||
|
|
||||||
|
// Твой эндпоинт из WebSocketConfig.java
|
||||||
|
const socket = new SockJS(`${this.baseUrl}/ws-jambotron`);
|
||||||
|
|
||||||
|
this.stompClient = new Client({
|
||||||
|
webSocketFactory: () => socket,
|
||||||
|
debug: (str) => console.log(str),
|
||||||
|
onConnect: () => {
|
||||||
|
// 1. Подписываемся на логи конкретного контейнера
|
||||||
|
this.stompClient?.subscribe(`/topic/logs/${containerId}`, (message: IMessage) => {
|
||||||
|
logSubject.next(message.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Отправляем сигнал бэкенду начать стриминг (согласно LogController)
|
||||||
|
this.stompClient?.publish({
|
||||||
|
destination: '/app/start-logs',
|
||||||
|
body: containerId
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStompError: (frame) => {
|
||||||
|
logSubject.error('Broker error: ' + frame.headers['message']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.stompClient.activate();
|
||||||
|
return logSubject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnectLogs(): void {
|
||||||
|
if (this.stompClient) {
|
||||||
|
this.stompClient.deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ИСПРАВЛЕНО: возвращаем DockerContainer и используем правильный baseUrl[cite: 5]
|
||||||
|
getProjectContainers(projectId: string): Observable<DockerContainer[]> {
|
||||||
|
return this.http.get<DockerContainer[]>(`${this.baseUrl}/user/projects/${projectId}/containers`);
|
||||||
|
}
|
||||||
|
// Добавь в project.service.ts
|
||||||
|
public connectToDeployLogs(projectId: string): Observable<string> {
|
||||||
|
const deploySubject = new Subject<string>();
|
||||||
|
const socket = new SockJS(`${this.baseUrl}/ws-jambotron`);
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
webSocketFactory: () => socket,
|
||||||
|
onConnect: () => {
|
||||||
|
// Подписываемся на системный топик деплоя
|
||||||
|
client.subscribe(`/topic/deploy/${projectId}`, (message: IMessage) => {
|
||||||
|
deploySubject.next(message.body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.activate();
|
||||||
|
return deploySubject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public connectToStats(containerId: string): Observable<ContainerStats> {
|
||||||
|
const statsSubject = new Subject<ContainerStats>();
|
||||||
|
const socket = new SockJS(`${this.baseUrl}/ws-jambotron`);
|
||||||
|
const client = new Client({
|
||||||
|
webSocketFactory: () => socket,
|
||||||
|
onConnect: () => {
|
||||||
|
client.subscribe(`/topic/stats/${containerId}`, (message) => {
|
||||||
|
statsSubject.next(JSON.parse(message.body));
|
||||||
|
});
|
||||||
|
// Сигнал к началу сбора
|
||||||
|
client.publish({ destination: '/app/start-stats', body: containerId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client.activate();
|
||||||
|
return statsSubject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
restartContainer(projectId: string, containerId: string): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/restart`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
stopContainer(projectId: string, containerId: string): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/stop`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
startContainer(projectId: string, containerId: string): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/start`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить конфигурацию поддоменов из MongoDB
|
||||||
|
getProjectConfig(projectId: string): Observable<ProjectConfig> {
|
||||||
|
return this.http.get<ProjectConfig>(`${this.baseUrl}/user/projects/${projectId}/config`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить конфигурацию (сохранить измененные домены и порты)
|
||||||
|
updateProjectConfig(projectId: string, config: ProjectConfig): Observable<string> {
|
||||||
|
return this.http.put(`${this.baseUrl}/user/projects/${projectId}/config`, config, {
|
||||||
|
responseType: 'text'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<script>
|
||||||
|
if (window.global === undefined) {
|
||||||
|
window.global = window;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>JambotronUi</title>
|
<title>JambotronUi</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<script>
|
||||||
|
if (window.global === undefined) {
|
||||||
|
window.global = window;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>JambotronUi</title>
|
<title>JambotronUi</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"secure": false,
|
"secure": false,
|
||||||
"changeOrigin": true,
|
"changeOrigin": true,
|
||||||
"logLevel": "debug",
|
"logLevel": "debug",
|
||||||
|
"ws": true,
|
||||||
"pathRewrite": {"^/api" : "http://localhost:8081/api"}
|
"pathRewrite": {"^/api" : "http://localhost:8081/api"}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"/api/**": {
|
"/api": {
|
||||||
"target": "http://localhost:8082",
|
"target": "http://localhost:8082",
|
||||||
"secure": false,
|
"secure": false,
|
||||||
"changeOrigin": true,
|
"changeOrigin": true,
|
||||||
"logLevel": "debug",
|
"logLevel": "debug",
|
||||||
"pathRewrite": {"^/api" : "http://localhost:8082/api"}
|
"timeout": 0,
|
||||||
|
"ws": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.jambotronGroup.jambotron.DTOs;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ContainerStatsDTO {
|
||||||
|
private String containerId;
|
||||||
|
private double cpuUsage; // Процент загрузки CPU
|
||||||
|
private long memoryUsage; // Использование памяти в байтах
|
||||||
|
private long memoryLimit; // Лимит памяти в байтах
|
||||||
|
private double memoryPercent; // Процент использования памяти
|
||||||
|
}
|
||||||
@@ -11,10 +11,14 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories;
|
||||||
|
|
||||||
import java.util.TimeZone;
|
import java.util.TimeZone;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableJpaRepositories(basePackages = "com.jambotronGroup.jambotron.repository")
|
||||||
|
@EnableMongoRepositories(basePackages = "com.jambotronGroup.jambotron.repository")
|
||||||
public class JambotronApplication{
|
public class JambotronApplication{
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(JambotronApplication.class);
|
private static final Logger logger = LoggerFactory.getLogger(JambotronApplication.class);
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.jambotronGroup.jambotron.WebSocket;
|
||||||
|
|
||||||
|
import com.github.dockerjava.api.DockerClient;
|
||||||
|
import com.github.dockerjava.api.async.ResultCallback;
|
||||||
|
|
||||||
|
import com.github.dockerjava.api.model.Container;
|
||||||
|
import com.github.dockerjava.api.model.Frame;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.TextMessage;
|
||||||
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||||
|
import org.springframework.web.util.UriTemplate;
|
||||||
|
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class DockerLogsHandler extends TextWebSocketHandler {
|
||||||
|
|
||||||
|
private static final Logger _logger = LoggerFactory.getLogger(DockerLogsHandler.class);
|
||||||
|
private final DockerClient dockerClient;
|
||||||
|
|
||||||
|
// ИСПРАВЛЕНО: Используем базовый интерфейс ResultCallback для хранения подписок
|
||||||
|
private final Map<String, ResultCallback<Frame>> activeSubscriptions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public DockerLogsHandler(DockerClient dockerClient) {
|
||||||
|
this.dockerClient = dockerClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||||
|
UriTemplate template = new UriTemplate("/api/ws-jambotron/{projectId}");
|
||||||
|
Map<String, String> variables = template.match(session.getUri().getPath());
|
||||||
|
String projectId = variables.get("projectId");
|
||||||
|
|
||||||
|
if (projectId != null) {
|
||||||
|
_logger.info("Установлено WS соединение для проекта: {}", projectId);
|
||||||
|
startStreamingLogs(session, projectId);
|
||||||
|
} else {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startStreamingLogs(WebSocketSession session, String projectId) {
|
||||||
|
try {
|
||||||
|
// 1. Ищем реальный ID контейнера по метке проекта Compose
|
||||||
|
// По умолчанию Docker Compose использует ID папки (наш projectId) как имя проекта
|
||||||
|
List<Container> containers = dockerClient.listContainersCmd()
|
||||||
|
.withLabelFilter(Map.of("com.docker.compose.project", projectId))
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
if (containers.isEmpty()) {
|
||||||
|
_logger.warn("Контейнеры для проекта {} не найдены в Docker", projectId);
|
||||||
|
session.sendMessage(new TextMessage("Система: Контейнеры не запущены. Ожидание деплоя..."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Берем первый контейнер из проекта для трансляции логов
|
||||||
|
String realContainerId = containers.get(0).getId();
|
||||||
|
_logger.info("Трансляция логов из контейнера: {}", realContainerId);
|
||||||
|
|
||||||
|
// 2. Настраиваем команду логов[cite: 5]
|
||||||
|
var logCmd = dockerClient.logContainerCmd(realContainerId)
|
||||||
|
.withStdOut(true)
|
||||||
|
.withStdErr(true)
|
||||||
|
//.withFollow(true)
|
||||||
|
.withTail(100);
|
||||||
|
|
||||||
|
// 3. Используем адаптер без опасных приведений типов[cite: 5]
|
||||||
|
ResultCallback.Adapter<Frame> callback = new ResultCallback.Adapter<>() {
|
||||||
|
@Override
|
||||||
|
public void onNext(Frame item) {
|
||||||
|
try {
|
||||||
|
if (session.isOpen() && item != null) {
|
||||||
|
// Декодируем байты лога в строку для терминала Angular[cite: 4, 5]
|
||||||
|
session.sendMessage(new TextMessage(new String(item.getPayload())));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
this.onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable throwable) {
|
||||||
|
_logger.error("Ошибка в потоке логов: {}", throwable.getMessage());
|
||||||
|
this.onComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logCmd.exec(callback);
|
||||||
|
activeSubscriptions.put(session.getId(), callback);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
_logger.error("Критическая ошибка при запуске логов: ", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionClosed(WebSocketSession session, org.springframework.web.socket.CloseStatus status) {
|
||||||
|
// ИСПРАВЛЕНО: Безопасное закрытие через интерфейс[cite: 5]
|
||||||
|
ResultCallback<Frame> callback = activeSubscriptions.remove(session.getId());
|
||||||
|
if (callback != null) {
|
||||||
|
try {
|
||||||
|
callback.close();
|
||||||
|
_logger.info("Стрим логов закрыт для сессии: {}", session.getId());
|
||||||
|
} catch (IOException e) {
|
||||||
|
_logger.error("Ошибка при закрытии подписки: ", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.jambotronGroup.jambotron.WebSocket;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||||
|
import org.springframework.web.socket.config.annotation.*;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocketMessageBroker
|
||||||
|
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||||
|
// Брокер для сообщений клиенту
|
||||||
|
config.enableSimpleBroker("/topic");
|
||||||
|
// Префикс для запросов от клиента к серверу
|
||||||
|
config.setApplicationDestinationPrefixes("/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||||
|
registry.addEndpoint("/api/ws-jambotron")
|
||||||
|
.setAllowedOriginPatterns("https://*.jambotron.com", "https://jambotron.com")
|
||||||
|
.setAllowedOrigins("http://localhost:8082", "http://localhost:4200")
|
||||||
|
.withSockJS(); // Для стабильности соединения
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.jambotronGroup.jambotron.console;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync // <--- Обязательно добавь это
|
||||||
|
public class AsyncConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.jambotronGroup.jambotron.console;
|
||||||
|
|
||||||
|
import com.github.dockerjava.api.DockerClient;
|
||||||
|
import com.github.dockerjava.api.async.ResultCallback;
|
||||||
|
import com.github.dockerjava.api.model.Frame;
|
||||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DockerLogService {
|
||||||
|
|
||||||
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
|
private final DockerClient dockerClient;
|
||||||
|
|
||||||
|
// Spring сам подставит сюда dockerClient из твоего DockerConfig
|
||||||
|
public DockerLogService(SimpMessagingTemplate messagingTemplate, DockerClient dockerClient) {
|
||||||
|
this.messagingTemplate = messagingTemplate;
|
||||||
|
this.dockerClient = dockerClient;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void streamLogs(String containerId) {
|
||||||
|
dockerClient.logContainerCmd(containerId)
|
||||||
|
.withStdOut(true)
|
||||||
|
.withStdErr(true)
|
||||||
|
.withFollowStream(true)
|
||||||
|
.withTail(100)
|
||||||
|
.exec(new ResultCallback.Adapter<Frame>() {
|
||||||
|
@Override
|
||||||
|
public void onNext(Frame frame) {
|
||||||
|
String logLine = new String(frame.getPayload());
|
||||||
|
// Отправляем в топик, на который подпишется фронтенд
|
||||||
|
messagingTemplate.convertAndSend("/topic/logs/" + containerId, logLine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.jambotronGroup.jambotron.controllers;
|
||||||
|
|
||||||
|
import com.jambotronGroup.jambotron.console.DockerLogService;
|
||||||
|
import com.jambotronGroup.jambotron.projects.DockerStatsService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class LogController {
|
||||||
|
|
||||||
|
private final DockerLogService dockerLogService;
|
||||||
|
|
||||||
|
private static final Logger _logger = LoggerFactory.getLogger(LogController.class);
|
||||||
|
|
||||||
|
private final DockerStatsService dockerStatsService;
|
||||||
|
|
||||||
|
public LogController(DockerLogService dockerLogService, DockerStatsService dockerStatsService) {
|
||||||
|
this.dockerLogService = dockerLogService;
|
||||||
|
this.dockerStatsService = dockerStatsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Когда клиент шлет сообщение на /app/start-logs
|
||||||
|
@MessageMapping("/start-logs")
|
||||||
|
public void handleStartLogs(String containerId) {
|
||||||
|
dockerLogService.streamLogs(containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@MessageMapping("/start-stats")
|
||||||
|
public void handleStartStats(String containerId) {
|
||||||
|
_logger.info("Начинаем сбор метрик для контейнера: {}", containerId);
|
||||||
|
dockerStatsService.startStreamingStats(containerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package com.jambotronGroup.jambotron.controllers;
|
||||||
|
|
||||||
|
import com.jambotronGroup.jambotron.model.Project;
|
||||||
|
import com.jambotronGroup.jambotron.model.ProjectConfig;
|
||||||
|
import com.jambotronGroup.jambotron.model.User;
|
||||||
|
import com.jambotronGroup.jambotron.projects.ContainerStatus;
|
||||||
|
import com.jambotronGroup.jambotron.projects.ProjectCreateRequest;
|
||||||
|
import com.jambotronGroup.jambotron.projects.ProjectDetailResponse;
|
||||||
|
import com.jambotronGroup.jambotron.projects.ProjectService;
|
||||||
|
import com.jambotronGroup.jambotron.repository.ProjectConfigRepository;
|
||||||
|
import com.jambotronGroup.jambotron.repository.UserRepository;
|
||||||
|
import com.jambotronGroup.jambotron.security.AuthenticationFacade;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProjectController {
|
||||||
|
|
||||||
|
private static final Logger _logger = LoggerFactory.getLogger(ProjectController.class);
|
||||||
|
|
||||||
|
private final ProjectService projectService;
|
||||||
|
private final ProjectConfigRepository configRepository; // <-- Это то, что я чуть не забыла!
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final AuthenticationFacade authenticationFacade;
|
||||||
|
|
||||||
|
// --- Просмотр списка и дашборд ---
|
||||||
|
|
||||||
|
@GetMapping("/user/projects")
|
||||||
|
public ResponseEntity<List<Project>> getUserProjects() {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
List<Project> projects = projectService.findAllByOwnerId(user.getId());
|
||||||
|
return projects.isEmpty() ? new ResponseEntity<>(HttpStatus.NO_CONTENT) : ResponseEntity.ok(projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/user/projects/{id}/details")
|
||||||
|
public ResponseEntity<ProjectDetailResponse> getProjectDetails(@PathVariable UUID id) {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
Project project = projectService.findById(id);
|
||||||
|
|
||||||
|
if (!hasAccess(user, project)) return new ResponseEntity<>(HttpStatus.FORBIDDEN);
|
||||||
|
|
||||||
|
// Тянем конфиг из Mongo для детального отображения
|
||||||
|
ProjectConfig config = configRepository.findByProjectId(String.valueOf(id))
|
||||||
|
.orElseThrow(() -> new RuntimeException("Конфигурация созидания не найдена"));
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new ProjectDetailResponse(project, config));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Создание и обновление ---
|
||||||
|
|
||||||
|
@PostMapping("/user/projects")
|
||||||
|
public ResponseEntity<Project> create(@Valid @RequestBody ProjectCreateRequest request) {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
Project newProject = projectService.createProject(request.name(), request.rawComposeContent(), user);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(newProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/user/projects/{id}/config")
|
||||||
|
public ResponseEntity<String> updateConfig(@PathVariable UUID id, @RequestBody ProjectConfig newConfig) {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
Project project = projectService.findById(id);
|
||||||
|
|
||||||
|
if (!hasAccess(user, project)) return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Передаем весь объект для валидации поддоменов
|
||||||
|
projectService.updateProjectConfig(id, newConfig);
|
||||||
|
return ResponseEntity.ok("Конфигурация обновлена.");
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Управление всем проектом ---
|
||||||
|
|
||||||
|
@PostMapping("/user/projects/{id}/deploy")
|
||||||
|
public ResponseEntity<String> deployProject(@PathVariable UUID id) {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
Project project = projectService.findById(id);
|
||||||
|
|
||||||
|
if (!hasAccess(user, project)) return ResponseEntity.status(403).body("Access Denied");
|
||||||
|
|
||||||
|
projectService.deployProject(project);
|
||||||
|
return ResponseEntity.ok("Процесс созидания запущен...");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/user/projects/{id}/stop")
|
||||||
|
public ResponseEntity<String> stopProject(@PathVariable UUID id) {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
Project project = projectService.findById(id);
|
||||||
|
if (!hasAccess(user, project)) return new ResponseEntity<>(HttpStatus.FORBIDDEN);
|
||||||
|
|
||||||
|
projectService.stopProject(project);
|
||||||
|
return ResponseEntity.ok("Проект остановлен.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/user/projects/{id}")
|
||||||
|
public ResponseEntity<String> deleteProject(@PathVariable UUID id) {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
Project project = projectService.findById(id);
|
||||||
|
if (!hasAccess(user, project)) return ResponseEntity.status(403).body("Access Denied");
|
||||||
|
|
||||||
|
projectService.deleteProject(id);
|
||||||
|
return ResponseEntity.ok("Проект удален.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Управление отдельными контейнерами ---
|
||||||
|
|
||||||
|
@GetMapping("/user/projects/{id}/containers")
|
||||||
|
public ResponseEntity<List<ContainerStatus>> getContainers(@PathVariable UUID id) {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
Project project = projectService.findById(id);
|
||||||
|
if (!hasAccess(user, project)) return ResponseEntity.status(403).build();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(projectService.getProjectContainers(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рестарт конкретного контейнера
|
||||||
|
@PostMapping("/user/projects/{projectId}/containers/{containerId}/restart")
|
||||||
|
public ResponseEntity<Void> restartContainer(
|
||||||
|
@PathVariable("projectId") UUID projectId,
|
||||||
|
@PathVariable("containerId") String containerId) {
|
||||||
|
projectService.restartContainer(projectId, containerId);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Остановка конкретного контейнера
|
||||||
|
@PostMapping("/user/projects/{projectId}/containers/{containerId}/stop")
|
||||||
|
public ResponseEntity<Void> stopContainer(
|
||||||
|
@PathVariable("projectId") UUID projectId,
|
||||||
|
@PathVariable("containerId") String containerId) {
|
||||||
|
projectService.stopContainer(projectId, containerId);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск конкретного контейнера
|
||||||
|
@PostMapping("/user/projects/{projectId}/containers/{containerId}/start")
|
||||||
|
public ResponseEntity<Void> startContainer(
|
||||||
|
@PathVariable("projectId") UUID projectId,
|
||||||
|
@PathVariable("containerId") String containerId) {
|
||||||
|
projectService.startContainer(projectId, containerId);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Утилиты ---
|
||||||
|
|
||||||
|
private boolean hasAccess(User user, Project project) {
|
||||||
|
return checkIfAdmin(user) || String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private User getCurrentUser() {
|
||||||
|
return userRepository.findById(authenticationFacade.getUserDetails().getId())
|
||||||
|
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean checkIfAdmin(User user) {
|
||||||
|
return user.getRoles() != null && user.getRoles().contains("ADMIN");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.jambotronGroup.jambotron.dockerClient;
|
||||||
|
|
||||||
|
import com.github.dockerjava.api.DockerClient;
|
||||||
|
import com.github.dockerjava.core.DefaultDockerClientConfig;
|
||||||
|
import com.github.dockerjava.core.DockerClientConfig;
|
||||||
|
import com.github.dockerjava.core.DockerClientImpl;
|
||||||
|
|
||||||
|
import com.github.dockerjava.transport.DockerHttpClient;
|
||||||
|
import com.github.dockerjava.zerodep.ZerodepDockerHttpClient;
|
||||||
|
import com.jambotronGroup.jambotron.controllers.FileBrowserTreeController;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class DockerConfig {
|
||||||
|
|
||||||
|
private final Logger _logger = LoggerFactory.getLogger(DockerConfig.class);
|
||||||
|
|
||||||
|
@Value("${DOCKER_HOST:${docker.host:unix:///var/run/docker.sock}}")
|
||||||
|
private String dockerHost;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DockerClient dockerClient() {
|
||||||
|
// 1. Четко определяем путь. Если переменная пуста или кривая - ставим дефолт.
|
||||||
|
String hostPath = (dockerHost != null && !dockerHost.contains("2375"))
|
||||||
|
? dockerHost
|
||||||
|
: "unix:///var/run/docker.sock";
|
||||||
|
|
||||||
|
// Создаем URI заранее, чтобы избежать ошибок парсинга в рантайме
|
||||||
|
java.net.URI dockerUri = java.net.URI.create(hostPath);
|
||||||
|
|
||||||
|
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
|
||||||
|
.withDockerHost(hostPath)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Zerodep напрямую работает с сокетом и не переключается на 2375
|
||||||
|
DockerHttpClient httpClient = new ZerodepDockerHttpClient.Builder()
|
||||||
|
.dockerHost(URI.create(dockerHost))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
_logger.info("FINAL ARCHITECTURE: Transport locked to {}", dockerUri);
|
||||||
|
|
||||||
|
return DockerClientImpl.getInstance(config, httpClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ public class GlobalConstants {
|
|||||||
public static Path PATH_ROOT = Path.of("/jambotron_data/");
|
public static Path PATH_ROOT = Path.of("/jambotron_data/");
|
||||||
public static Path PATH_PUBLIC = PATH_ROOT.resolve("public");
|
public static Path PATH_PUBLIC = PATH_ROOT.resolve("public");
|
||||||
public static Path PATH_USERS = PATH_ROOT.resolve("users");
|
public static Path PATH_USERS = PATH_ROOT.resolve("users");
|
||||||
|
public static Path PATH_PROJECTS = PATH_ROOT.resolve("projects"); // Добавили для структуры
|
||||||
public static Path PATH_INIT_DATA = PATH_ROOT.resolve("init_data");
|
public static Path PATH_INIT_DATA = PATH_ROOT.resolve("init_data");
|
||||||
public static Path PATH_JSON_DUMP = PATH_ROOT.resolve("json_dump");
|
public static Path PATH_JSON_DUMP = PATH_ROOT.resolve("json_dump");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.jambotronGroup.jambotron.model;
|
||||||
|
|
||||||
|
public class ContainerStats {
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.jambotronGroup.jambotron.model;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "projects")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class Project {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.EAGER)//, cascade = CascadeType.PERSIST)
|
||||||
|
@JoinColumn(name = "userID", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@Column(name = "local_path")
|
||||||
|
private String localPath; // Путь к папке проекта на диске
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(length = 20)
|
||||||
|
private ProjectStatus status = ProjectStatus.CREATED;
|
||||||
|
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.jambotronGroup.jambotron.model;
|
||||||
|
|
||||||
|
import lombok.*;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Document(collection = "project_configs")
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ProjectConfig {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private String id; // Внутренний ID Mongo
|
||||||
|
|
||||||
|
private String projectId; // UUID из таблицы projects в Postgres
|
||||||
|
|
||||||
|
private String rawComposeContent; // Исходный текст docker-compose.yaml
|
||||||
|
|
||||||
|
private List<String> serviceNames; // Извлеченные имена сервисов для стабильного UI
|
||||||
|
|
||||||
|
private Map<String, String> envVariables; // Переменные окружения
|
||||||
|
|
||||||
|
private Map<String, ServiceSettings> serviceConfigs; // Настройки поддоменов и портов для каждого контейнера
|
||||||
|
|
||||||
|
private Integer version = 1; // Версия конфига
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
// Класс настроек (ServiceSettings.java)
|
||||||
|
public static class ServiceSettings {
|
||||||
|
private String yamlServiceName; // "web-test" или "logger-test"
|
||||||
|
private String subdomain; // "test-web"
|
||||||
|
private Integer port; // 80
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.jambotronGroup.jambotron.model;
|
||||||
|
|
||||||
|
public enum ProjectStatus {
|
||||||
|
CREATED, // Папка создана, конфиги в Mongo есть
|
||||||
|
STARTING, // Процесс docker compose up запущен
|
||||||
|
RUNNING, // Контейнеры успешно поднялись
|
||||||
|
ERROR, // Docker вернул exit code != 0[cite: 3]
|
||||||
|
STOPPING, // Выполняется docker compose down[cite: 3]
|
||||||
|
STOPPED // Проект успешно остановлен
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.jambotronGroup.jambotron.projects;
|
||||||
|
|
||||||
|
import com.jambotronGroup.jambotron.globalConstants.GlobalConstants;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public class ConfigService {
|
||||||
|
|
||||||
|
public void generateEnvFile(UUID projectUuid, Map<String, String> configMap) {
|
||||||
|
// 1. Формируем путь к папке configs через GlobalConstants
|
||||||
|
Path configPath = GlobalConstants.PATH_PROJECTS
|
||||||
|
.resolve(projectUuid.toString())
|
||||||
|
.resolve("configs");
|
||||||
|
|
||||||
|
// 2. Подготавливаем содержимое .env файла
|
||||||
|
String envContent = configMap.entrySet().stream()
|
||||||
|
.map(entry -> entry.getKey() + "=" + entry.getValue())
|
||||||
|
.collect(Collectors.joining("\n"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. Создаем директорию, если её нет
|
||||||
|
Files.createDirectories(configPath);
|
||||||
|
|
||||||
|
// 4. Записываем файл project.env
|
||||||
|
Path envFile = configPath.resolve("project.env");
|
||||||
|
Files.writeString(envFile, envContent);
|
||||||
|
|
||||||
|
System.out.println("Конфиг успешно записан: " + envFile.toAbsolutePath());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Ошибка записи конфига для проекта " + projectUuid, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.jambotronGroup.jambotron.projects;
|
||||||
|
|
||||||
|
public record ContainerStatus(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String image,
|
||||||
|
String state,
|
||||||
|
String status
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.jambotronGroup.jambotron.projects;
|
||||||
|
|
||||||
|
import com.github.dockerjava.api.DockerClient;
|
||||||
|
import com.github.dockerjava.api.async.ResultCallback;
|
||||||
|
import com.github.dockerjava.api.model.CpuStatsConfig;
|
||||||
|
import com.github.dockerjava.api.model.Statistics;
|
||||||
|
import com.jambotronGroup.jambotron.DTOs.ContainerStatsDTO;
|
||||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DockerStatsService {
|
||||||
|
private final DockerClient dockerClient;
|
||||||
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
|
|
||||||
|
public DockerStatsService(DockerClient dockerClient, SimpMessagingTemplate messagingTemplate) {
|
||||||
|
this.dockerClient = dockerClient;
|
||||||
|
this.messagingTemplate = messagingTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void startStreamingStats(String containerId) {
|
||||||
|
dockerClient.statsCmd(containerId).exec(new ResultCallback.Adapter<Statistics>() {
|
||||||
|
@Override
|
||||||
|
public void onNext(Statistics stats) {
|
||||||
|
ContainerStatsDTO dto = calculate(containerId, stats);
|
||||||
|
messagingTemplate.convertAndSend("/topic/stats/" + containerId, dto);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ContainerStatsDTO calculate(String id, Statistics stats) {
|
||||||
|
ContainerStatsDTO dto = new ContainerStatsDTO();
|
||||||
|
dto.setContainerId(id);
|
||||||
|
|
||||||
|
CpuStatsConfig cpu = stats.getCpuStats();
|
||||||
|
CpuStatsConfig preCpu = stats.getPreCpuStats();
|
||||||
|
|
||||||
|
// ЗАЩИТА: Проверяем наличие системных данных, чтобы избежать NullPointerException
|
||||||
|
if (cpu == null || preCpu == null ||
|
||||||
|
cpu.getSystemCpuUsage() == null || preCpu.getSystemCpuUsage() == null) {
|
||||||
|
dto.setCpuUsage(0.0);
|
||||||
|
return fillMemoryStats(dto, stats); // Память обычно доступна сразу
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Считаем дельту использования процессора контейнером
|
||||||
|
long cpuUsageDelta = cpu.getCpuUsage().getTotalUsage() - preCpu.getCpuUsage().getTotalUsage();
|
||||||
|
|
||||||
|
// 2. Считаем дельту системного времени
|
||||||
|
long systemUsageDelta = cpu.getSystemCpuUsage() - preCpu.getSystemCpuUsage();
|
||||||
|
|
||||||
|
// 3. Определяем количество ядер
|
||||||
|
long onlineCpus = 0;
|
||||||
|
if (cpu.getCpuUsage().getPercpuUsage() != null) {
|
||||||
|
onlineCpus = cpu.getCpuUsage().getPercpuUsage().size();
|
||||||
|
}
|
||||||
|
if (onlineCpus == 0) {
|
||||||
|
onlineCpus = Runtime.getRuntime().availableProcessors();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Расчет процента с защитой от деления на ноль
|
||||||
|
if (systemUsageDelta > 0 && cpuUsageDelta > 0) {
|
||||||
|
double cpuPercent = ((double) cpuUsageDelta / systemUsageDelta) * onlineCpus * 100.0;
|
||||||
|
dto.setCpuUsage(cpuPercent);
|
||||||
|
} else {
|
||||||
|
dto.setCpuUsage(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fillMemoryStats(dto, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выносим расчет памяти для чистоты кода
|
||||||
|
private ContainerStatsDTO fillMemoryStats(ContainerStatsDTO dto, Statistics stats) {
|
||||||
|
if (stats.getMemoryStats() != null) {
|
||||||
|
long usage = stats.getMemoryStats().getUsage() != null ? stats.getMemoryStats().getUsage() : 0L;
|
||||||
|
long limit = stats.getMemoryStats().getLimit() != null ? stats.getMemoryStats().getLimit() : 0L;
|
||||||
|
|
||||||
|
dto.setMemoryUsage(usage);
|
||||||
|
dto.setMemoryLimit(limit);
|
||||||
|
|
||||||
|
if (limit > 0) {
|
||||||
|
dto.setMemoryPercent((double) usage / limit * 100.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.jambotronGroup.jambotron.projects;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(ProjectAlreadyExistsException.class)
|
||||||
|
public ResponseEntity<String> handleConflict(ProjectAlreadyExistsException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
|
||||||
|
Map<String, String> errors = new HashMap<>();
|
||||||
|
ex.getBindingResult().getFieldErrors().forEach(error ->
|
||||||
|
errors.put(error.getField(), error.getDefaultMessage()));
|
||||||
|
return ResponseEntity.badRequest().body(errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.jambotronGroup.jambotron.projects;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
|
||||||
|
// Аннотация гарантирует, что если исключение не перехвачено Handler-ом,
|
||||||
|
// Spring всё равно вернет 409 статус.
|
||||||
|
@ResponseStatus(HttpStatus.CONFLICT)
|
||||||
|
public class ProjectAlreadyExistsException extends RuntimeException {
|
||||||
|
public ProjectAlreadyExistsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.jambotronGroup.jambotron.projects;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record ProjectCreateRequest(
|
||||||
|
@NotBlank(message = "Имя проекта не может быть пустым")
|
||||||
|
@Size(min = 3, max = 50, message = "Имя проекта должно быть от 3 до 50 символов")
|
||||||
|
String name,
|
||||||
|
|
||||||
|
@NotBlank(message = " not null")
|
||||||
|
String rawComposeContent
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.jambotronGroup.jambotron.projects;
|
||||||
|
|
||||||
|
import com.jambotronGroup.jambotron.model.Project;
|
||||||
|
import com.jambotronGroup.jambotron.model.ProjectConfig;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ProjectDetailResponse {
|
||||||
|
private Project project;
|
||||||
|
private ConfigDTO config;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class ConfigDTO {
|
||||||
|
private String projectId;
|
||||||
|
private String rawComposeContent;
|
||||||
|
private List<String> serviceNames; // Список сервисов для UI
|
||||||
|
private Map<String, ProjectConfig.ServiceSettings> serviceConfigs; // Поддомены и порты
|
||||||
|
private Map<String, String> envVariables;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProjectDetailResponse(Project project, ProjectConfig config) {
|
||||||
|
this.project = project;
|
||||||
|
this.config = new ConfigDTO(
|
||||||
|
config.getProjectId(),
|
||||||
|
config.getRawComposeContent(),
|
||||||
|
config.getServiceNames(), // Передаем извлеченные имена
|
||||||
|
config.getServiceConfigs(),
|
||||||
|
config.getEnvVariables()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
package com.jambotronGroup.jambotron.projects;
|
||||||
|
|
||||||
|
import com.jambotronGroup.jambotron.globalConstants.GlobalConstants;
|
||||||
|
import com.jambotronGroup.jambotron.model.*;
|
||||||
|
import com.jambotronGroup.jambotron.repository.ProjectConfigRepository;
|
||||||
|
import com.jambotronGroup.jambotron.repository.ProjectRepository;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.yaml.snakeyaml.DumperOptions;
|
||||||
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProjectService {
|
||||||
|
|
||||||
|
private final ProjectRepository projectRepository;
|
||||||
|
private final ProjectConfigRepository configRepository;
|
||||||
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
|
|
||||||
|
private static final Logger _logger = LoggerFactory.getLogger(ProjectService.class);
|
||||||
|
private static final DateTimeFormatter L_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
// --- Вспомогательные методы логирования ---
|
||||||
|
|
||||||
|
private void logToConsole(UUID projectId, String message) {
|
||||||
|
String timestamp = LocalDateTime.now().format(L_TIME_FORMATTER);
|
||||||
|
String formattedMessage = String.format("[%s] %s", timestamp, message);
|
||||||
|
_logger.info("[Project-{}]: {}", projectId, message);
|
||||||
|
messagingTemplate.convertAndSend("/topic/deploy/" + projectId, formattedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Базовые операции CRUD ---
|
||||||
|
|
||||||
|
public Project save(Project project) {
|
||||||
|
return projectRepository.save(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Project findById(UUID id) {
|
||||||
|
return projectRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Project not found with id: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Project> findAllByOwnerId(Long ownerId) {
|
||||||
|
return projectRepository.findAllByUserId(ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Управление конфигурацией и создание ---
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Project createProject(String projectName, String rawComposeContent, User owner) {
|
||||||
|
if (projectRepository.existsByNameAndUserId(projectName, owner.getId())) {
|
||||||
|
throw new RuntimeException("Проект с таким именем уже существует");
|
||||||
|
}
|
||||||
|
|
||||||
|
Project project = new Project();
|
||||||
|
project.setName(projectName);
|
||||||
|
project.setUser(owner);
|
||||||
|
project.setStatus(ProjectStatus.CREATED);
|
||||||
|
project = projectRepository.save(project);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path projectDir = GlobalConstants.PATH_PROJECTS.resolve(project.getId().toString());
|
||||||
|
Files.createDirectories(projectDir);
|
||||||
|
project.setLocalPath(projectDir.toString());
|
||||||
|
|
||||||
|
ProjectConfig config = ProjectConfig.builder()
|
||||||
|
.projectId(String.valueOf(project.getId()))
|
||||||
|
.rawComposeContent(rawComposeContent)
|
||||||
|
.serviceNames(extractServiceNames(rawComposeContent))
|
||||||
|
.build();
|
||||||
|
configRepository.save(config);
|
||||||
|
|
||||||
|
return projectRepository.save(project);
|
||||||
|
} catch (IOException e) {
|
||||||
|
_logger.error("Ошибка при создании директории проекта", e);
|
||||||
|
throw new RuntimeException("Ошибка инициализации хранилища");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateProjectConfig(UUID projectId, ProjectConfig newConfig) {
|
||||||
|
// 1. Поиск существующего проекта в Postgres
|
||||||
|
Project project = projectRepository.findById(projectId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Проект не найден с ID: " + projectId));
|
||||||
|
|
||||||
|
// Находим или инициализируем корневую конфигурацию в MongoDB (ключ — String)
|
||||||
|
ProjectConfig existing = configRepository.findByProjectId(projectId.toString())
|
||||||
|
.orElseGet(() -> {
|
||||||
|
ProjectConfig c = new ProjectConfig();
|
||||||
|
c.setProjectId(projectId.toString());
|
||||||
|
c.setServiceConfigs(new HashMap<>());
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ПЕРВОПРИЧИНА ИСПРАВЛЕНА: Берем исходный YAML текст из прилетевшего конфига (newConfig)
|
||||||
|
String rawYaml = newConfig.getRawComposeContent();
|
||||||
|
|
||||||
|
// 2. Парсинг актуальных имен сервисов из docker-compose.yml
|
||||||
|
List<String> actualYamlNames = new ArrayList<>();
|
||||||
|
if (rawYaml != null && !rawYaml.isBlank()) {
|
||||||
|
try {
|
||||||
|
Yaml yaml = new Yaml();
|
||||||
|
Map<String, Object> obj = yaml.load(rawYaml);
|
||||||
|
if (obj != null && obj.get("services") instanceof Map) {
|
||||||
|
Map<?, ?> servicesMap = (Map<?, ?>) obj.get("services");
|
||||||
|
for (Object key : servicesMap.keySet()) {
|
||||||
|
actualYamlNames.add(String.valueOf(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
_logger.error("Ошибка парсинга docker-compose.yml для извлечения имен сервисов в проекте {}", projectId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Умное сопоставление (маппинг) Service ID
|
||||||
|
Map<String, ProjectConfig.ServiceSettings> incomingConfigs = newConfig.getServiceConfigs();
|
||||||
|
Map<String, ProjectConfig.ServiceSettings> existingConfigs = existing.getServiceConfigs();
|
||||||
|
|
||||||
|
if (incomingConfigs == null) incomingConfigs = new HashMap<>();
|
||||||
|
if (existingConfigs == null) existingConfigs = new HashMap<>();
|
||||||
|
|
||||||
|
Map<String, ProjectConfig.ServiceSettings> alignedConfigs = new HashMap<>();
|
||||||
|
|
||||||
|
// Итерируемся по реальным сервисам из файла конфигурации
|
||||||
|
for (int i = 0; i < actualYamlNames.size(); i++) {
|
||||||
|
String yamlName = actualYamlNames.get(i);
|
||||||
|
final int currentIndex = i;
|
||||||
|
|
||||||
|
// Определяем стабильный системный ID (например, srv-0) на основе старой базы
|
||||||
|
String targetServiceId = existingConfigs.entrySet().stream()
|
||||||
|
.filter(e -> yamlName.equals(e.getValue().getYamlServiceName()))
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (targetServiceId == null) {
|
||||||
|
// Если в базе еще нет, проверяем, вдруг фронт привязал к srv-X
|
||||||
|
targetServiceId = incomingConfigs.entrySet().stream()
|
||||||
|
.filter(e -> yamlName.equals(e.getValue().getYamlServiceName()))
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.findFirst()
|
||||||
|
.orElse("srv-" + currentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ИЩЕМ ИСТОЧНИК ДАННЫХ (Где лежат введенные пользователем порт и поддомен)
|
||||||
|
ProjectConfig.ServiceSettings userUiSettings = null;
|
||||||
|
|
||||||
|
// Стратегия А: Ищем по стабильному ID (srv-0)
|
||||||
|
ProjectConfig.ServiceSettings settingsBySubId = incomingConfigs.get(targetServiceId);
|
||||||
|
if (settingsBySubId != null && (settingsBySubId.getSubdomain() != null || settingsBySubId.getPort() != null)) {
|
||||||
|
userUiSettings = settingsBySubId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стратегия Б: Ищем по полному YAML-имени как по ключу мапы (например, "web-test")
|
||||||
|
if (userUiSettings == null) {
|
||||||
|
ProjectConfig.ServiceSettings settingsByFullName = incomingConfigs.get(yamlName);
|
||||||
|
if (settingsByFullName != null && (settingsByFullName.getSubdomain() != null || settingsByFullName.getPort() != null)) {
|
||||||
|
userUiSettings = settingsByFullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стратегия В (Решение для image_bb4570): Ищем частичное совпадение ключей мапы (фронт прислал ключ "web" для сервиса "web-test")
|
||||||
|
if (userUiSettings == null) {
|
||||||
|
userUiSettings = incomingConfigs.entrySet().stream()
|
||||||
|
.filter(entry -> yamlName.startsWith(entry.getKey()) || entry.getKey().startsWith(yamlName))
|
||||||
|
.map(Map.Entry::getValue)
|
||||||
|
.filter(s -> s.getSubdomain() != null || s.getPort() != null)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стратегия Г: Ищем по внутреннему полю yamlServiceName, если оно заполнено
|
||||||
|
if (userUiSettings == null) {
|
||||||
|
userUiSettings = incomingConfigs.values().stream()
|
||||||
|
.filter(s -> yamlName.equals(s.getYamlServiceName()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем или извлекаем целевой объект настроек для базы
|
||||||
|
ProjectConfig.ServiceSettings finalSettings = existingConfigs.get(targetServiceId);
|
||||||
|
if (finalSettings == null) {
|
||||||
|
finalSettings = new ProjectConfig.ServiceSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// НАКАТЫВАЕМ ДАННЫЕ: Если нашли донора из UI, забираем его конфигурацию порта и домена
|
||||||
|
if (userUiSettings != null) {
|
||||||
|
finalSettings.setSubdomain(userUiSettings.getSubdomain());
|
||||||
|
finalSettings.setPort(userUiSettings.getPort());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Жестко гарантируем железную привязку к реальному имени контейнера
|
||||||
|
finalSettings.setYamlServiceName(yamlName);
|
||||||
|
alignedConfigs.put(targetServiceId, finalSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем выровненную конфигурацию и метаданные в документ Mongo
|
||||||
|
existing.setServiceConfigs(alignedConfigs);
|
||||||
|
existing.setRawComposeContent(rawYaml);
|
||||||
|
existing.setServiceNames(actualYamlNames);
|
||||||
|
|
||||||
|
// Валидируем поддомены перед записью в репозиторий
|
||||||
|
validateSubdomains(existing);
|
||||||
|
|
||||||
|
// 4. Сохранение обновленного монолита в MongoDB
|
||||||
|
configRepository.save(existing);
|
||||||
|
_logger.info("[Jambotron] Конфигурация шлюза для проекта {} успешно синхронизирована.", projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateSubdomains(ProjectConfig config) {
|
||||||
|
if (config.getServiceConfigs() == null) return;
|
||||||
|
for (var entry : config.getServiceConfigs().values()) {
|
||||||
|
String sub = entry.getSubdomain();
|
||||||
|
if (sub != null && !sub.isEmpty()) {
|
||||||
|
if (!sub.matches("^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")) {
|
||||||
|
throw new RuntimeException("Некорректный формат поддомена (только латиница, цифры и дефис): " + sub);
|
||||||
|
}
|
||||||
|
// Проверка на занятость поддомена другими проектами
|
||||||
|
List<ProjectConfig> conflicts = configRepository.findAllByAnyServiceSubdomain(sub);
|
||||||
|
if (conflicts.stream().anyMatch(c -> !c.getProjectId().equals(config.getProjectId()))) {
|
||||||
|
throw new RuntimeException("Поддомен '" + sub + "' уже занят!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> extractServiceNames(String yamlContent) {
|
||||||
|
try {
|
||||||
|
Yaml yaml = new Yaml();
|
||||||
|
Map<String, Object> obj = yaml.load(yamlContent);
|
||||||
|
Map<String, Object> services = (Map<String, Object>) obj.get("services");
|
||||||
|
return services != null ? new ArrayList<>(services.keySet()) : List.of();
|
||||||
|
} catch (Exception e) { return List.of(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Жизненный цикл (Deploy, Stop, Delete) ---
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deployProject(Project project) {
|
||||||
|
UUID projectId = project.getId();
|
||||||
|
String projectIdStr = String.valueOf(projectId);
|
||||||
|
Path projectPath = Paths.get(project.getLocalPath());
|
||||||
|
|
||||||
|
logToConsole(projectId, "Инициация созидания окружения...");
|
||||||
|
updateStatusInNewTransaction(projectId, ProjectStatus.STARTING);
|
||||||
|
|
||||||
|
// 1. Извлекаем доменные настройки из MongoDB
|
||||||
|
ProjectConfig config = configRepository.findByProjectId(projectIdStr).orElse(null);
|
||||||
|
|
||||||
|
// 2. Внедряем метки Caddy в пользовательский docker-compose.yml
|
||||||
|
assert config != null;
|
||||||
|
String finalComposeContent = injectCaddyLabels(config.getRawComposeContent(), config);
|
||||||
|
|
||||||
|
logToConsole(projectId, finalComposeContent);
|
||||||
|
try {
|
||||||
|
Files.createDirectories(projectPath);
|
||||||
|
|
||||||
|
// Записываем модифицированный файл со всеми доменами
|
||||||
|
Files.writeString(projectPath.resolve("docker-compose.yml"), finalComposeContent);
|
||||||
|
|
||||||
|
logToConsole(projectId, "Конфигурация Caddy подготовлена. Запуск контейнеров...");
|
||||||
|
|
||||||
|
// Добавляем --force-recreate, чтобы Docker гарантированно применил новые labels!
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d", "--force-recreate", "--remove-orphans");
|
||||||
|
pb.directory(projectPath.toFile());
|
||||||
|
Process process = pb.start();
|
||||||
|
|
||||||
|
readProcessOutputAsync(projectIdStr, process);
|
||||||
|
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
if (exitCode == 0) {
|
||||||
|
project.setStatus(ProjectStatus.RUNNING);
|
||||||
|
logToConsole(projectId, "Все службы успешно запущены и подключены к шлюзу Caddy.");
|
||||||
|
} else {
|
||||||
|
project.setStatus(ProjectStatus.ERROR);
|
||||||
|
logToConsole(projectId, "Ошибка сборки стека. Код возврата: " + exitCode);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
project.setStatus(ProjectStatus.ERROR);
|
||||||
|
logToConsole(projectId, "Критическая ошибка: " + e.getMessage());
|
||||||
|
}
|
||||||
|
projectRepository.save(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обогащает исходный docker-compose.yml метками для Caddy reverse proxy
|
||||||
|
* на основе конфигурации проекта из MongoDB.
|
||||||
|
*/
|
||||||
|
public String injectCaddyLabels(String rawComposeContent, ProjectConfig config) {
|
||||||
|
if (config == null || config.getServiceConfigs() == null || config.getServiceConfigs().isEmpty()) {
|
||||||
|
return rawComposeContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Yaml yaml = new Yaml();
|
||||||
|
Map<String, Object> composeMap = yaml.load(rawComposeContent);
|
||||||
|
|
||||||
|
Map<String, Object> services = (Map<String, Object>) composeMap.get("services");
|
||||||
|
if (services == null) {
|
||||||
|
return rawComposeContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перебираем конфигурации сервисов, сохраненные в MongoDB
|
||||||
|
config.getServiceConfigs().forEach((serviceKey, serviceSetting) -> {
|
||||||
|
|
||||||
|
// Умный поиск: ищем ключ в docker-compose.yml
|
||||||
|
String actualComposeKey = null;
|
||||||
|
|
||||||
|
// 1. Проверяем, может serviceKey — это прямое имя из YAML (web-test)
|
||||||
|
if (services.containsKey(serviceKey)) {
|
||||||
|
actualComposeKey = serviceKey;
|
||||||
|
}
|
||||||
|
// 2. Или может это сохраненный yamlServiceName внутри настроек?
|
||||||
|
else if (serviceSetting.getYamlServiceName() != null && services.containsKey(serviceSetting.getYamlServiceName())) {
|
||||||
|
actualComposeKey = serviceSetting.getYamlServiceName();
|
||||||
|
}
|
||||||
|
// 3. Резервный вариант: если прилетело урезанное "web", ищем частичное совпадение в YAML ("web-test" стартует с "web")
|
||||||
|
else {
|
||||||
|
actualComposeKey = services.keySet().stream()
|
||||||
|
.filter(k -> k.startsWith(serviceKey) || serviceKey.startsWith(k))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если нашли точку соприкосновения и задан поддомен — инжектим метки Caddy
|
||||||
|
if (actualComposeKey != null && serviceSetting.getSubdomain() != null && !serviceSetting.getSubdomain().isEmpty()) {
|
||||||
|
Map<String, Object> service = (Map<String, Object>) services.get(actualComposeKey);
|
||||||
|
|
||||||
|
Map<String, String> labels = (Map<String, String>) service.get("labels");
|
||||||
|
if (labels == null) {
|
||||||
|
labels = new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
String domain = serviceSetting.getSubdomain() + ".jambotron.com";
|
||||||
|
String internalPort = serviceSetting.getPort() != null ? serviceSetting.getPort().toString() : "80";
|
||||||
|
|
||||||
|
labels.put("caddy", domain);
|
||||||
|
labels.put("caddy.reverse_proxy", "{{upstreams " + internalPort + "}}");
|
||||||
|
|
||||||
|
service.put("labels", labels);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
DumperOptions options = new DumperOptions();
|
||||||
|
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
|
||||||
|
Yaml dumper = new Yaml(options);
|
||||||
|
|
||||||
|
return dumper.dump(composeMap);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
_logger.error("Ошибка при модификации YAML структуры для Caddy", e);
|
||||||
|
return rawComposeContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prepareEnhancedFiles(Project project, Path projectPath) throws IOException {
|
||||||
|
ProjectConfig config = configRepository.findByProjectId(String.valueOf(project.getId()))
|
||||||
|
.orElseThrow(() -> new RuntimeException("Config not found"));
|
||||||
|
|
||||||
|
if (!Files.exists(projectPath)) Files.createDirectories(projectPath);
|
||||||
|
|
||||||
|
Yaml yaml = new Yaml(getDumperOptions());
|
||||||
|
Map<String, Object> compose = yaml.load(config.getRawComposeContent());
|
||||||
|
Map<String, Object> services = (Map<String, Object>) compose.get("services");
|
||||||
|
|
||||||
|
if (services != null) {
|
||||||
|
services.forEach((name, obj) -> {
|
||||||
|
Map<String, Object> service = (Map<String, Object>) obj;
|
||||||
|
// Уникальное имя контейнера для изоляции
|
||||||
|
service.put("container_name", "jambotron-" + project.getId() + "-" + name);
|
||||||
|
|
||||||
|
// Инъекция меток Caddy для автоматического SSL и проксирования
|
||||||
|
if (config.getServiceConfigs() != null && config.getServiceConfigs().containsKey(name)) {
|
||||||
|
var settings = config.getServiceConfigs().get(name);
|
||||||
|
if (settings.getSubdomain() != null && !settings.getSubdomain().isEmpty()) {
|
||||||
|
Map<String, String> labels = (Map<String, String>) service.getOrDefault("labels", new HashMap<String, String>());
|
||||||
|
labels.put("caddy", settings.getSubdomain() + ".jambotron.com");
|
||||||
|
labels.put("caddy.reverse_proxy", "{{upstreams " + settings.getPort() + "}}");
|
||||||
|
service.put("labels", labels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Files.writeString(projectPath.resolve("docker-compose.yml"), yaml.dump(compose));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DumperOptions getDumperOptions() {
|
||||||
|
DumperOptions options = new DumperOptions();
|
||||||
|
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
|
||||||
|
options.setPrettyFlow(true);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int runComposeUp(Path projectPath, UUID projectId) {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d", "--build");
|
||||||
|
pb.directory(projectPath.toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
try {
|
||||||
|
Process process = pb.start();
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
messagingTemplate.convertAndSend("/topic/deploy/" + projectId, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return process.waitFor();
|
||||||
|
} catch (Exception e) { return -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void stopProject(Project project) {
|
||||||
|
UUID projectId = project.getId();
|
||||||
|
logToConsole(projectId, "Остановка служб...");
|
||||||
|
try {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down");
|
||||||
|
pb.directory(Paths.get(project.getLocalPath()).toFile());
|
||||||
|
if (pb.start().waitFor() == 0) {
|
||||||
|
project.setStatus(ProjectStatus.STOPPED);
|
||||||
|
logToConsole(projectId, "Службы остановлены.");
|
||||||
|
}
|
||||||
|
} catch (Exception e) { logToConsole(projectId, "Ошибка: " + e.getMessage()); }
|
||||||
|
projectRepository.save(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteProject(UUID projectId) {
|
||||||
|
Project project = findById(projectId);
|
||||||
|
Path projectPath = Paths.get(project.getLocalPath());
|
||||||
|
logToConsole(projectId, "ПОЛНОЕ УДАЛЕНИЕ ресурсов...");
|
||||||
|
try {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down", "-v", "--rmi", "all");
|
||||||
|
pb.directory(projectPath.toFile());
|
||||||
|
pb.start().waitFor();
|
||||||
|
|
||||||
|
if (Files.exists(projectPath)) {
|
||||||
|
try (var stream = Files.walk(projectPath)) {
|
||||||
|
stream.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(java.io.File::delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configRepository.deleteByProjectId(String.valueOf(projectId));
|
||||||
|
projectRepository.delete(project);
|
||||||
|
logToConsole(projectId, "Проект удален.");
|
||||||
|
} catch (Exception e) { logToConsole(projectId, "Ошибка: " + e.getMessage()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateStatusInNewTransaction(UUID projectId, ProjectStatus status) {
|
||||||
|
Project project = findById(projectId);
|
||||||
|
project.setStatus(status);
|
||||||
|
projectRepository.save(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение актуального списка ВСЕХ контейнеров для конкретного проекта.
|
||||||
|
* Добавлен флаг --all, чтобы показывать даже выключенные службы.
|
||||||
|
*/
|
||||||
|
public List<ContainerStatus> getProjectContainers(UUID projectId) {
|
||||||
|
Project project = findById(projectId);
|
||||||
|
List<ContainerStatus> statuses = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Флаг --all заставляет docker compose вернуть информацию обо всех контейнерах стека
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "ps", "--all", "--format", "json");
|
||||||
|
pb.directory(Paths.get(project.getLocalPath()).toFile());
|
||||||
|
Process process = pb.start();
|
||||||
|
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
if (line.trim().isEmpty()) continue;
|
||||||
|
|
||||||
|
// Парсим строку вывода и собираем неизменяемый record
|
||||||
|
statuses.add(new ContainerStatus(
|
||||||
|
extractValue(line, "ID"),
|
||||||
|
extractValue(line, "Name"),
|
||||||
|
extractValue(line, "Image"),
|
||||||
|
extractValue(line, "State"),
|
||||||
|
extractValue(line, "Status")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.waitFor();
|
||||||
|
} catch (Exception e) {
|
||||||
|
_logger.error("Ошибка при получении статусов всех контейнеров для проекта {}", projectId, e);
|
||||||
|
}
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перезапуск конкретного контейнера внутри проекта.
|
||||||
|
* Выполняет команду: docker compose restart <containerId>
|
||||||
|
*/
|
||||||
|
public void restartContainer(UUID projectId, String containerId) {
|
||||||
|
executeContainerAction(projectId, containerId, "restart", "перезапущен");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Остановка конкретного контейнера внутри проекта.
|
||||||
|
* Выполняет команду: docker compose stop <containerId>
|
||||||
|
*/
|
||||||
|
public void stopContainer(UUID projectId, String containerId) {
|
||||||
|
executeContainerAction(projectId, containerId, "stop", "остановлен");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запуск конкретного контейнера внутри проекта.
|
||||||
|
* Выполняет команду: docker compose start <containerId>
|
||||||
|
*/
|
||||||
|
public void startContainer(UUID projectId, String containerId) {
|
||||||
|
executeContainerAction(projectId, containerId, "start", "запущен");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Унифицированный приватный метод для выполнения команд над контейнерами Docker Compose.
|
||||||
|
*/
|
||||||
|
private void executeContainerAction(UUID projectId, String containerId, String action, String actionPastTense) {
|
||||||
|
String pIdStr = String.valueOf(projectId);
|
||||||
|
|
||||||
|
logToConsole(projectId, "Инициация операции [" + action + "] для контейнера ID: " + containerId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Вызываем напрямую docker stop/start/restart <id> без compose
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("docker", action, containerId);
|
||||||
|
|
||||||
|
Process process = pb.start();
|
||||||
|
readProcessOutputAsync(pIdStr, process);
|
||||||
|
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
if (exitCode == 0) {
|
||||||
|
logToConsole(projectId, "Контейнер " + containerId + " успешно " + actionPastTense + ".");
|
||||||
|
} else {
|
||||||
|
logToConsole(projectId, "Ошибка операции [" + action + "]. Код возврата: " + exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (IOException | InterruptedException e) {
|
||||||
|
_logger.error("Исключение при операции {} над контейнером {} в проекте {}", action, containerId, projectId, e);
|
||||||
|
logToConsole(projectId, "Критическая ошибка созидания: " + e.getMessage());
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractValue(String line, String key) {
|
||||||
|
try {
|
||||||
|
String search = "\"" + key + "\":\"";
|
||||||
|
int start = line.indexOf(search);
|
||||||
|
if (start == -1) return "unknown";
|
||||||
|
start += search.length();
|
||||||
|
int end = line.indexOf("\"", start);
|
||||||
|
return line.substring(start, end);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readProcessOutputAsync(String projectIdStr, Process process) {
|
||||||
|
new Thread(() -> {
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
logToConsole(UUID.fromString(projectIdStr), line);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
_logger.error("Ошибка асинхронного чтения логов процесса для проекта {}", projectIdStr, e);
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.jambotronGroup.jambotron.repository;
|
||||||
|
|
||||||
|
import com.jambotronGroup.jambotron.model.ProjectConfig;
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
import org.springframework.data.mongodb.repository.Query;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ProjectConfigRepository extends MongoRepository<ProjectConfig, String> {
|
||||||
|
|
||||||
|
// Достаем конфиг по ID проекта из Postgres
|
||||||
|
Optional<ProjectConfig> findByProjectId(String projectId);
|
||||||
|
|
||||||
|
// Удаление конфига при удалении проекта
|
||||||
|
void deleteByProjectId(String projectId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поиск всех конфигураций, где хотя бы у одного сервиса в Map совпадает subdomain.
|
||||||
|
* Используем dot-notation для поиска по значениям в Map (serviceConfigs.values.subdomain).
|
||||||
|
*/
|
||||||
|
@Query("{ 'serviceConfigs.values.subdomain': ?0 }")
|
||||||
|
List<ProjectConfig> findAllByAnyServiceSubdomain(String subdomain);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.jambotronGroup.jambotron.repository;
|
||||||
|
|
||||||
|
import com.jambotronGroup.jambotron.model.Project;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ProjectRepository extends JpaRepository<Project, UUID> {
|
||||||
|
|
||||||
|
// Поиск всех проектов конкретного пользователя
|
||||||
|
List<Project> findAllByUserId(Long userId);
|
||||||
|
|
||||||
|
// Поиск по имени (например, для проверки уникальности внутри аккаунта)
|
||||||
|
boolean existsByNameAndUserId(String name, Long userId);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -117,6 +117,14 @@ public class WebSecurityConfig implements WebMvcConfigurer {// extends WebSecuri
|
|||||||
.requestMatchers("/api/public/zhipuai/image/**").permitAll()
|
.requestMatchers("/api/public/zhipuai/image/**").permitAll()
|
||||||
|
|
||||||
|
|
||||||
|
.requestMatchers("/api/ws-jambotron/**").permitAll()
|
||||||
|
|
||||||
|
.requestMatchers("/api/user/projects").permitAll()
|
||||||
|
.requestMatchers("/api/user/projects/**").permitAll()
|
||||||
|
|
||||||
|
.requestMatchers("/api/admin/projects").permitAll()
|
||||||
|
.requestMatchers("/api/admin/projects/**").permitAll()
|
||||||
|
|
||||||
// Access permitted for specific roles
|
// Access permitted for specific roles
|
||||||
.requestMatchers("/api/user/**").hasRole("USER")
|
.requestMatchers("/api/user/**").hasRole("USER")
|
||||||
.requestMatchers("/api/user/file-browser-tree/**").hasRole("USER")
|
.requestMatchers("/api/user/file-browser-tree/**").hasRole("USER")
|
||||||
@@ -134,10 +142,10 @@ public class WebSecurityConfig implements WebMvcConfigurer {// extends WebSecuri
|
|||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if(_environment.getProperty("server.port") != null &&
|
// if(_environment.getProperty("server.port") != null &&
|
||||||
_environment.getProperty("server.port").equalsIgnoreCase("443")) {
|
// _environment.getProperty("server.port").equalsIgnoreCase("443")) {
|
||||||
http.redirectToHttps(withDefaults());
|
// http.redirectToHttps(withDefaults());
|
||||||
}
|
// }
|
||||||
|
|
||||||
http.authenticationProvider(authenticationProvider());
|
http.authenticationProvider(authenticationProvider());
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ spring.application.name=jambotron
|
|||||||
server.port=8082
|
server.port=8082
|
||||||
#============Localhost Configurations========================
|
#============Localhost Configurations========================
|
||||||
|
|
||||||
spring.datasource.url= jdbc:postgresql://localhost:5433/jambotronDB?options=-c%20timezone=UTC
|
spring.datasource.url= jdbc:postgresql://localhost:5432/jambotron_dev?options=-c%20timezone=UTC
|
||||||
spring.datasource.username= admin
|
spring.datasource.username= jambotron_admin
|
||||||
spring.datasource.password= postgrespw
|
spring.datasource.password= jambo_secret_pass
|
||||||
|
|
||||||
spring.flyway.baseline-on-migrate=true
|
spring.flyway.baseline-on-migrate=true
|
||||||
spring.flyway.validate-on-migrate=true
|
spring.flyway.validate-on-migrate=true
|
||||||
|
|
||||||
spring.flyway.url=jdbc:postgresql://localhost:5433/jambotronDB?options=-c%20timezone=UTC
|
spring.flyway.url=jdbc:postgresql://localhost:5432/jambotron_dev?options=-c%20timezone=UTC
|
||||||
spring.flyway.user=admin
|
spring.flyway.user=jambotron_admin
|
||||||
spring.flyway.password=postgrespw
|
spring.flyway.password=jambo_secret_pass
|
||||||
|
|
||||||
|
# ??? ???? ?????? Spring ?? ??????? mongoTemplate ?????????????
|
||||||
|
spring.data.mongodb.uri=mongodb://root:password@localhost:27017/jambotron?authSource=admin
|
||||||
|
|
||||||
#spring.datasource.url= jdbc:postgresql://localhost:5433/db_for_import
|
#spring.datasource.url= jdbc:postgresql://localhost:5433/db_for_import
|
||||||
#spring.datasource.username= admin
|
#spring.datasource.username= admin
|
||||||
@@ -57,4 +60,7 @@ spring.servlet.multipart.max-request-size=50MB
|
|||||||
|
|
||||||
|
|
||||||
server.tomcat.max-swallow-size=100MB
|
server.tomcat.max-swallow-size=100MB
|
||||||
#=============
|
#=============
|
||||||
|
|
||||||
|
|
||||||
|
docker.host=npipe:////./pipe/docker_engine
|
||||||
@@ -23,6 +23,9 @@ spring.flyway.url=${SPRING_FLYWAY_URL}
|
|||||||
spring.flyway.user=${SPRING_FLYWAY_USER}
|
spring.flyway.user=${SPRING_FLYWAY_USER}
|
||||||
spring.flyway.password=${SPRING_FLYWAY_PASSWORD}
|
spring.flyway.password=${SPRING_FLYWAY_PASSWORD}
|
||||||
|
|
||||||
|
# ??? ???? ?????? Spring ?? ??????? mongoTemplate ?????????????
|
||||||
|
spring.data.mongodb.uri=mongodb://root:password@localhost:27017/jambotron?authSource=admin
|
||||||
|
|
||||||
#============Koyeb Configurations========================
|
#============Koyeb Configurations========================
|
||||||
|
|
||||||
#spring.datasource.url= jdbc:postgresql://ep-red-breeze-a2cxhq5f.eu-central-1.pg.koyeb.app/jambotronDB
|
#spring.datasource.url= jdbc:postgresql://ep-red-breeze-a2cxhq5f.eu-central-1.pg.koyeb.app/jambotronDB
|
||||||
@@ -71,3 +74,4 @@ spring.servlet.multipart.max-request-size=50MB
|
|||||||
server.tomcat.max-swallow-size=100MB
|
server.tomcat.max-swallow-size=100MB
|
||||||
|
|
||||||
#=============
|
#=============
|
||||||
|
#docker.host=unix:///var/run/docker.sock
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE projects (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
userID integer NOT NULL,
|
||||||
|
local_path TEXT, -- Путь к папке проекта
|
||||||
|
status VARCHAR(20), -- CREATED, RUNNING, ERROR
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user