From 584fa9e320c1899ef45e278088f3f510cb92dee4 Mon Sep 17 00:00:00 2001 From: Liosha Date: Sun, 17 May 2026 01:05:04 +0300 Subject: [PATCH] EDIT subdomen --- How_To.md | 50 ++ .../project-details.component.html | 115 +++- .../project-details.component.ts | 214 +++++- .../src/app/services/project.service.ts | 81 +++ .../jambotron/DTOs/ContainerStatsDTO.java | 18 + .../jambotron/controllers/LogController.java | 16 +- .../controllers/ProjectController.java | 228 +++---- .../jambotron/model/ContainerStats.java | 4 + .../jambotron/model/ProjectConfig.java | 29 +- .../projects/DockerStatsService.java | 88 +++ .../projects/ProjectDetailResponse.java | 39 +- .../jambotron/projects/ProjectService.java | 612 ++++++++++-------- .../repository/ProjectConfigRepository.java | 13 +- 13 files changed, 1075 insertions(+), 432 deletions(-) create mode 100644 How_To.md create mode 100644 src/main/java/com/jambotronGroup/jambotron/DTOs/ContainerStatsDTO.java create mode 100644 src/main/java/com/jambotronGroup/jambotron/model/ContainerStats.java create mode 100644 src/main/java/com/jambotronGroup/jambotron/projects/DockerStatsService.java diff --git a/How_To.md b/How_To.md new file mode 100644 index 0000000..07f7617 --- /dev/null +++ b/How_To.md @@ -0,0 +1,50 @@ +Для того чтобы найти и завершить процесс, занимающий определенный порт (в данном случае 8082), в большинстве операционных систем используются стандартные терминальные команды. + +Вот пошаговая инструкция для Linux/macOS и Windows. + +🐧 Linux и macOS +Самый быстрый способ — использовать утилиту lsof или fuser. + +1. Поиск процесса + Выполните команду в терминале, чтобы узнать PID (идентификатор процесса): + +Bash +sudo lsof -i :8082 +PID — это число в соответствующей колонке, которое нам понадобится. + +Или с помощью fuser: + +Bash +sudo fuser 8082/tcp +2. Завершение процесса + Используйте команду kill, подставив найденный PID: + +Bash +kill -9 PID +Примечание: Флаг -9 (SIGKILL) принудительно завершает процесс. Если вы хотите закрыть его «мягко», используйте kill PID без флага. + +Лайфхак (одной командой): +Если нужно найти и сразу убить: + +Bash +sudo kill -9 $(sudo lsof -t -i:8082) +🪟 Windows +В Windows это делается через командную строку (CMD) или PowerShell. + +1. Поиск процесса + Откройте терминал и введите: + +DOS +```bash +netstat -ano | findstr :8082 +``` +В последней колонке вы увидите PID. Например: 1234. + +2. Завершение процесса + Используйте команду taskkill: + +DOS +```bash +taskkill /PID 1234 /F +``` +/F — принудительное завершение. \ No newline at end of file diff --git a/jambotron-ui/src/app/modules/user-module/project-details.component/project-details.component.html b/jambotron-ui/src/app/modules/user-module/project-details.component/project-details.component.html index 9274c6d..b0c6b3c 100644 --- a/jambotron-ui/src/app/modules/user-module/project-details.component/project-details.component.html +++ b/jambotron-ui/src/app/modules/user-module/project-details.component/project-details.component.html @@ -16,15 +16,116 @@
@for (container of containers; track container.name) { - -
- {{ container.name }} - {{ container.image }} + + +
+ +
+ + {{ container.name }} + + + {{ container.image }} + + + @if (containerStats.get(container.id); as stats) { +
+ CPU: {{ stats.cpuUsage | number:'1.1-1' }}% | RAM: {{ stats.memoryPercent | number:'1.0-0' }}% +
+ } +
+ +
+
+ + +
+ +
+
+ {{ container.status }} +
+
+
-
-
- {{ container.status }} + +
+
+ +
+
+ Маршрутизация сети: + + +
+ + + + + {{ domain }} + + + :{{ getContainerPort(container.name) }} + +
+
+ + + Локальный стек (нет публичного прокси) + +
+ + +
+ +
+
+ Настройка шлюза +
+ + +
+
+ +
+
+ + .jamb +
+ +
+ +
+
+
+ +
+ } diff --git a/jambotron-ui/src/app/modules/user-module/project-details.component/project-details.component.ts b/jambotron-ui/src/app/modules/user-module/project-details.component/project-details.component.ts index e97183f..fd38113 100644 --- a/jambotron-ui/src/app/modules/user-module/project-details.component/project-details.component.ts +++ b/jambotron-ui/src/app/modules/user-module/project-details.component/project-details.component.ts @@ -3,18 +3,26 @@ import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } 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 { + ProjectService, + Project, + DockerContainer, + ProjectDetailResponse, + ContainerStats +} from '../../../services/project.service'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatDividerModule } from '@angular/material/divider'; import { MatCard, MatCardContent } from '@angular/material/card'; +import {FormsModule} from '@angular/forms'; @Component({ selector: 'app-project-details', standalone: true, imports: [ CommonModule, + FormsModule, MatIconModule, MatButtonModule, MatProgressSpinnerModule, @@ -66,23 +74,39 @@ export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChec }); } - // Опитування стану контейнерів кожні 5 секунд private startMonitoring(id: string): void { this.pollingSub = timer(0, 5000).pipe( switchMap(() => this.projectService.getProjectContainers(id).pipe( - catchError(() => of([])) // У разі помилки повертаємо порожній список + catchError(() => of([])) )) ).subscribe({ next: (data: DockerContainer[]) => { + const activeIds = data.map(c => c.id); + + // Очистка подписок для исчезнувших контейнеров + this.statsSubs.forEach((sub, containerId) => { + if (!activeIds.includes(containerId)) { + sub.unsubscribe(); + this.statsSubs.delete(containerId); + this.containerStats.delete(containerId); + } + }); + this.containers = data; - // Якщо з'явилися контейнери і ми ще не підключені до логів — підключаємося + // Инициация подписок для новых запущенных контейнеров + this.containers.forEach(container => { + if (container.state === 'running' && !this.statsSubs.has(container.id)) { + this.subscribeToContainerStats(container.id); + } + }); + + // Логика подключения к логам (сохранена) if (this.containers.length > 0 && !this.logSub) { const mainContainer = this.containers.find(c => c.state === 'running') || this.containers[0]; this.connectLogs(mainContainer.id); } - }, - error: (err) => console.error('Помилка моніторингу:', err) + } }); } @@ -111,6 +135,16 @@ export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChec deploy(): void { if (this.project) { this.loading = true; + this.logs = []; // Очищаем окно перед новым деплоем + + // Подписываемся на системный поток деплоя + this.projectService.connectToDeployLogs(this.project.id).subscribe({ + next: (line) => { + this.logs.push(line); + if (this.logs.length > 200) this.logs.shift(); + } + }); + this.projectService.deployProject(this.project.id).subscribe({ next: () => this.loading = false, error: () => this.loading = false @@ -148,7 +182,12 @@ export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChec ngOnDestroy(): void { this.pollingSub?.unsubscribe(); this.logSub?.unsubscribe(); - this.projectService.disconnectLogs(); // Закриваємо з'єднання з брокером + + // Очищаем все активные подписки на метрики контейнеров + this.statsSubs.forEach(sub => sub.unsubscribe()); + this.statsSubs.clear(); + + this.projectService.disconnectLogs(); // Закрываем основное STOMP соединение } // Форматування для відображення кольорів у терміналі @@ -167,4 +206,165 @@ export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChec } return formatted; } + + + containerStats: Map = new Map(); + private statsSubs: Map = new Map(); + + // Новый метод для живых метрик + private subscribeToContainerStats(containerId: string): void { + const sub = this.projectService.connectToStats(containerId).subscribe({ + next: (stats) => { + this.containerStats.set(containerId, stats); + }, + error: (err) => console.error(`Stats error for ${containerId}:`, err) + }); + this.statsSubs.set(containerId, sub); + } + + // Переключение состояния (Start/Stop) + toggleContainer(container: DockerContainer): void { + if (!this.project) return; + + const obs = container.state === 'running' + ? this.projectService.stopContainer(this.project.id, container.id) + : this.projectService.startContainer(this.project.id, container.id); + + obs.subscribe({ + next: () => console.log(`Статус контейнера ${container.id} изменен`), + error: (err) => console.error('Ошибка управления:', err) + }); + } + +// Рестарт + restartContainer(containerId: string): void { + if (this.project) { + this.projectService.restartContainer(this.project.id, containerId).subscribe({ + next: () => console.log(`Контейнер ${containerId} перезапущен`), + error: (err) => console.error('Ошибка рестарта:', err) + }); + } + } +// ========================================== + // CADDY ROUTING & INLINE EDITING MANAGEMENT + // ========================================== + + // Хранит состояние редактирования для каждого контейнера: { [containerId]: boolean } + editingDomains: { [key: string]: boolean } = {}; + + // Временные переменные для хранения ввода пользователя во время редактирования + tempSubdomain: string = ''; + tempPort: number = 80; + + /** + * Извлекает имя сервиса (например, 'web') из полного имени Docker-контейнера + */ + getServiceName(containerName: string): string | null { + if (!containerName || !this.project?.id) return null; + + // Проверяем префикс по ID проекта + const prefixId = `jambotron-${this.project.id}-`; + // Резервный префикс по имени проекта (на случай, если бэкенд использует его) + const prefixName = `jambotron-${this.project.name}-`; + + let servicePart = ''; + + if (containerName.startsWith(prefixId)) { + servicePart = containerName.substring(prefixId.length); + } else if (containerName.startsWith(prefixName)) { + servicePart = containerName.substring(prefixName.length); + } else { + // Фолбэк-разбор для коротких имён, если префиксы не подошли + const parts = containerName.split('-'); + if (parts.length >= 3 && parts[0] === 'jambotron') { + servicePart = parts.slice(2).join('-'); + } else { + return null; + } + } + + // Отрезаем индекс реплики Compose (например, "-1") + if (servicePart.endsWith('-1')) { + servicePart = servicePart.substring(0, servicePart.length - 2); + } + return servicePart; + } + + /** + * Возвращает текущий сформированный домен для отображения + */ + getContainerDomain(containerName: string): string | null { + const serviceName = this.getServiceName(containerName); + if (!serviceName || !this.config?.serviceConfigs) return null; + + const serviceConfig = this.config.serviceConfigs[serviceName]; + return serviceConfig && serviceConfig.subdomain ? `${serviceConfig.subdomain}.jambotron.com` : null; + } + + /** + * Возвращает текущий порт сервиса + */ + getContainerPort(containerName: string): number { + const serviceName = this.getServiceName(containerName); + if (!serviceName || !this.config?.serviceConfigs) return 80; + + const serviceConfig = this.config.serviceConfigs[serviceName]; + return serviceConfig && serviceConfig.port ? serviceConfig.port : 80; + } + + /** + * Вход в режим редактирования плашки + */ + startEditDomain(container: any) { + const serviceName = this.getServiceName(container.name); + if (!serviceName) return; + + this.editingDomains[container.id] = true; + + // Инициализируем временные переменные текущими значениями из Mongo конфигурации + const currentConfig = this.config?.serviceConfigs?.[serviceName]; + this.tempSubdomain = currentConfig?.subdomain || ''; + this.tempPort = currentConfig?.port || 80; + } + + /** + * Сохранение изменённого домена и порта на бэкенд + */ + saveDomainConfig(container: any) { + const serviceName = this.getServiceName(container.name); + if (!serviceName || !this.project?.id || !this.config) return; + + // Гарантируем существование мапы конфигураций + if (!this.config.serviceConfigs) { + this.config.serviceConfigs = {}; + } + + const subdomainClean = this.tempSubdomain.trim().toLowerCase(); + + if (!subdomainClean) { + // Если поддомен стёрли — удаляем настройку проксирования для этого сервиса + delete this.config.serviceConfigs[serviceName]; + } else { + this.config.serviceConfigs[serviceName] = { + subdomain: subdomainClean, + port: this.tempPort || 80 + }; + } + + // Отправляем монолитный конфиг в базу MongoDB через твой сохранённый сервис + this.projectService.updateProjectConfig(this.project.id, this.config).subscribe({ + next: (updatedConfig) => { + this.config = updatedConfig; + this.editingDomains[container.id] = false; + console.log(`Конфигурация Caddy для ${serviceName} успешно зафиксирована.`); + }, + error: (err) => { + alert(err.error?.message || 'Ошибка валидации доменного имени.'); + } + }); + } + + cancelEditDomain(containerId: string) { + this.editingDomains[containerId] = false; + } } diff --git a/jambotron-ui/src/app/services/project.service.ts b/jambotron-ui/src/app/services/project.service.ts index 911c69c..6094ebb 100644 --- a/jambotron-ui/src/app/services/project.service.ts +++ b/jambotron-ui/src/app/services/project.service.ts @@ -40,6 +40,29 @@ export interface ProjectDetailResponse { }; } +export interface ContainerStats { + containerId: string; + cpuUsage: number; + memoryUsage: number; + memoryLimit: number; + memoryPercent: number; +} +// Описание структуры конфигурации доменов созидания +export interface ServiceSettings { + subdomain?: string; + port?: number; +} + +export interface ProjectConfig { + id?: string; + projectId: string; + rawComposeContent: string; + serviceNames: string[]; + serviceConfigs: { [key: string]: ServiceSettings }; + version?: number; +} + + @Injectable({ providedIn: 'root' }) @@ -123,4 +146,62 @@ export class ProjectService { getProjectContainers(projectId: string): Observable { return this.http.get(`${this.baseUrl}/user/projects/${projectId}/containers`); } + // Добавь в project.service.ts + public connectToDeployLogs(projectId: string): Observable { + const deploySubject = new Subject(); + const socket = new SockJS(`${this.baseUrl}/ws-jambotron`); + + const client = new Client({ + webSocketFactory: () => socket, + onConnect: () => { + // Подписываемся на системный топик деплоя + client.subscribe(`/topic/deploy/${projectId}`, (message: IMessage) => { + deploySubject.next(message.body); + }); + } + }); + + client.activate(); + return deploySubject.asObservable(); + } + + public connectToStats(containerId: string): Observable { + const statsSubject = new Subject(); + const socket = new SockJS(`${this.baseUrl}/ws-jambotron`); + const client = new Client({ + webSocketFactory: () => socket, + onConnect: () => { + client.subscribe(`/topic/stats/${containerId}`, (message) => { + statsSubject.next(JSON.parse(message.body)); + }); + // Сигнал к началу сбора + client.publish({ destination: '/app/start-stats', body: containerId }); + } + }); + client.activate(); + return statsSubject.asObservable(); + } + + restartContainer(projectId: string, containerId: string): Observable { + return this.http.post(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/restart`, {}); + } + + stopContainer(projectId: string, containerId: string): Observable { + return this.http.post(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/stop`, {}); + } + + startContainer(projectId: string, containerId: string): Observable { + return this.http.post(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/start`, {}); + } + + // Получить конфигурацию поддоменов из MongoDB + getProjectConfig(projectId: string): Observable { + return this.http.get(`${this.baseUrl}/user/projects/${projectId}/config`); + } + + // Обновить конфигурацию (сохранить измененные домены и порты) + updateProjectConfig(projectId: string, config: ProjectConfig): Observable { + return this.http.put(`${this.baseUrl}/user/projects/${projectId}/config`, config); + } + } diff --git a/src/main/java/com/jambotronGroup/jambotron/DTOs/ContainerStatsDTO.java b/src/main/java/com/jambotronGroup/jambotron/DTOs/ContainerStatsDTO.java new file mode 100644 index 0000000..1ee36d2 --- /dev/null +++ b/src/main/java/com/jambotronGroup/jambotron/DTOs/ContainerStatsDTO.java @@ -0,0 +1,18 @@ +package com.jambotronGroup.jambotron.DTOs; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ContainerStatsDTO { + private String containerId; + private double cpuUsage; // Процент загрузки CPU + private long memoryUsage; // Использование памяти в байтах + private long memoryLimit; // Лимит памяти в байтах + private double memoryPercent; // Процент использования памяти +} diff --git a/src/main/java/com/jambotronGroup/jambotron/controllers/LogController.java b/src/main/java/com/jambotronGroup/jambotron/controllers/LogController.java index 809f5b3..1dd0a4d 100644 --- a/src/main/java/com/jambotronGroup/jambotron/controllers/LogController.java +++ b/src/main/java/com/jambotronGroup/jambotron/controllers/LogController.java @@ -1,6 +1,9 @@ package com.jambotronGroup.jambotron.controllers; import com.jambotronGroup.jambotron.console.DockerLogService; +import com.jambotronGroup.jambotron.projects.DockerStatsService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.stereotype.Controller; @@ -9,8 +12,13 @@ public class LogController { private final DockerLogService dockerLogService; - public LogController(DockerLogService dockerLogService) { + private static final Logger _logger = LoggerFactory.getLogger(LogController.class); + + private final DockerStatsService dockerStatsService; + + public LogController(DockerLogService dockerLogService, DockerStatsService dockerStatsService) { this.dockerLogService = dockerLogService; + this.dockerStatsService = dockerStatsService; } // Когда клиент шлет сообщение на /app/start-logs @@ -18,4 +26,10 @@ public class LogController { public void handleStartLogs(String containerId) { dockerLogService.streamLogs(containerId); } + + @MessageMapping("/start-stats") + public void handleStartStats(String containerId) { + _logger.info("Начинаем сбор метрик для контейнера: {}", containerId); + dockerStatsService.startStreamingStats(containerId); + } } \ No newline at end of file diff --git a/src/main/java/com/jambotronGroup/jambotron/controllers/ProjectController.java b/src/main/java/com/jambotronGroup/jambotron/controllers/ProjectController.java index 04b649d..ae2f68b 100644 --- a/src/main/java/com/jambotronGroup/jambotron/controllers/ProjectController.java +++ b/src/main/java/com/jambotronGroup/jambotron/controllers/ProjectController.java @@ -1,12 +1,13 @@ package com.jambotronGroup.jambotron.controllers; import com.jambotronGroup.jambotron.model.Project; -import com.jambotronGroup.jambotron.model.ProjectStatus; +import com.jambotronGroup.jambotron.model.ProjectConfig; import com.jambotronGroup.jambotron.model.User; import com.jambotronGroup.jambotron.projects.ContainerStatus; import com.jambotronGroup.jambotron.projects.ProjectCreateRequest; import com.jambotronGroup.jambotron.projects.ProjectDetailResponse; import com.jambotronGroup.jambotron.projects.ProjectService; +import com.jambotronGroup.jambotron.repository.ProjectConfigRepository; import com.jambotronGroup.jambotron.repository.UserRepository; import com.jambotronGroup.jambotron.security.AuthenticationFacade; import jakarta.validation.Valid; @@ -15,7 +16,6 @@ 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; @@ -29,181 +29,141 @@ public class ProjectController { private static final Logger _logger = LoggerFactory.getLogger(ProjectController.class); private final ProjectService projectService; - + private final ProjectConfigRepository configRepository; // <-- Это то, что я чуть не забыла! private final UserRepository userRepository; private final AuthenticationFacade authenticationFacade; - //-------------------- Просмотр и Dashboard ---------------------------- + // --- Просмотр списка и дашборд --- - /** - * Получение списка проектов текущего пользователя (Dashboard)[cite: 2, 3]. - */ @GetMapping("/user/projects") public ResponseEntity> getUserProjects() { - try { - User user = getCurrentUser(); - List 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); - } + User user = getCurrentUser(); + List projects = projectService.findAllByOwnerId(user.getId()); + return projects.isEmpty() ? new ResponseEntity<>(HttpStatus.NO_CONTENT) : ResponseEntity.ok(projects); } - /** - * Детали проекта (Project + Config). Доступно владельцу или админу. - */ @GetMapping("/user/projects/{id}/details") public ResponseEntity 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 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 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); - } + if (!hasAccess(user, project)) return new ResponseEntity<>(HttpStatus.FORBIDDEN); - projectService.stopProject(project); - project.setStatus(ProjectStatus.STOPPED); - projectService.save(project); + // Тянем конфиг из Mongo для детального отображения + ProjectConfig config = configRepository.findByProjectId(String.valueOf(id)) + .orElseThrow(() -> new RuntimeException("Конфигурация созидания не найдена")); - return ResponseEntity.ok("Project stopped: " + project.getName()); + return ResponseEntity.ok(new ProjectDetailResponse(project, config)); } - /** - * Удаление проекта (Очистка Docker + Файлы + БД). Доступно владельцу или админу[cite: 1, 2]. - */ - @DeleteMapping("/user/projects/{id}") - public ResponseEntity 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> getAllProjectsForAdmin() { - User user = getCurrentUser(); - if (!checkIfAdmin(user)) { - return new ResponseEntity<>(HttpStatus.FORBIDDEN); - } - - List allProjects = projectService.findAllProjectsForAdmin(); - return new ResponseEntity<>(allProjects, HttpStatus.OK); - } - - //-------------------- Создание и Конфигурация ---------------------------- + // --- Создание и обновление --- @PostMapping("/user/projects") public ResponseEntity 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 updateConfig(@PathVariable UUID id, @RequestBody String newContent) { + public ResponseEntity updateConfig(@PathVariable UUID id, @RequestBody ProjectConfig newConfig) { 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); + if (!hasAccess(user, project)) return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN); + + try { + // Передаем весь объект для валидации поддоменов + projectService.updateProjectConfig(id, newConfig); + return ResponseEntity.ok("Конфигурация обновлена."); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); } - - 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")); + @PostMapping("/user/projects/{id}/deploy") + public ResponseEntity deployProject(@PathVariable UUID id) { + User user = getCurrentUser(); + Project project = projectService.findById(id); + + if (!hasAccess(user, project)) return ResponseEntity.status(403).body("Access Denied"); + + projectService.deployProject(project); + return ResponseEntity.ok("Процесс созидания запущен..."); } - private boolean checkIfAdmin(User user) { - return user.getRoles() != null && (user.getRoles().equals("ADMIN") || user.getRoles().equals("ROLE_ADMIN")); + @PostMapping("/user/projects/{id}/stop") + public ResponseEntity stopProject(@PathVariable UUID id) { + User user = getCurrentUser(); + Project project = projectService.findById(id); + if (!hasAccess(user, project)) return new ResponseEntity<>(HttpStatus.FORBIDDEN); + + projectService.stopProject(project); + return ResponseEntity.ok("Проект остановлен."); } + @DeleteMapping("/user/projects/{id}") + public ResponseEntity deleteProject(@PathVariable UUID id) { + User user = getCurrentUser(); + Project project = projectService.findById(id); + if (!hasAccess(user, project)) return ResponseEntity.status(403).body("Access Denied"); + + projectService.deleteProject(id); + return ResponseEntity.ok("Проект удален."); + } + + // --- Управление отдельными контейнерами --- + @GetMapping("/user/projects/{id}/containers") public ResponseEntity> getContainers(@PathVariable UUID id) { User user = getCurrentUser(); Project project = projectService.findById(id); + if (!hasAccess(user, project)) return ResponseEntity.status(403).build(); - // Применяем нашу "золотую формулу" сравнения ID - if (checkIfAdmin(user) || - String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) { + return ResponseEntity.ok(projectService.getProjectContainers(id)); + } - return ResponseEntity.ok(projectService.getProjectContainers(id)); - } + // Рестарт конкретного контейнера + @PostMapping("/user/projects/{projectId}/containers/{containerId}/restart") + public ResponseEntity restartContainer( + @PathVariable("projectId") UUID projectId, + @PathVariable("containerId") String containerId) { + projectService.restartContainer(projectId, containerId); + return ResponseEntity.ok().build(); + } - return ResponseEntity.status(403).build(); + // Остановка конкретного контейнера + @PostMapping("/user/projects/{projectId}/containers/{containerId}/stop") + public ResponseEntity stopContainer( + @PathVariable("projectId") UUID projectId, + @PathVariable("containerId") String containerId) { + projectService.stopContainer(projectId, containerId); + return ResponseEntity.ok().build(); + } + + // Запуск конкретного контейнера + @PostMapping("/user/projects/{projectId}/containers/{containerId}/start") + public ResponseEntity startContainer( + @PathVariable("projectId") UUID projectId, + @PathVariable("containerId") String containerId) { + projectService.startContainer(projectId, containerId); + return ResponseEntity.ok().build(); + } + + // --- Утилиты --- + + private boolean hasAccess(User user, Project project) { + return checkIfAdmin(user) || String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId())); + } + + private User getCurrentUser() { + return userRepository.findById(authenticationFacade.getUserDetails().getId()) + .orElseThrow(() -> new RuntimeException("User not found")); + } + + private boolean checkIfAdmin(User user) { + return user.getRoles() != null && user.getRoles().contains("ADMIN"); } } \ No newline at end of file diff --git a/src/main/java/com/jambotronGroup/jambotron/model/ContainerStats.java b/src/main/java/com/jambotronGroup/jambotron/model/ContainerStats.java new file mode 100644 index 0000000..19ed123 --- /dev/null +++ b/src/main/java/com/jambotronGroup/jambotron/model/ContainerStats.java @@ -0,0 +1,4 @@ +package com.jambotronGroup.jambotron.model; + +public class ContainerStats { +} diff --git a/src/main/java/com/jambotronGroup/jambotron/model/ProjectConfig.java b/src/main/java/com/jambotronGroup/jambotron/model/ProjectConfig.java index 4478dae..2499381 100644 --- a/src/main/java/com/jambotronGroup/jambotron/model/ProjectConfig.java +++ b/src/main/java/com/jambotronGroup/jambotron/model/ProjectConfig.java @@ -4,23 +4,36 @@ import lombok.*; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; +import java.util.List; import java.util.Map; @Document(collection = "project_configs") -@Data // Генерирует геттеры, сеттеры, toString, equals, hashCode -@Builder // Генерирует тот самый метод .builder() -@NoArgsConstructor // Нужен для десериализации MongoDB -@AllArgsConstructor // Нужен для работы @Builder +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class ProjectConfig { @Id private String id; // Внутренний ID Mongo - private String projectId; // UUID из нашей таблицы projects + private String projectId; // UUID из таблицы projects в Postgres - private String rawComposeContent; // Прямо текст твоего docker-compose.yaml + private String rawComposeContent; // Исходный текст docker-compose.yaml - private Map envVariables; // Ключ-значение для переменных + private List serviceNames; // Извлеченные имена сервисов для стабильного UI - private Integer version = 1; // Для отслеживания правок конфига + private Map envVariables; // Переменные окружения + + private Map serviceConfigs; // Настройки поддоменов и портов для каждого контейнера + + private Integer version = 1; // Версия конфига + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ServiceSettings { + private String subdomain; // Уникальный поддомен (например, lioshik-app-api) + private Integer port; // Внутренний порт контейнера (8080, 80 и т.д.) + } } \ No newline at end of file diff --git a/src/main/java/com/jambotronGroup/jambotron/projects/DockerStatsService.java b/src/main/java/com/jambotronGroup/jambotron/projects/DockerStatsService.java new file mode 100644 index 0000000..5e4f18a --- /dev/null +++ b/src/main/java/com/jambotronGroup/jambotron/projects/DockerStatsService.java @@ -0,0 +1,88 @@ +package com.jambotronGroup.jambotron.projects; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.async.ResultCallback; +import com.github.dockerjava.api.model.CpuStatsConfig; +import com.github.dockerjava.api.model.Statistics; +import com.jambotronGroup.jambotron.DTOs.ContainerStatsDTO; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +public class DockerStatsService { + private final DockerClient dockerClient; + private final SimpMessagingTemplate messagingTemplate; + + public DockerStatsService(DockerClient dockerClient, SimpMessagingTemplate messagingTemplate) { + this.dockerClient = dockerClient; + this.messagingTemplate = messagingTemplate; + } + + @Async + public void startStreamingStats(String containerId) { + dockerClient.statsCmd(containerId).exec(new ResultCallback.Adapter() { + @Override + public void onNext(Statistics stats) { + ContainerStatsDTO dto = calculate(containerId, stats); + messagingTemplate.convertAndSend("/topic/stats/" + containerId, dto); + } + }); + } + + private ContainerStatsDTO calculate(String id, Statistics stats) { + ContainerStatsDTO dto = new ContainerStatsDTO(); + dto.setContainerId(id); + + CpuStatsConfig cpu = stats.getCpuStats(); + CpuStatsConfig preCpu = stats.getPreCpuStats(); + + // ЗАЩИТА: Проверяем наличие системных данных, чтобы избежать NullPointerException + if (cpu == null || preCpu == null || + cpu.getSystemCpuUsage() == null || preCpu.getSystemCpuUsage() == null) { + dto.setCpuUsage(0.0); + return fillMemoryStats(dto, stats); // Память обычно доступна сразу + } + + // 1. Считаем дельту использования процессора контейнером + long cpuUsageDelta = cpu.getCpuUsage().getTotalUsage() - preCpu.getCpuUsage().getTotalUsage(); + + // 2. Считаем дельту системного времени + long systemUsageDelta = cpu.getSystemCpuUsage() - preCpu.getSystemCpuUsage(); + + // 3. Определяем количество ядер + long onlineCpus = 0; + if (cpu.getCpuUsage().getPercpuUsage() != null) { + onlineCpus = cpu.getCpuUsage().getPercpuUsage().size(); + } + if (onlineCpus == 0) { + onlineCpus = Runtime.getRuntime().availableProcessors(); + } + + // 4. Расчет процента с защитой от деления на ноль + if (systemUsageDelta > 0 && cpuUsageDelta > 0) { + double cpuPercent = ((double) cpuUsageDelta / systemUsageDelta) * onlineCpus * 100.0; + dto.setCpuUsage(cpuPercent); + } else { + dto.setCpuUsage(0.0); + } + + return fillMemoryStats(dto, stats); + } + + // Выносим расчет памяти для чистоты кода + private ContainerStatsDTO fillMemoryStats(ContainerStatsDTO dto, Statistics stats) { + if (stats.getMemoryStats() != null) { + long usage = stats.getMemoryStats().getUsage() != null ? stats.getMemoryStats().getUsage() : 0L; + long limit = stats.getMemoryStats().getLimit() != null ? stats.getMemoryStats().getLimit() : 0L; + + dto.setMemoryUsage(usage); + dto.setMemoryLimit(limit); + + if (limit > 0) { + dto.setMemoryPercent((double) usage / limit * 100.0); + } + } + return dto; + } +} \ No newline at end of file diff --git a/src/main/java/com/jambotronGroup/jambotron/projects/ProjectDetailResponse.java b/src/main/java/com/jambotronGroup/jambotron/projects/ProjectDetailResponse.java index a92588a..b89c2de 100644 --- a/src/main/java/com/jambotronGroup/jambotron/projects/ProjectDetailResponse.java +++ b/src/main/java/com/jambotronGroup/jambotron/projects/ProjectDetailResponse.java @@ -2,8 +2,39 @@ package com.jambotronGroup.jambotron.projects; import com.jambotronGroup.jambotron.model.Project; import com.jambotronGroup.jambotron.model.ProjectConfig; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; -public record ProjectDetailResponse( - Project project, - ProjectConfig config -) {} \ No newline at end of file +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProjectDetailResponse { + private Project project; + private ConfigDTO config; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ConfigDTO { + private String projectId; + private String rawComposeContent; + private List serviceNames; // Список сервисов для UI + private Map serviceConfigs; // Поддомены и порты + private Map envVariables; + } + + public ProjectDetailResponse(Project project, ProjectConfig config) { + this.project = project; + this.config = new ConfigDTO( + config.getProjectId(), + config.getRawComposeContent(), + config.getServiceNames(), // Передаем извлеченные имена + config.getServiceConfigs(), + config.getEnvVariables() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/jambotronGroup/jambotron/projects/ProjectService.java b/src/main/java/com/jambotronGroup/jambotron/projects/ProjectService.java index 9f50d7e..3b438a0 100644 --- a/src/main/java/com/jambotronGroup/jambotron/projects/ProjectService.java +++ b/src/main/java/com/jambotronGroup/jambotron/projects/ProjectService.java @@ -1,20 +1,18 @@ 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.model.*; import com.jambotronGroup.jambotron.repository.ProjectConfigRepository; import com.jambotronGroup.jambotron.repository.ProjectRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; import java.io.BufferedReader; import java.io.IOException; @@ -22,11 +20,9 @@ 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.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; import java.util.stream.Collectors; @Service @@ -34,151 +30,302 @@ import java.util.stream.Collectors; public class ProjectService { private final ProjectRepository projectRepository; - private final ProjectConfigRepository configRepository; - + private final SimpMessagingTemplate messagingTemplate; private static final Logger _logger = LoggerFactory.getLogger(ProjectService.class); + private static final DateTimeFormatter L_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + // --- Вспомогательные методы логирования --- + + private void logToConsole(UUID projectId, String message) { + String timestamp = LocalDateTime.now().format(L_TIME_FORMATTER); + String formattedMessage = String.format("[%s] %s", timestamp, message); + _logger.info("[Project-{}]: {}", projectId, message); + messagingTemplate.convertAndSend("/topic/deploy/" + projectId, formattedMessage); + } + + // --- Базовые операции CRUD --- + + public Project save(Project project) { + return projectRepository.save(project); + } public Project findById(UUID id) { return projectRepository.findById(id) .orElseThrow(() -> new RuntimeException("Project not found with id: " + id)); } - /** - * Получение всех проектов пользователя (для Dashboard). - * Возвращает список проектов, принадлежащих конкретному владельцу. - */ public List findAllByOwnerId(Long ownerId) { - _logger.info("Запрос списка проектов для пользователя: {}", ownerId); return projectRepository.findAllByUserId(ownerId); } - /** - * Получение всех проектов в системе (для Admin Panel). - * Позволяет администратору видеть состояние всех развернутых мощностей. - */ - public List 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 + "' уже существует у данного пользователя" - ); + throw new RuntimeException("Проект с таким именем уже существует"); } - _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()); + Path projectDir = GlobalConstants.PATH_PROJECTS.resolve(project.getId().toString()); Files.createDirectories(projectDir); - project.setLocalPath(projectDir.toString()); - projectRepository.save(project); ProjectConfig config = ProjectConfig.builder() .projectId(String.valueOf(project.getId())) .rawComposeContent(rawComposeContent) + .serviceNames(extractServiceNames(rawComposeContent)) .build(); configRepository.save(config); - return project; + return projectRepository.save(project); } catch (IOException e) { - _logger.error("FS Error during project creation: {}", projectId, e); + _logger.error("Ошибка при создании директории проекта", e); throw new RuntimeException("Ошибка инициализации хранилища"); } } - /** - * Основной процесс деплоя. - * Метод не помечен @Transactional целиком, чтобы статус STARTING - * зафиксировался в БД до начала выполнения долгой команды docker compose. - */ - public void deployProject(Project project) { + @Transactional + public void updateProjectConfig(UUID projectId, ProjectConfig newConfig) { + ProjectConfig existing = configRepository.findByProjectId(String.valueOf(projectId)) + .orElseThrow(() -> new RuntimeException("Configuration not found")); - _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()); + // Валидация поддоменов перед сохранением + validateSubdomains(newConfig); - // 3. Генерируем файлы конфигурации - prepareFiles(project, projectPath); - _logger.info("Ready to deploy project at: {}", projectPath); + existing.setRawComposeContent(newConfig.getRawComposeContent()); + existing.setServiceConfigs(newConfig.getServiceConfigs()); + existing.setServiceNames(extractServiceNames(newConfig.getRawComposeContent())); + existing.setVersion(existing.getVersion() + 1); + configRepository.save(existing); + } - // 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); + private void validateSubdomains(ProjectConfig config) { + if (config.getServiceConfigs() == null) return; + for (var entry : config.getServiceConfigs().values()) { + String sub = entry.getSubdomain(); + if (sub != null && !sub.isEmpty()) { + if (!sub.matches("^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")) { + throw new RuntimeException("Некорректный формат поддомена (только латиница, цифры и дефис): " + sub); + } + // Проверка на занятость поддомена другими проектами + List conflicts = configRepository.findAllByAnyServiceSubdomain(sub); + if (conflicts.stream().anyMatch(c -> !c.getProjectId().equals(config.getProjectId()))) { + throw new RuntimeException("Поддомен '" + sub + "' уже занят!"); + } } - } catch (Exception e) { - _logger.error("Critical error during deploy: {}", e.getMessage()); - updateStatusInNewTransaction(project.getId(), ProjectStatus.ERROR); } } + private List extractServiceNames(String yamlContent) { + try { + Yaml yaml = new Yaml(); + Map obj = yaml.load(yamlContent); + Map services = (Map) obj.get("services"); + return services != null ? new ArrayList<>(services.keySet()) : List.of(); + } catch (Exception e) { return List.of(); } + } + + // --- Жизненный цикл (Deploy, Stop, Delete) --- + + @Transactional + public void deployProject(Project project) { + UUID projectId = project.getId(); + String projectIdStr = String.valueOf(projectId); + Path projectPath = Paths.get(project.getLocalPath()); + + logToConsole(projectId, "Инициация созидания окружения..."); + updateStatusInNewTransaction(projectId, ProjectStatus.STARTING); + + // 1. Извлекаем доменные настройки из MongoDB + ProjectConfig config = configRepository.findByProjectId(projectIdStr).orElse(null); + + // 2. Внедряем метки Caddy в пользовательский docker-compose.yml + assert config != null; + String finalComposeContent = injectCaddyLabels(config.getRawComposeContent(), config); + + try { + Files.createDirectories(projectPath); + + // Записываем уже модифицированный файл со всеми доменами + Files.writeString(projectPath.resolve("docker-compose.yml"), finalComposeContent); + + logToConsole(projectId, "Конфигурация Caddy подготовлена. Запуск контейнеров..."); + + ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d", "--remove-orphans"); + pb.directory(projectPath.toFile()); + Process process = pb.start(); + + readProcessOutputAsync(projectIdStr, process); + + int exitCode = process.waitFor(); + if (exitCode == 0) { + project.setStatus(ProjectStatus.RUNNING); + logToConsole(projectId, "Все службы успешно запущены и подключены к шлюзу Caddy."); + } else { + project.setStatus(ProjectStatus.ERROR); + logToConsole(projectId, "Ошибка сборки стека. Код возврата: " + exitCode); + } + } catch (Exception e) { + project.setStatus(ProjectStatus.ERROR); + logToConsole(projectId, "Критическая ошибка: " + e.getMessage()); + } + projectRepository.save(project); + } + /** - * Вспомогательный метод для атомарного обновления статуса + * Обогащает исходный docker-compose.yml метками для Caddy reverse proxy + * на основе конфигурации проекта из MongoDB. */ + public String injectCaddyLabels(String rawComposeContent, ProjectConfig config) { + if (config == null || config.getServiceConfigs() == null || config.getServiceConfigs().isEmpty()) { + return rawComposeContent; + } + + try { + Yaml yaml = new Yaml(); + Map composeMap = yaml.load(rawComposeContent); + + Map services = (Map) composeMap.get("services"); + if (services == null) { + return rawComposeContent; + } + + // Перебираем конфигурации сервисов, сохраненные в MongoDB + config.getServiceConfigs().forEach((serviceName, serviceSetting) -> { + if (services.containsKey(serviceName) && serviceSetting.getSubdomain() != null && !serviceSetting.getSubdomain().isEmpty()) { + Map service = (Map) services.get(serviceName); + + // Получаем существующий блок labels или создаем новый, если его нет + Map labels = (Map) service.get("labels"); + if (labels == null) { + labels = new HashMap<>(); + } + + String domain = serviceSetting.getSubdomain(); + // Если внутренний порт не задан, по умолчанию шлем на 80 + String internalPort = serviceSetting.getPort() != null ? serviceSetting.getPort().toString() : "80"; + + // Прописываем директивы созидания для caddy-docker-proxy + labels.put("caddy", domain); + labels.put("caddy.reverse_proxy", "{{upstreams " + internalPort + "}}"); + + service.put("labels", labels); + } + }); + + // Настраиваем красивый вывод структуры YAML (BLOCK style) + DumperOptions options = new DumperOptions(); + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + Yaml dumper = new Yaml(options); + + return dumper.dump(composeMap); + + } catch (Exception e) { + _logger.error("Ошибка при модификации YAML структуры для Caddy", e); + // Если упал парсинг, возвращаем исходный текст, чтобы не сломать деплой целиком + return rawComposeContent; + } + } + + private void prepareEnhancedFiles(Project project, Path projectPath) throws IOException { + ProjectConfig config = configRepository.findByProjectId(String.valueOf(project.getId())) + .orElseThrow(() -> new RuntimeException("Config not found")); + + if (!Files.exists(projectPath)) Files.createDirectories(projectPath); + + Yaml yaml = new Yaml(getDumperOptions()); + Map compose = yaml.load(config.getRawComposeContent()); + Map services = (Map) compose.get("services"); + + if (services != null) { + services.forEach((name, obj) -> { + Map service = (Map) obj; + // Уникальное имя контейнера для изоляции + service.put("container_name", "jambotron-" + project.getId() + "-" + name); + + // Инъекция меток Caddy для автоматического SSL и проксирования + if (config.getServiceConfigs() != null && config.getServiceConfigs().containsKey(name)) { + var settings = config.getServiceConfigs().get(name); + if (settings.getSubdomain() != null && !settings.getSubdomain().isEmpty()) { + Map labels = (Map) service.getOrDefault("labels", new HashMap()); + labels.put("caddy", settings.getSubdomain() + ".jambotron.com"); + labels.put("caddy.reverse_proxy", "{{upstreams " + settings.getPort() + "}}"); + service.put("labels", labels); + } + } + }); + } + Files.writeString(projectPath.resolve("docker-compose.yml"), yaml.dump(compose)); + } + + private DumperOptions getDumperOptions() { + DumperOptions options = new DumperOptions(); + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + options.setPrettyFlow(true); + return options; + } + + private int runComposeUp(Path projectPath, UUID projectId) { + ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d", "--build"); + pb.directory(projectPath.toFile()); + pb.redirectErrorStream(true); + try { + Process process = pb.start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + messagingTemplate.convertAndSend("/topic/deploy/" + projectId, line); + } + } + return process.waitFor(); + } catch (Exception e) { return -1; } + } + + @Transactional + public void stopProject(Project project) { + UUID projectId = project.getId(); + logToConsole(projectId, "Остановка служб..."); + try { + ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down"); + pb.directory(Paths.get(project.getLocalPath()).toFile()); + if (pb.start().waitFor() == 0) { + project.setStatus(ProjectStatus.STOPPED); + logToConsole(projectId, "Службы остановлены."); + } + } catch (Exception e) { logToConsole(projectId, "Ошибка: " + e.getMessage()); } + projectRepository.save(project); + } + + @Transactional + public void deleteProject(UUID projectId) { + Project project = findById(projectId); + Path projectPath = Paths.get(project.getLocalPath()); + logToConsole(projectId, "ПОЛНОЕ УДАЛЕНИЕ ресурсов..."); + try { + ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down", "-v", "--rmi", "all"); + pb.directory(projectPath.toFile()); + pb.start().waitFor(); + + if (Files.exists(projectPath)) { + try (var stream = Files.walk(projectPath)) { + stream.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(java.io.File::delete); + } + } + configRepository.deleteByProjectId(String.valueOf(projectId)); + projectRepository.delete(project); + logToConsole(projectId, "Проект удален."); + } catch (Exception e) { logToConsole(projectId, "Ошибка: " + e.getMessage()); } + } + @Transactional public void updateStatusInNewTransaction(UUID projectId, ProjectStatus status) { Project project = findById(projectId); @@ -186,191 +333,118 @@ public class ProjectService { 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); + /** + * Получение актуального списка ВСЕХ контейнеров для конкретного проекта. + * Добавлен флаг --all, чтобы показывать даже выключенные службы. + */ + public List getProjectContainers(UUID projectId) { + Project project = findById(projectId); + List statuses = new ArrayList<>(); try { - _logger.info("Запуск docker compose up для проекта: {}", projectId); + // Флаг --all заставляет docker compose вернуть информацию обо всех контейнерах стека + ProcessBuilder pb = new ProcessBuilder("docker", "compose", "ps", "--all", "--format", "json"); + pb.directory(Paths.get(project.getLocalPath()).toFile()); Process process = pb.start(); - // 1. Читаем поток вывода в реальном времени - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getInputStream()))) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { - // Логируем в консоль сервера (из этого потока наш DockerLogsHandler - // будет забирать данные для передачи в Angular) - _logger.info("[Docker-{}]: {}", projectId, line); + if (line.trim().isEmpty()) continue; - // УДАЛЕНО: messagingTemplate.convertAndSend(...) - // Теперь логи транслирует DockerLogsHandler напрямую из Docker-сокета. + // Парсим строку вывода и собираем неизменяемый record + statuses.add(new ContainerStatus( + extractValue(line, "ID"), + extractValue(line, "Name"), + extractValue(line, "Image"), + extractValue(line, "State"), + extractValue(line, "Status") + )); } } - - 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); - } + process.waitFor(); } 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); + _logger.error("Ошибка при получении статусов всех контейнеров для проекта {}", projectId, e); } + return statuses; } /** - * Универсальный метод для запуска быстрых команд Docker без чтения логов в реальном времени + * Перезапуск конкретного контейнера внутри проекта. + * Выполняет команду: docker compose restart */ - 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 void restartContainer(UUID projectId, String containerId) { + executeContainerAction(projectId, containerId, "restart", "перезапущен"); } - public List getProjectContainers(UUID projectId) { - Project project = findById(projectId); - Path projectPath = Paths.get(project.getLocalPath()); + /** + * Остановка конкретного контейнера внутри проекта. + * Выполняет команду: docker compose stop + */ + public void stopContainer(UUID projectId, String containerId) { + executeContainerAction(projectId, containerId, "stop", "остановлен"); + } - // Формат: ID | Service Name | Image | State | Status - ProcessBuilder pb = new ProcessBuilder( - "docker", "compose", "ps", "--format", "{{.ID}}|{{.Service}}|{{.Image}}|{{.State}}|{{.Status}}" - ); - pb.directory(projectPath.toFile()); + /** + * Запуск конкретного контейнера внутри проекта. + * Выполняет команду: docker compose start + */ + public void startContainer(UUID projectId, String containerId) { + executeContainerAction(projectId, containerId, "start", "запущен"); + } + + /** + * Унифицированный приватный метод для выполнения команд над контейнерами Docker Compose. + */ + private void executeContainerAction(UUID projectId, String containerId, String action, String actionPastTense) { + String pIdStr = String.valueOf(projectId); + + logToConsole(projectId, "Инициация операции [" + action + "] для контейнера ID: " + containerId); try { + // Вызываем напрямую docker stop/start/restart без compose + ProcessBuilder pb = new ProcessBuilder("docker", action, containerId); + 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()); + readProcessOutputAsync(pIdStr, process); + + int exitCode = process.waitFor(); + if (exitCode == 0) { + logToConsole(projectId, "Контейнер " + containerId + " успешно " + actionPastTense + "."); + } else { + logToConsole(projectId, "Ошибка операции [" + action + "]. Код возврата: " + exitCode); } - } catch (IOException e) { - _logger.error("Ошибка при получении списка контейнеров для проекта {}: {}", projectId, e.getMessage()); - return List.of(); + + } catch (IOException | InterruptedException e) { + _logger.error("Исключение при операции {} над контейнером {} в проекте {}", action, containerId, projectId, e); + logToConsole(projectId, "Критическая ошибка созидания: " + e.getMessage()); + Thread.currentThread().interrupt(); } } + + private String extractValue(String line, String key) { + try { + String search = "\"" + key + "\":\""; + int start = line.indexOf(search); + if (start == -1) return "unknown"; + start += search.length(); + int end = line.indexOf("\"", start); + return line.substring(start, end); + } catch (Exception e) { + return "unknown"; + } + } + + private void readProcessOutputAsync(String projectIdStr, Process process) { + new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + logToConsole(UUID.fromString(projectIdStr), line); + } + } catch (IOException e) { + _logger.error("Ошибка асинхронного чтения логов процесса для проекта {}", projectIdStr, e); + } + }).start(); + } } \ No newline at end of file diff --git a/src/main/java/com/jambotronGroup/jambotron/repository/ProjectConfigRepository.java b/src/main/java/com/jambotronGroup/jambotron/repository/ProjectConfigRepository.java index 5f3a62a..acda18b 100644 --- a/src/main/java/com/jambotronGroup/jambotron/repository/ProjectConfigRepository.java +++ b/src/main/java/com/jambotronGroup/jambotron/repository/ProjectConfigRepository.java @@ -2,15 +2,24 @@ package com.jambotronGroup.jambotron.repository; import com.jambotronGroup.jambotron.model.ProjectConfig; import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository public interface ProjectConfigRepository extends MongoRepository { - // Нам часто нужно будет доставать конфиг именно по ID проекта из Postgres + // Достаем конфиг по ID проекта из Postgres Optional findByProjectId(String projectId); // Удаление конфига при удалении проекта void deleteByProjectId(String projectId); -} + + /** + * Поиск всех конфигураций, где хотя бы у одного сервиса в Map совпадает subdomain. + * Используем dot-notation для поиска по значениям в Map (serviceConfigs.values.subdomain). + */ + @Query("{ 'serviceConfigs.values.subdomain': ?0 }") + List findAllByAnyServiceSubdomain(String subdomain); +} \ No newline at end of file