31 Commits
Author SHA1 Message Date
Liosha 3934a63270 adjast wss + docker logs 2026-05-11 17:28:40 +03:00
Liosha 1d0ff13b62 adjast wss + docker logs 2026-05-11 16:24:47 +03:00
Liosha a94acd0c64 adjast wss + docker logs 2026-05-11 16:11:38 +03:00
Liosha 0943517bc9 adjast wss + docker logs 2026-05-11 14:23:13 +03:00
Liosha 20e862c50d adjast wss + docker logs 2026-05-11 14:22:54 +03:00
Liosha 4d574d5fdf adjast wss + docker logs 2026-05-11 13:10:00 +03:00
Liosha 724b74b3c8 adjast wss 2026-05-08 21:14:23 +03:00
Liosha e8dc0cdb39 adjast wss 2026-05-08 19:41:23 +03:00
Liosha 8459b2e58e adjast wss 2026-05-07 22:43:07 +03:00
Liosha 19d1e1f034 adjast wss 2026-05-07 22:28:17 +03:00
Liosha 5193a2859b adjast wss 2026-05-07 22:19:35 +03:00
Liosha 2042d8118d adjast wss 2026-05-07 22:17:49 +03:00
Liosha 2c5c1fb675 remow starship dns 2026-05-07 21:20:05 +03:00
Liosha 882801ef17 spaceship 2026-05-07 12:17:15 +03:00
Liosha 85c81838e3 spaceship 2026-05-07 12:07:09 +03:00
Liosha 7dfc798b8b spaceship 2026-05-07 11:11:46 +03:00
Liosha c08aec68ca spaceship 2026-05-07 11:03:31 +03:00
Liosha d147ce3b0d spaceship 2026-05-07 10:43:15 +03:00
Liosha 392726c65e spaship 2026-05-06 21:38:29 +03:00
Liosha 3729198b25 spaship 2026-05-06 15:41:00 +03:00
Liosha 0eaab7c26e caddy.header_up_1: "Upgrade {>Upgrade}"
caddy.header_up_2: "Connection {>Connection}"
2026-05-06 14:08:38 +03:00
Liosha 462a0ab423 RUN apt-get update && apt-get install -y docker.io 2026-05-06 13:06:24 +03:00
Liosha 13e5949445 RUN apt-get update && apt-get install -y docker.io 2026-05-06 12:50:49 +03:00
Liosha 2402b75cb4 RUN apt-get update && apt-get install -y docker.io 2026-05-06 12:38:36 +03:00
Liosha 1c878793dc deploy.yml add SPRING_DATA_MONGODB_URI 2026-05-06 10:41:13 +03:00
Liosha ac6e238180 # 1. Этап сборки (Build stage)
# ОБНОВЛЕНО: Переходим на JDK 21
FROM eclipse-temurin:21-jdk-jammy AS build
2026-05-06 10:07:13 +03:00
Liosha 244c1c0e7d # 1. Этап сборки (Build stage)
# ОБНОВЛЕНО: Переходим на JDK 21
FROM eclipse-temurin:21-jdk-jammy AS build
2026-05-06 09:58:29 +03:00
Liosha f7c791e6b6 add project 2026-05-06 09:41:00 +03:00
Liosha 53c040161a add project 2026-05-05 23:10:05 +03:00
Liosha b2b066e0d3 add projectService 2026-05-02 00:46:47 +03:00
Liosha 415ab4faf0 Merge pull request '[Monitoring] MongoDB Metrics in Grafana' (#17) from development into 15-docker-client
Reviewed-on: Liosha84/jambotron#17
2026-05-01 18:19:31 +00:00
74 changed files with 3273 additions and 1026 deletions
+23
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
target/
.idea/
logs/
jambotron_data/
+30 -10
View File
@@ -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."
+11 -1
View File
@@ -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" />
+1 -1
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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,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:** периодически очищай образы, чтобы старые версии не накапливались
- **Мониторинг:** следи за ресурсами и состоянием контейнеров
---
## Итог
- **Статус системы:** Очищена
- **Направление:** Задано
- **Цель:** Созидание
+12 -1
View File
@@ -26,14 +26,22 @@ 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'
// Транспорт (HTTP Client 5) — именно он нужен для DockerConfig.java
implementation 'com.github.docker-java:docker-java-transport-httpclient5:3.3.6' implementation 'com.github.docker-java:docker-java-transport-httpclient5:3.3.6'
// 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'
implementation 'org.postgresql:postgresql:42.6.0' implementation 'org.postgresql:postgresql:42.6.0'
@@ -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'
+15
View File
@@ -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:
+15
View File
@@ -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:
+759 -969
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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",
+4
View File
@@ -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
@@ -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,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
], ],
@@ -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>
@@ -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;
}
@@ -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();
});
});
@@ -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');
}
});
}
}
}
@@ -0,0 +1,44 @@
<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="card-layout">
<div class="card-left">
<span class="c-name">{{ container.name }}</span>
<span class="c-image">{{ container.image }}</span>
</div>
<div class="card-right">
<div class="state-dot" [class.running]="container.state === 'running'"></div>
<span class="uptime">{{ container.status }}</span>
</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>
@@ -0,0 +1,106 @@
.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;
}
}
@@ -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();
});
});
@@ -0,0 +1,170 @@
// 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 } 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';
@Component({
selector: 'app-project-details',
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
MatProgressSpinnerModule,
MatDividerModule,
MatCard,
MatCardContent
],
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;
}
});
}
// Опитування стану контейнерів кожні 5 секунд
private startMonitoring(id: string): void {
this.pollingSub = timer(0, 5000).pipe(
switchMap(() => this.projectService.getProjectContainers(id).pipe(
catchError(() => of([])) // У разі помилки повертаємо порожній список
))
).subscribe({
next: (data: DockerContainer[]) => {
this.containers = data;
// Якщо з'явилися контейнери і ми ще не підключені до логів — підключаємося
if (this.containers.length > 0 && !this.logSub) {
const mainContainer = this.containers.find(c => c.state === 'running') || this.containers[0];
this.connectLogs(mainContainer.id);
}
},
error: (err) => console.error('Помилка моніторингу:', err)
});
}
// Підключення до трансляції логів
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.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.projectService.disconnectLogs(); // Закриваємо з'єднання з брокером
}
// Форматування для відображення кольорів у терміналі
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;
}
}
@@ -0,0 +1 @@
<p>project-edit.component works!</p>
@@ -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();
});
});
@@ -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;
}
}
@@ -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';
}
}
}
@@ -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,
@@ -42,7 +42,6 @@ type TutorialFormValue = {
MatCard, MatCard,
MatCardActions, MatCardActions,
MatCardContent, MatCardContent,
MatCardHeader,
MatFormField, MatFormField,
MatInput, MatInput,
MatLabel, MatLabel,
@@ -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,126 @@
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 };
};
}
@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`);
}
}
+5
View File
@@ -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="/">
+5
View File
@@ -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
View File
@@ -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"}
} }
+3 -3
View File
@@ -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
} }
} }
@@ -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,21 @@
package com.jambotronGroup.jambotron.controllers;
import com.jambotronGroup.jambotron.console.DockerLogService;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
@Controller
public class LogController {
private final DockerLogService dockerLogService;
public LogController(DockerLogService dockerLogService) {
this.dockerLogService = dockerLogService;
}
// Когда клиент шлет сообщение на /app/start-logs
@MessageMapping("/start-logs")
public void handleStartLogs(String containerId) {
dockerLogService.streamLogs(containerId);
}
}
@@ -0,0 +1,209 @@
package com.jambotronGroup.jambotron.controllers;
import com.jambotronGroup.jambotron.model.Project;
import com.jambotronGroup.jambotron.model.ProjectStatus;
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.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.security.access.AccessDeniedException;
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 UserRepository userRepository;
private final AuthenticationFacade authenticationFacade;
//-------------------- Просмотр и Dashboard ----------------------------
/**
* Получение списка проектов текущего пользователя (Dashboard)[cite: 2, 3].
*/
@GetMapping("/user/projects")
public ResponseEntity<List<Project>> getUserProjects() {
try {
User user = getCurrentUser();
List<Project> projects = projectService.findAllByOwnerId(user.getId());
if (projects.isEmpty()) {
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
return new ResponseEntity<>(projects, HttpStatus.OK);
} catch (Exception e) {
_logger.error("Error fetching user projects", e);
return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/**
* Детали проекта (Project + Config). Доступно владельцу или админу.
*/
@GetMapping("/user/projects/{id}/details")
public ResponseEntity<ProjectDetailResponse> getProjectDetails(@PathVariable UUID id) {
try {
User user = getCurrentUser();
boolean isAdmin = checkIfAdmin(user);
// Метод сервиса сам проверит права владельца, если isAdmin = false
ProjectDetailResponse details = projectService.getProjectDetailsSecure(
id,
user.getId(),
isAdmin
);
return new ResponseEntity<>(details, HttpStatus.OK);
} catch (AccessDeniedException e) {
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
} catch (Exception e) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
//-------------------- Управление Жизненным Циклом (User & Admin) ----------------------------
/**
* Деплой проекта (Docker Compose Up). Доступно владельцу или админу[cite: 1, 4].
*/
@PostMapping("/user/projects/{id}/deploy")
public ResponseEntity<String> deployProject(@PathVariable UUID id) {
// Получаем текущего пользователя (твой метод)
User user = getCurrentUser();
// Используем метод сервиса findById, а не репозиторий напрямую!
Project project = projectService.findById(id);
// Выполняем проверку безопасности (через String.valueOf, как мы выяснили)
if (checkIfAdmin(user) ||
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
// ВАЖНО: В твоем сервисе метод называется deployProject(Project project)
projectService.deployProject(project);
return ResponseEntity.ok("Процесс созидания запущен...");
}
return ResponseEntity.status(403).body("Access Denied");
}
/**
* Остановка проекта (Docker Compose Down). Доступно владельцу или админу[cite: 1, 4].
*/
@PostMapping("/user/projects/{id}/stop")
public ResponseEntity<String> stopProject(@PathVariable UUID id) {
User user = getCurrentUser();
Project project = projectService.findById(id);
if (!checkIfAdmin(user) && !project.getUser().getId().equals(String.valueOf(user.getId()))) {
return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN);
}
projectService.stopProject(project);
project.setStatus(ProjectStatus.STOPPED);
projectService.save(project);
return ResponseEntity.ok("Project stopped: " + project.getName());
}
/**
* Удаление проекта (Очистка Docker + Файлы + БД). Доступно владельцу или админу[cite: 1, 2].
*/
@DeleteMapping("/user/projects/{id}")
public ResponseEntity<String> deleteProject(@PathVariable UUID id) {
User user = getCurrentUser();
Project project = projectService.findById(id);
// Безопасная проверка владельца (уже ставшая стандартом для нашего "сосуда")
if (checkIfAdmin(user) ||
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
_logger.info("Начато полное удаление проекта: {}", id);
projectService.deleteProject(id); // Вызов того самого метода из ProjectService_2.java
return ResponseEntity.ok("Проект полностью удален из системы");
}
return ResponseEntity.status(403).body("Access Denied");
}
//-------------------- Администрирование PaaS ----------------------------
/**
* Список всех проектов системы для глобального мониторинга[cite: 2].
*/
@GetMapping("/admin/projects/all")
public ResponseEntity<List<Project>> getAllProjectsForAdmin() {
User user = getCurrentUser();
if (!checkIfAdmin(user)) {
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
}
List<Project> allProjects = projectService.findAllProjectsForAdmin();
return new ResponseEntity<>(allProjects, HttpStatus.OK);
}
//-------------------- Создание и Конфигурация ----------------------------
@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 String newContent) {
User user = getCurrentUser();
Project project = projectService.findById(id);
if (!checkIfAdmin(user) && !project.getUser().getId().equals(String.valueOf(user.getId()))) {
return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN);
}
projectService.updateProjectConfig(id, newContent);
return ResponseEntity.ok("Configuration updated.");
}
//-------------------- Вспомогательные методы ----------------------------
private User getCurrentUser() {
return userRepository.findById(authenticationFacade.getUserDetails().getId())
.orElseThrow(() -> new RuntimeException("Current user not found"));
}
private boolean checkIfAdmin(User user) {
return user.getRoles() != null && (user.getRoles().equals("ADMIN") || user.getRoles().equals("ROLE_ADMIN"));
}
@GetMapping("/user/projects/{id}/containers")
public ResponseEntity<List<ContainerStatus>> getContainers(@PathVariable UUID id) {
User user = getCurrentUser();
Project project = projectService.findById(id);
// Применяем нашу "золотую формулу" сравнения ID
if (checkIfAdmin(user) ||
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
return ResponseEntity.ok(projectService.getProjectContainers(id));
}
return ResponseEntity.status(403).build();
}
}
@@ -0,0 +1,45 @@
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.httpclient5.ApacheDockerHttpClient;
import com.github.dockerjava.transport.DockerHttpClient;
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;
@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() {
String hostToUse = dockerHost;
if (hostToUse.contains("2375") || hostToUse.equals("localhost")) {
hostToUse = "unix:///var/run/docker.sock";
}
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
.withDockerHost(hostToUse)
.build();
// Явно задаем URI для транспорта, чтобы исключить localhost:2375
DockerHttpClient httpClient = new ApacheDockerHttpClient.Builder()
.dockerHost(java.net.URI.create(hostToUse)) // Принудительный URI
.sslConfig(config.getSSLConfig())
.maxConnections(100)
.build();
_logger.info("Fixed Docker Host for transport: {}", hostToUse);
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,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,26 @@
package com.jambotronGroup.jambotron.model;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.Map;
@Document(collection = "project_configs")
@Data // Генерирует геттеры, сеттеры, toString, equals, hashCode
@Builder // Генерирует тот самый метод .builder()
@NoArgsConstructor // Нужен для десериализации MongoDB
@AllArgsConstructor // Нужен для работы @Builder
public class ProjectConfig {
@Id
private String id; // Внутренний ID Mongo
private String projectId; // UUID из нашей таблицы projects
private String rawComposeContent; // Прямо текст твоего docker-compose.yaml
private Map<String, String> envVariables; // Ключ-значение для переменных
private Integer version = 1; // Для отслеживания правок конфига
}
@@ -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,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);
}
}
@@ -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,9 @@
package com.jambotronGroup.jambotron.projects;
import com.jambotronGroup.jambotron.model.Project;
import com.jambotronGroup.jambotron.model.ProjectConfig;
public record ProjectDetailResponse(
Project project,
ProjectConfig config
) {}
@@ -0,0 +1,376 @@
package com.jambotronGroup.jambotron.projects;
import com.jambotronGroup.jambotron.globalConstants.GlobalConstants;
import com.jambotronGroup.jambotron.model.Project;
import com.jambotronGroup.jambotron.model.ProjectConfig;
import com.jambotronGroup.jambotron.model.ProjectStatus;
import com.jambotronGroup.jambotron.model.User;
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 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.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ProjectService {
private final ProjectRepository projectRepository;
private final ProjectConfigRepository configRepository;
private static final Logger _logger = LoggerFactory.getLogger(ProjectService.class);
public Project findById(UUID id) {
return projectRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Project not found with id: " + id));
}
/**
* Получение всех проектов пользователя (для Dashboard).
* Возвращает список проектов, принадлежащих конкретному владельцу.
*/
public List<Project> findAllByOwnerId(Long ownerId) {
_logger.info("Запрос списка проектов для пользователя: {}", ownerId);
return projectRepository.findAllByUserId(ownerId);
}
/**
* Получение всех проектов в системе (для Admin Panel).
* Позволяет администратору видеть состояние всех развернутых мощностей.
*/
public List<Project> findAllProjectsForAdmin() {
_logger.info("Запрос всех проектов системы администратором");
return projectRepository.findAll();
}
/**
* Получение развернутой информации о проекте (Project + Config).
* Полезно для детального отображения в Dashboard.
*/
public ProjectDetailResponse getProjectDetails(UUID projectId) {
Project project = findById(projectId);
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
.orElseThrow(() -> new RuntimeException("Конфигурация не найдена"));
return new ProjectDetailResponse(project, config);
}
/**
* Безопасное получение деталей проекта.
* @param projectId ID проекта
* @param currentUserId ID пользователя, совершающего запрос
* @param isAdmin флаг, является ли пользователь администратором
*/
public ProjectDetailResponse getProjectDetailsSecure(UUID projectId, Long currentUserId, boolean isAdmin) {
Project project = findById(projectId);
// ПРОВЕРКА БЕЗОПАСНОСТИ:
// Если пользователь не админ и не владелец проекта — доступ запрещен
if (!isAdmin && !Objects.equals(project.getUser().getId(), currentUserId)) {
_logger.warn("Security Alert: User {} tried to access project {} owned by {}",
currentUserId, projectId, project.getUser().getId());
throw new AccessDeniedException("У вас нет прав для просмотра этого проекта");
}
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
.orElseThrow(() -> new RuntimeException("Конфигурация не найдена"));
return new ProjectDetailResponse(project, config);
}
@Transactional
public Project createProject(String projectName, String rawComposeContent, User owner) {
if (projectRepository.existsByNameAndUserId(projectName, owner.getId())) {
throw new ProjectAlreadyExistsException(
"Проект с именем '" + projectName + "' уже существует у данного пользователя"
);
}
_logger.info("Валидация пройдена. Создание проекта: {}", projectName);
Project project = new Project();
project.setName(projectName);
project.setUser(owner);
project.setStatus(ProjectStatus.CREATED);
project = projectRepository.save(project);
UUID projectId = project.getId();
try {
// Используем структуру из GlobalConstants для создания рабочей директории
Path projectDir = GlobalConstants.PATH_PROJECTS.resolve(projectId.toString());
Files.createDirectories(projectDir);
project.setLocalPath(projectDir.toString());
projectRepository.save(project);
ProjectConfig config = ProjectConfig.builder()
.projectId(String.valueOf(project.getId()))
.rawComposeContent(rawComposeContent)
.build();
configRepository.save(config);
return project;
} catch (IOException e) {
_logger.error("FS Error during project creation: {}", projectId, e);
throw new RuntimeException("Ошибка инициализации хранилища");
}
}
/**
* Основной процесс деплоя.
* Метод не помечен @Transactional целиком, чтобы статус STARTING
* зафиксировался в БД до начала выполнения долгой команды docker compose.
*/
public void deployProject(Project project) {
_logger.info("invoke deployProject");
// 1. Устанавливаем промежуточный статус
updateStatusInNewTransaction(project.getId(), ProjectStatus.STARTING);
_logger.info("{}: chenget status to{}",project.getId(),project.getStatus());
try {
// 2. Путь берем из сущности (он был инициализирован в createProject через GlobalConstants)
Path projectPath = Paths.get(project.getLocalPath());
// 3. Генерируем файлы конфигурации
prepareFiles(project, projectPath);
_logger.info("Ready to deploy project at: {}", projectPath);
// 4. Запускаем Docker Compose (блокирующая операция)
int exitCode = runComposeUp(projectPath,project.getId());
// 5. Финализируем статус на основе результата
if (exitCode == 0) {
updateStatusInNewTransaction(project.getId(), ProjectStatus.RUNNING);
_logger.info("Project {} is now RUNNING", project.getId());
} else {
updateStatusInNewTransaction(project.getId(), ProjectStatus.ERROR);
_logger.error("Docker Compose failed with exit code: {}", exitCode);
}
} catch (Exception e) {
_logger.error("Critical error during deploy: {}", e.getMessage());
updateStatusInNewTransaction(project.getId(), ProjectStatus.ERROR);
}
}
/**
* Вспомогательный метод для атомарного обновления статуса
*/
@Transactional
public void updateStatusInNewTransaction(UUID projectId, ProjectStatus status) {
Project project = findById(projectId);
project.setStatus(status);
projectRepository.save(project);
}
private void prepareFiles(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);
}
// docker-compose.yml
Path composeFile = projectPath.resolve("docker-compose.yml");
Files.writeString(composeFile, config.getRawComposeContent());
// .env
if (config.getEnvVariables() != null && !config.getEnvVariables().isEmpty()) {
String envContent = config.getEnvVariables().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.collect(Collectors.joining("\n"));
Files.writeString(projectPath.resolve(".env"), envContent);
_logger.info(".env file generated for project: {}", project.getId());
}
}
private int runComposeUp(Path projectPath, UUID projectId) {
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d");
pb.directory(projectPath.toFile());
pb.redirectErrorStream(true);
try {
_logger.info("Запуск docker compose up для проекта: {}", projectId);
Process process = pb.start();
// 1. Читаем поток вывода в реальном времени
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
// Логируем в консоль сервера (из этого потока наш DockerLogsHandler
// будет забирать данные для передачи в Angular)
_logger.info("[Docker-{}]: {}", projectId, line);
// УДАЛЕНО: messagingTemplate.convertAndSend(...)
// Теперь логи транслирует DockerLogsHandler напрямую из Docker-сокета.
}
}
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
// ... остальная логика завершения процесса ...
return process.exitValue();
} catch (IOException | InterruptedException e) {
_logger.error("Критическая ошибка при выполнении docker compose: {}", e.getMessage());
return -1;
}
}
@Transactional
public void stopProject(Project project) {
Path projectPath = Paths.get(project.getLocalPath());
_logger.info("Stopping project: {}", project.getId());
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down");
pb.directory(projectPath.toFile());
pb.inheritIO();
try {
Process process = pb.start();
int exitCode = process.waitFor();
if (exitCode == 0) {
project.setStatus(ProjectStatus.STOPPED);
_logger.info("Project {} stopped successfully.", project.getId());
} else {
project.setStatus(ProjectStatus.ERROR);
_logger.error("Failed to stop project. Exit code: {}", exitCode);
}
} catch (Exception e) {
project.setStatus(ProjectStatus.ERROR);
_logger.error("Error during docker compose down", e);
} finally {
projectRepository.save(project);
}
}
@Transactional
public void save(Project project) {
projectRepository.save(project);
}
@Transactional
public void updateProjectConfig(UUID projectId, String newYaml) {
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
.orElseThrow(() -> new RuntimeException("Config not found for project: " + projectId));
config.setRawComposeContent(newYaml);
configRepository.save(config);
}
@Transactional
public void deleteProject(UUID projectId) {
Project project = findById(projectId);
// Путь берем из проинициализированного поля localPath
Path projectPath = Paths.get(project.getLocalPath());
_logger.info("Запуск полного удаления проекта: {} (путь: {})", projectId, projectPath);
try {
// 1. Останавливаем и удаляем всё в Docker (контейнеры, сети и ТОМА)
// Флаг -v критически важен для очистки места
runCommand(projectPath, "docker", "compose", "down", "-v");
if (!Files.isWritable(projectPath)) {
throw new RuntimeException("Директория проекта доступна только для чтения. Удаление невозможно.");
}
// 2. Рекурсивно удаляем папку проекта в файловой системе
if (Files.exists(projectPath)) {
try (var stream = Files.walk(projectPath)) {
stream.sorted(Comparator.reverseOrder()) // Сначала файлы, потом папки
.map(Path::toFile)
.forEach(file -> {
if (!file.delete()) {
_logger.warn("Не удалось удалить файл: {}", file.getAbsolutePath());
}
});
}
_logger.info("Файловая система очищена для проекта: {}", projectId);
}
// 3. Удаляем данные из БД[cite: 1]
// Сначала конфиг (связанная сущность), потом сам проект
configRepository.deleteByProjectId(String.valueOf(projectId));
projectRepository.delete(project);
_logger.info("Проект {} успешно удален из системы", projectId);
} catch (Exception e) {
_logger.error("Критическая ошибка при удалении проекта {}: {}", projectId, e.getMessage());
throw new RuntimeException("Ошибка при полном удалении проекта", e);
}
}
/**
* Универсальный метод для запуска быстрых команд Docker без чтения логов в реальном времени
*/
private void runCommand(Path directory, String... command) throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(directory.toFile());
pb.inheritIO();
Process process = pb.start();
int exitCode = process.waitFor();
if (exitCode != 0) {
_logger.warn("Команда {} завершилась с кодом {}", String.join(" ", command), exitCode);
}
}
public List<ContainerStatus> getProjectContainers(UUID projectId) {
Project project = findById(projectId);
Path projectPath = Paths.get(project.getLocalPath());
// Формат: ID | Service Name | Image | State | Status
ProcessBuilder pb = new ProcessBuilder(
"docker", "compose", "ps", "--format", "{{.ID}}|{{.Service}}|{{.Image}}|{{.State}}|{{.Status}}"
);
pb.directory(projectPath.toFile());
try {
Process process = pb.start();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
return reader.lines()
.map(line -> {
String[] parts = line.split("\\|");
if (parts.length >= 5) {
return new ContainerStatus(parts[0], parts[1], parts[2], parts[3], parts[4]);
}
return null;
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
} catch (IOException e) {
_logger.error("Ошибка при получении списка контейнеров для проекта {}: {}", projectId, e.getMessage());
return List.of();
}
}
}
@@ -0,0 +1,16 @@
package com.jambotronGroup.jambotron.repository;
import com.jambotronGroup.jambotron.model.ProjectConfig;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface ProjectConfigRepository extends MongoRepository<ProjectConfig, String> {
// Нам часто нужно будет доставать конфиг именно по ID проекта из Postgres
Optional<ProjectConfig> findByProjectId(String projectId);
// Удаление конфига при удалении проекта
void deleteByProjectId(String projectId);
}
@@ -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());
+13 -7
View File
@@ -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()
);