-
-
{{ container.status }}
+
+
+
+
+
+
+
Маршрутизация сети:
+
+
+
+
+
+
+ Локальный стек (нет публичного прокси)
+
+
+
+
+
+
+
+
+
+
+
+
+
Настройка шлюза
+
+
+ OK
+
+
+ Отмена
+
+
+
+
+
+
+
+
+
}
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