EDIT subdomen

This commit is contained in:
2026-05-17 01:05:04 +03:00
parent bae059df3f
commit 584fa9e320
13 changed files with 1075 additions and 432 deletions
+50
View File
@@ -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 — принудительное завершение.
@@ -16,15 +16,116 @@
<div class="containers-grid"> <div class="containers-grid">
@for (container of containers; track container.name) { @for (container of containers; track container.name) {
<mat-card class="container-card"> <mat-card class="container-card">
<mat-card-content class="card-layout"> <mat-card-content class="flex flex-col p-4">
<div class="card-left">
<span class="c-name">{{ container.name }}</span> <div class="card-layout flex w-full justify-between items-start gap-4">
<span class="c-image">{{ container.image }}</span>
<div class="card-left flex-1 min-w-0">
<span class="c-name block text-base font-bold text-gray-150 truncate" [title]="container.name">
{{ container.name }}
</span>
<span class="c-image block text-xs text-gray-500 font-mono mt-0.5 truncate" [title]="container.image">
{{ container.image }}
</span>
@if (containerStats.get(container.id); as stats) {
<div class="stats-row" style="font-size: 11px; color: #888; margin-top: 6px;">
CPU: {{ stats.cpuUsage | number:'1.1-1' }}% | RAM: {{ stats.memoryPercent | number:'1.0-0' }}%
</div>
}
</div>
<div class="card-right flex flex-col align-items-end justify-between items-end gap-2 shrink-0">
<div class="actions-row flex gap-1">
<button mat-icon-button (click)="toggleContainer(container)"
[color]="container.state === 'running' ? 'warn' : 'primary'" class="!w-8 !h-8 !line-height-[32px]">
<mat-icon class="!text-lg">{{ container.state === 'running' ? 'stop' : 'play_arrow' }}</mat-icon>
</button>
<button mat-icon-button (click)="restartContainer(container.id)" style="color: #ffbd2e;" class="!w-8 !h-8 !line-height-[32px]">
<mat-icon class="!text-lg">refresh</mat-icon>
</button>
</div>
<div class="flex items-center gap-2 mt-1">
<div class="state-dot" [class.running]="container.state === 'running'"></div>
<span class="uptime text-xs text-gray-400 font-mono">{{ container.status }}</span>
</div>
</div>
</div> </div>
<div class="card-right">
<div class="state-dot" [class.running]="container.state === 'running'"></div> <div class="w-full mt-4">
<span class="uptime">{{ container.status }}</span> <div class="p-2.5 bg-gray-950/40 rounded-lg border border-gray-850 flex flex-col justify-center min-h-[52px]">
<div *ngIf="!editingDomains[container.id]" class="flex items-center justify-between gap-2 w-full">
<div class="flex flex-col gap-0.5">
<span class="text-[10px] uppercase tracking-wider font-bold text-gray-500">Маршрутизация сети:</span>
<ng-container *ngIf="getContainerDomain(container.name) as domain; else noDomain">
<div class="flex items-center gap-1.5 mt-0.5">
<a [href]="'https://' + domain" target="_blank" class="text-xs font-semibold text-blue-400 hover:text-blue-300 hover:underline flex items-center gap-1 font-mono">
<svg class="w-3 h-3 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h14a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
<span class="truncate max-w-[180px]">{{ domain }}</span>
</a>
<span class="text-[10px] text-gray-500 font-mono bg-gray-900 px-1.5 py-0.25 rounded border border-gray-800 shrink-0">
:{{ getContainerPort(container.name) }}
</span>
</div>
</ng-container>
<ng-template #noDomain>
<span class="text-xs text-gray-600 italic mt-0.5">Локальный стек (нет публичного прокси)</span>
</ng-template>
</div>
<button (click)="startEditDomain(container)" class="p-1 text-gray-500 hover:text-blue-400 hover:bg-gray-900 rounded border border-transparent hover:border-gray-850 transition shadow-sm shrink-0" title="Изменить настройки Caddy">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
</svg>
</button>
</div>
<div *ngIf="editingDomains[container.id]" class="w-full space-y-2 animate-fadeIn">
<div class="flex items-center justify-between border-b border-gray-850 pb-1 mb-1">
<span class="text-[9px] uppercase tracking-wider font-bold text-blue-400">Настройка шлюза</span>
<div class="flex gap-1.5">
<button (click)="saveDomainConfig(container)" class="bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold px-2 py-0.5 rounded transition shadow-md">
OK
</button>
<button (click)="cancelEditDomain(container.id)" class="bg-gray-800 hover:bg-gray-750 text-gray-400 text-[10px] font-medium px-2 py-0.5 rounded transition">
Отмена
</button>
</div>
</div>
<div class="grid grid-cols-3 gap-1.5">
<div class="col-span-2 relative flex items-center">
<input
type="text"
[(ngModel)]="tempSubdomain"
placeholder="subdomain"
class="w-full bg-gray-900 border border-gray-800 rounded pl-2 pr-12 py-1 text-xs text-gray-200 font-mono focus:outline-none focus:border-blue-600 lowercase"
/>
<span class="absolute right-2 text-[9px] font-mono text-gray-600 pointer-events-none">.jamb</span>
</div>
<div>
<input
type="number"
[(ngModel)]="tempPort"
placeholder="Порт"
class="w-full bg-gray-900 border border-gray-800 rounded px-2 py-1 text-xs text-gray-200 font-mono focus:outline-none focus:border-blue-600"
title="Внутренний порт службы внутри docker-compose"
/>
</div>
</div>
</div>
</div>
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
} }
@@ -3,18 +3,26 @@ import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked }
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Subscription, timer, switchMap, catchError, of } from 'rxjs'; 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 { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDividerModule } from '@angular/material/divider'; import { MatDividerModule } from '@angular/material/divider';
import { MatCard, MatCardContent } from '@angular/material/card'; import { MatCard, MatCardContent } from '@angular/material/card';
import {FormsModule} from '@angular/forms';
@Component({ @Component({
selector: 'app-project-details', selector: 'app-project-details',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
MatIconModule, MatIconModule,
MatButtonModule, MatButtonModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
@@ -66,23 +74,39 @@ export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChec
}); });
} }
// Опитування стану контейнерів кожні 5 секунд
private startMonitoring(id: string): void { private startMonitoring(id: string): void {
this.pollingSub = timer(0, 5000).pipe( this.pollingSub = timer(0, 5000).pipe(
switchMap(() => this.projectService.getProjectContainers(id).pipe( switchMap(() => this.projectService.getProjectContainers(id).pipe(
catchError(() => of([])) // У разі помилки повертаємо порожній список catchError(() => of([]))
)) ))
).subscribe({ ).subscribe({
next: (data: DockerContainer[]) => { 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 = 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) { if (this.containers.length > 0 && !this.logSub) {
const mainContainer = this.containers.find(c => c.state === 'running') || this.containers[0]; const mainContainer = this.containers.find(c => c.state === 'running') || this.containers[0];
this.connectLogs(mainContainer.id); this.connectLogs(mainContainer.id);
} }
}, }
error: (err) => console.error('Помилка моніторингу:', err)
}); });
} }
@@ -111,6 +135,16 @@ export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChec
deploy(): void { deploy(): void {
if (this.project) { if (this.project) {
this.loading = true; 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({ this.projectService.deployProject(this.project.id).subscribe({
next: () => this.loading = false, next: () => this.loading = false,
error: () => this.loading = false error: () => this.loading = false
@@ -148,7 +182,12 @@ export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChec
ngOnDestroy(): void { ngOnDestroy(): void {
this.pollingSub?.unsubscribe(); this.pollingSub?.unsubscribe();
this.logSub?.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; return formatted;
} }
containerStats: Map<string, ContainerStats> = new Map();
private statsSubs: Map<string, Subscription> = new Map();
// Новый метод для живых метрик
private subscribeToContainerStats(containerId: string): void {
const sub = this.projectService.connectToStats(containerId).subscribe({
next: (stats) => {
this.containerStats.set(containerId, stats);
},
error: (err) => console.error(`Stats error for ${containerId}:`, err)
});
this.statsSubs.set(containerId, sub);
}
// Переключение состояния (Start/Stop)
toggleContainer(container: DockerContainer): void {
if (!this.project) return;
const obs = container.state === 'running'
? this.projectService.stopContainer(this.project.id, container.id)
: this.projectService.startContainer(this.project.id, container.id);
obs.subscribe({
next: () => console.log(`Статус контейнера ${container.id} изменен`),
error: (err) => console.error('Ошибка управления:', err)
});
}
// Рестарт
restartContainer(containerId: string): void {
if (this.project) {
this.projectService.restartContainer(this.project.id, containerId).subscribe({
next: () => console.log(`Контейнер ${containerId} перезапущен`),
error: (err) => console.error('Ошибка рестарта:', err)
});
}
}
// ==========================================
// CADDY ROUTING & INLINE EDITING MANAGEMENT
// ==========================================
// Хранит состояние редактирования для каждого контейнера: { [containerId]: boolean }
editingDomains: { [key: string]: boolean } = {};
// Временные переменные для хранения ввода пользователя во время редактирования
tempSubdomain: string = '';
tempPort: number = 80;
/**
* Извлекает имя сервиса (например, 'web') из полного имени Docker-контейнера
*/
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;
}
} }
@@ -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({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
@@ -123,4 +146,62 @@ export class ProjectService {
getProjectContainers(projectId: string): Observable<DockerContainer[]> { getProjectContainers(projectId: string): Observable<DockerContainer[]> {
return this.http.get<DockerContainer[]>(`${this.baseUrl}/user/projects/${projectId}/containers`); return this.http.get<DockerContainer[]>(`${this.baseUrl}/user/projects/${projectId}/containers`);
} }
// Добавь в project.service.ts
public connectToDeployLogs(projectId: string): Observable<string> {
const deploySubject = new Subject<string>();
const socket = new SockJS(`${this.baseUrl}/ws-jambotron`);
const client = new Client({
webSocketFactory: () => socket,
onConnect: () => {
// Подписываемся на системный топик деплоя
client.subscribe(`/topic/deploy/${projectId}`, (message: IMessage) => {
deploySubject.next(message.body);
});
}
});
client.activate();
return deploySubject.asObservable();
}
public connectToStats(containerId: string): Observable<ContainerStats> {
const statsSubject = new Subject<ContainerStats>();
const socket = new SockJS(`${this.baseUrl}/ws-jambotron`);
const client = new Client({
webSocketFactory: () => socket,
onConnect: () => {
client.subscribe(`/topic/stats/${containerId}`, (message) => {
statsSubject.next(JSON.parse(message.body));
});
// Сигнал к началу сбора
client.publish({ destination: '/app/start-stats', body: containerId });
}
});
client.activate();
return statsSubject.asObservable();
}
restartContainer(projectId: string, containerId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/restart`, {});
}
stopContainer(projectId: string, containerId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/stop`, {});
}
startContainer(projectId: string, containerId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/start`, {});
}
// Получить конфигурацию поддоменов из MongoDB
getProjectConfig(projectId: string): Observable<ProjectConfig> {
return this.http.get<ProjectConfig>(`${this.baseUrl}/user/projects/${projectId}/config`);
}
// Обновить конфигурацию (сохранить измененные домены и порты)
updateProjectConfig(projectId: string, config: ProjectConfig): Observable<ProjectConfig> {
return this.http.put<ProjectConfig>(`${this.baseUrl}/user/projects/${projectId}/config`, config);
}
} }
@@ -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; // Процент использования памяти
}
@@ -1,6 +1,9 @@
package com.jambotronGroup.jambotron.controllers; package com.jambotronGroup.jambotron.controllers;
import com.jambotronGroup.jambotron.console.DockerLogService; 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.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
@@ -9,8 +12,13 @@ public class LogController {
private final DockerLogService dockerLogService; 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.dockerLogService = dockerLogService;
this.dockerStatsService = dockerStatsService;
} }
// Когда клиент шлет сообщение на /app/start-logs // Когда клиент шлет сообщение на /app/start-logs
@@ -18,4 +26,10 @@ public class LogController {
public void handleStartLogs(String containerId) { public void handleStartLogs(String containerId) {
dockerLogService.streamLogs(containerId); dockerLogService.streamLogs(containerId);
} }
@MessageMapping("/start-stats")
public void handleStartStats(String containerId) {
_logger.info("Начинаем сбор метрик для контейнера: {}", containerId);
dockerStatsService.startStreamingStats(containerId);
}
} }
@@ -1,12 +1,13 @@
package com.jambotronGroup.jambotron.controllers; package com.jambotronGroup.jambotron.controllers;
import com.jambotronGroup.jambotron.model.Project; 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.model.User;
import com.jambotronGroup.jambotron.projects.ContainerStatus; import com.jambotronGroup.jambotron.projects.ContainerStatus;
import com.jambotronGroup.jambotron.projects.ProjectCreateRequest; import com.jambotronGroup.jambotron.projects.ProjectCreateRequest;
import com.jambotronGroup.jambotron.projects.ProjectDetailResponse; import com.jambotronGroup.jambotron.projects.ProjectDetailResponse;
import com.jambotronGroup.jambotron.projects.ProjectService; import com.jambotronGroup.jambotron.projects.ProjectService;
import com.jambotronGroup.jambotron.repository.ProjectConfigRepository;
import com.jambotronGroup.jambotron.repository.UserRepository; import com.jambotronGroup.jambotron.repository.UserRepository;
import com.jambotronGroup.jambotron.security.AuthenticationFacade; import com.jambotronGroup.jambotron.security.AuthenticationFacade;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -15,7 +16,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -29,181 +29,141 @@ public class ProjectController {
private static final Logger _logger = LoggerFactory.getLogger(ProjectController.class); private static final Logger _logger = LoggerFactory.getLogger(ProjectController.class);
private final ProjectService projectService; private final ProjectService projectService;
private final ProjectConfigRepository configRepository; // <-- Это то, что я чуть не забыла!
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuthenticationFacade authenticationFacade; private final AuthenticationFacade authenticationFacade;
//-------------------- Просмотр и Dashboard ---------------------------- // --- Просмотр списка и дашборд ---
/**
* Получение списка проектов текущего пользователя (Dashboard)[cite: 2, 3].
*/
@GetMapping("/user/projects") @GetMapping("/user/projects")
public ResponseEntity<List<Project>> getUserProjects() { public ResponseEntity<List<Project>> getUserProjects() {
try { User user = getCurrentUser();
User user = getCurrentUser(); List<Project> projects = projectService.findAllByOwnerId(user.getId());
List<Project> projects = projectService.findAllByOwnerId(user.getId()); return projects.isEmpty() ? new ResponseEntity<>(HttpStatus.NO_CONTENT) : ResponseEntity.ok(projects);
if (projects.isEmpty()) {
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
return new ResponseEntity<>(projects, HttpStatus.OK);
} catch (Exception e) {
_logger.error("Error fetching user projects", e);
return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
}
} }
/**
* Детали проекта (Project + Config). Доступно владельцу или админу.
*/
@GetMapping("/user/projects/{id}/details") @GetMapping("/user/projects/{id}/details")
public ResponseEntity<ProjectDetailResponse> getProjectDetails(@PathVariable UUID id) { public ResponseEntity<ProjectDetailResponse> getProjectDetails(@PathVariable UUID id) {
try {
User user = getCurrentUser();
boolean isAdmin = checkIfAdmin(user);
// Метод сервиса сам проверит права владельца, если isAdmin = false
ProjectDetailResponse details = projectService.getProjectDetailsSecure(
id,
user.getId(),
isAdmin
);
return new ResponseEntity<>(details, HttpStatus.OK);
} catch (AccessDeniedException e) {
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
} catch (Exception e) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
//-------------------- Управление Жизненным Циклом (User & Admin) ----------------------------
/**
* Деплой проекта (Docker Compose Up). Доступно владельцу или админу[cite: 1, 4].
*/
@PostMapping("/user/projects/{id}/deploy")
public ResponseEntity<String> deployProject(@PathVariable UUID id) {
// Получаем текущего пользователя (твой метод)
User user = getCurrentUser();
// Используем метод сервиса findById, а не репозиторий напрямую!
Project project = projectService.findById(id);
// Выполняем проверку безопасности (через String.valueOf, как мы выяснили)
if (checkIfAdmin(user) ||
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
// ВАЖНО: В твоем сервисе метод называется deployProject(Project project)
projectService.deployProject(project);
return ResponseEntity.ok("Процесс созидания запущен...");
}
return ResponseEntity.status(403).body("Access Denied");
}
/**
* Остановка проекта (Docker Compose Down). Доступно владельцу или админу[cite: 1, 4].
*/
@PostMapping("/user/projects/{id}/stop")
public ResponseEntity<String> stopProject(@PathVariable UUID id) {
User user = getCurrentUser(); User user = getCurrentUser();
Project project = projectService.findById(id); Project project = projectService.findById(id);
if (!checkIfAdmin(user) && !project.getUser().getId().equals(String.valueOf(user.getId()))) { if (!hasAccess(user, project)) return new ResponseEntity<>(HttpStatus.FORBIDDEN);
return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN);
}
projectService.stopProject(project); // Тянем конфиг из Mongo для детального отображения
project.setStatus(ProjectStatus.STOPPED); ProjectConfig config = configRepository.findByProjectId(String.valueOf(id))
projectService.save(project); .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<String> deleteProject(@PathVariable UUID id) {
User user = getCurrentUser();
Project project = projectService.findById(id);
// Безопасная проверка владельца (уже ставшая стандартом для нашего "сосуда")
if (checkIfAdmin(user) ||
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
_logger.info("Начато полное удаление проекта: {}", id);
projectService.deleteProject(id); // Вызов того самого метода из ProjectService_2.java
return ResponseEntity.ok("Проект полностью удален из системы");
}
return ResponseEntity.status(403).body("Access Denied");
}
//-------------------- Администрирование PaaS ----------------------------
/**
* Список всех проектов системы для глобального мониторинга[cite: 2].
*/
@GetMapping("/admin/projects/all")
public ResponseEntity<List<Project>> getAllProjectsForAdmin() {
User user = getCurrentUser();
if (!checkIfAdmin(user)) {
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
}
List<Project> allProjects = projectService.findAllProjectsForAdmin();
return new ResponseEntity<>(allProjects, HttpStatus.OK);
}
//-------------------- Создание и Конфигурация ----------------------------
@PostMapping("/user/projects") @PostMapping("/user/projects")
public ResponseEntity<Project> create(@Valid @RequestBody ProjectCreateRequest request) { public ResponseEntity<Project> create(@Valid @RequestBody ProjectCreateRequest request) {
User user = getCurrentUser(); User user = getCurrentUser();
Project newProject = projectService.createProject(request.name(), request.rawComposeContent(), user); Project newProject = projectService.createProject(request.name(), request.rawComposeContent(), user);
return ResponseEntity.status(HttpStatus.CREATED).body(newProject); return ResponseEntity.status(HttpStatus.CREATED).body(newProject);
} }
@PutMapping("/user/projects/{id}/config") @PutMapping("/user/projects/{id}/config")
public ResponseEntity<String> updateConfig(@PathVariable UUID id, @RequestBody String newContent) { public ResponseEntity<String> updateConfig(@PathVariable UUID id, @RequestBody ProjectConfig newConfig) {
User user = getCurrentUser(); User user = getCurrentUser();
Project project = projectService.findById(id); Project project = projectService.findById(id);
if (!checkIfAdmin(user) && !project.getUser().getId().equals(String.valueOf(user.getId()))) { if (!hasAccess(user, project)) return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN);
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() { @PostMapping("/user/projects/{id}/deploy")
return userRepository.findById(authenticationFacade.getUserDetails().getId()) public ResponseEntity<String> deployProject(@PathVariable UUID id) {
.orElseThrow(() -> new RuntimeException("Current user not found")); 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) { @PostMapping("/user/projects/{id}/stop")
return user.getRoles() != null && (user.getRoles().equals("ADMIN") || user.getRoles().equals("ROLE_ADMIN")); public ResponseEntity<String> stopProject(@PathVariable UUID id) {
User user = getCurrentUser();
Project project = projectService.findById(id);
if (!hasAccess(user, project)) return new ResponseEntity<>(HttpStatus.FORBIDDEN);
projectService.stopProject(project);
return ResponseEntity.ok("Проект остановлен.");
} }
@DeleteMapping("/user/projects/{id}")
public ResponseEntity<String> deleteProject(@PathVariable UUID id) {
User user = getCurrentUser();
Project project = projectService.findById(id);
if (!hasAccess(user, project)) return ResponseEntity.status(403).body("Access Denied");
projectService.deleteProject(id);
return ResponseEntity.ok("Проект удален.");
}
// --- Управление отдельными контейнерами ---
@GetMapping("/user/projects/{id}/containers") @GetMapping("/user/projects/{id}/containers")
public ResponseEntity<List<ContainerStatus>> getContainers(@PathVariable UUID id) { public ResponseEntity<List<ContainerStatus>> getContainers(@PathVariable UUID id) {
User user = getCurrentUser(); User user = getCurrentUser();
Project project = projectService.findById(id); Project project = projectService.findById(id);
if (!hasAccess(user, project)) return ResponseEntity.status(403).build();
// Применяем нашу "золотую формулу" сравнения ID return ResponseEntity.ok(projectService.getProjectContainers(id));
if (checkIfAdmin(user) || }
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
return ResponseEntity.ok(projectService.getProjectContainers(id)); // Рестарт конкретного контейнера
} @PostMapping("/user/projects/{projectId}/containers/{containerId}/restart")
public ResponseEntity<Void> restartContainer(
@PathVariable("projectId") UUID projectId,
@PathVariable("containerId") String containerId) {
projectService.restartContainer(projectId, containerId);
return ResponseEntity.ok().build();
}
return ResponseEntity.status(403).build(); // Остановка конкретного контейнера
@PostMapping("/user/projects/{projectId}/containers/{containerId}/stop")
public ResponseEntity<Void> stopContainer(
@PathVariable("projectId") UUID projectId,
@PathVariable("containerId") String containerId) {
projectService.stopContainer(projectId, containerId);
return ResponseEntity.ok().build();
}
// Запуск конкретного контейнера
@PostMapping("/user/projects/{projectId}/containers/{containerId}/start")
public ResponseEntity<Void> startContainer(
@PathVariable("projectId") UUID projectId,
@PathVariable("containerId") String containerId) {
projectService.startContainer(projectId, containerId);
return ResponseEntity.ok().build();
}
// --- Утилиты ---
private boolean hasAccess(User user, Project project) {
return checkIfAdmin(user) || String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()));
}
private User getCurrentUser() {
return userRepository.findById(authenticationFacade.getUserDetails().getId())
.orElseThrow(() -> new RuntimeException("User not found"));
}
private boolean checkIfAdmin(User user) {
return user.getRoles() != null && user.getRoles().contains("ADMIN");
} }
} }
@@ -0,0 +1,4 @@
package com.jambotronGroup.jambotron.model;
public class ContainerStats {
}
@@ -4,23 +4,36 @@ import lombok.*;
import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Document;
import java.util.List;
import java.util.Map; import java.util.Map;
@Document(collection = "project_configs") @Document(collection = "project_configs")
@Data // Генерирует геттеры, сеттеры, toString, equals, hashCode @Data
@Builder // Генерирует тот самый метод .builder() @Builder
@NoArgsConstructor // Нужен для десериализации MongoDB @NoArgsConstructor
@AllArgsConstructor // Нужен для работы @Builder @AllArgsConstructor
public class ProjectConfig { public class ProjectConfig {
@Id @Id
private String id; // Внутренний ID Mongo 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<String, String> envVariables; // Ключ-значение для переменных private List<String> serviceNames; // Извлеченные имена сервисов для стабильного UI
private Integer version = 1; // Для отслеживания правок конфига private Map<String, String> envVariables; // Переменные окружения
private Map<String, ServiceSettings> serviceConfigs; // Настройки поддоменов и портов для каждого контейнера
private Integer version = 1; // Версия конфига
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ServiceSettings {
private String subdomain; // Уникальный поддомен (например, lioshik-app-api)
private Integer port; // Внутренний порт контейнера (8080, 80 и т.д.)
}
} }
@@ -0,0 +1,88 @@
package com.jambotronGroup.jambotron.projects;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.model.CpuStatsConfig;
import com.github.dockerjava.api.model.Statistics;
import com.jambotronGroup.jambotron.DTOs.ContainerStatsDTO;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class DockerStatsService {
private final DockerClient dockerClient;
private final SimpMessagingTemplate messagingTemplate;
public DockerStatsService(DockerClient dockerClient, SimpMessagingTemplate messagingTemplate) {
this.dockerClient = dockerClient;
this.messagingTemplate = messagingTemplate;
}
@Async
public void startStreamingStats(String containerId) {
dockerClient.statsCmd(containerId).exec(new ResultCallback.Adapter<Statistics>() {
@Override
public void onNext(Statistics stats) {
ContainerStatsDTO dto = calculate(containerId, stats);
messagingTemplate.convertAndSend("/topic/stats/" + containerId, dto);
}
});
}
private ContainerStatsDTO calculate(String id, Statistics stats) {
ContainerStatsDTO dto = new ContainerStatsDTO();
dto.setContainerId(id);
CpuStatsConfig cpu = stats.getCpuStats();
CpuStatsConfig preCpu = stats.getPreCpuStats();
// ЗАЩИТА: Проверяем наличие системных данных, чтобы избежать NullPointerException
if (cpu == null || preCpu == null ||
cpu.getSystemCpuUsage() == null || preCpu.getSystemCpuUsage() == null) {
dto.setCpuUsage(0.0);
return fillMemoryStats(dto, stats); // Память обычно доступна сразу
}
// 1. Считаем дельту использования процессора контейнером
long cpuUsageDelta = cpu.getCpuUsage().getTotalUsage() - preCpu.getCpuUsage().getTotalUsage();
// 2. Считаем дельту системного времени
long systemUsageDelta = cpu.getSystemCpuUsage() - preCpu.getSystemCpuUsage();
// 3. Определяем количество ядер
long onlineCpus = 0;
if (cpu.getCpuUsage().getPercpuUsage() != null) {
onlineCpus = cpu.getCpuUsage().getPercpuUsage().size();
}
if (onlineCpus == 0) {
onlineCpus = Runtime.getRuntime().availableProcessors();
}
// 4. Расчет процента с защитой от деления на ноль
if (systemUsageDelta > 0 && cpuUsageDelta > 0) {
double cpuPercent = ((double) cpuUsageDelta / systemUsageDelta) * onlineCpus * 100.0;
dto.setCpuUsage(cpuPercent);
} else {
dto.setCpuUsage(0.0);
}
return fillMemoryStats(dto, stats);
}
// Выносим расчет памяти для чистоты кода
private ContainerStatsDTO fillMemoryStats(ContainerStatsDTO dto, Statistics stats) {
if (stats.getMemoryStats() != null) {
long usage = stats.getMemoryStats().getUsage() != null ? stats.getMemoryStats().getUsage() : 0L;
long limit = stats.getMemoryStats().getLimit() != null ? stats.getMemoryStats().getLimit() : 0L;
dto.setMemoryUsage(usage);
dto.setMemoryLimit(limit);
if (limit > 0) {
dto.setMemoryPercent((double) usage / limit * 100.0);
}
}
return dto;
}
}
@@ -2,8 +2,39 @@ package com.jambotronGroup.jambotron.projects;
import com.jambotronGroup.jambotron.model.Project; import com.jambotronGroup.jambotron.model.Project;
import com.jambotronGroup.jambotron.model.ProjectConfig; import com.jambotronGroup.jambotron.model.ProjectConfig;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
public record ProjectDetailResponse( import java.util.List;
Project project, import java.util.Map;
ProjectConfig config
) {} @Data
@NoArgsConstructor
@AllArgsConstructor
public class ProjectDetailResponse {
private Project project;
private ConfigDTO config;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ConfigDTO {
private String projectId;
private String rawComposeContent;
private List<String> serviceNames; // Список сервисов для UI
private Map<String, ProjectConfig.ServiceSettings> serviceConfigs; // Поддомены и порты
private Map<String, String> envVariables;
}
public ProjectDetailResponse(Project project, ProjectConfig config) {
this.project = project;
this.config = new ConfigDTO(
config.getProjectId(),
config.getRawComposeContent(),
config.getServiceNames(), // Передаем извлеченные имена
config.getServiceConfigs(),
config.getEnvVariables()
);
}
}
@@ -1,20 +1,18 @@
package com.jambotronGroup.jambotron.projects; package com.jambotronGroup.jambotron.projects;
import com.jambotronGroup.jambotron.globalConstants.GlobalConstants; import com.jambotronGroup.jambotron.globalConstants.GlobalConstants;
import com.jambotronGroup.jambotron.model.Project; import com.jambotronGroup.jambotron.model.*;
import com.jambotronGroup.jambotron.model.ProjectConfig;
import com.jambotronGroup.jambotron.model.ProjectStatus;
import com.jambotronGroup.jambotron.model.User;
import com.jambotronGroup.jambotron.repository.ProjectConfigRepository; import com.jambotronGroup.jambotron.repository.ProjectConfigRepository;
import com.jambotronGroup.jambotron.repository.ProjectRepository; import com.jambotronGroup.jambotron.repository.ProjectRepository;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
@@ -22,11 +20,9 @@ import java.io.InputStreamReader;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.Comparator; import java.time.LocalDateTime;
import java.util.List; import java.time.format.DateTimeFormatter;
import java.util.Objects; import java.util.*;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@@ -34,151 +30,302 @@ import java.util.stream.Collectors;
public class ProjectService { public class ProjectService {
private final ProjectRepository projectRepository; private final ProjectRepository projectRepository;
private final ProjectConfigRepository configRepository; private final ProjectConfigRepository configRepository;
private final SimpMessagingTemplate messagingTemplate;
private static final Logger _logger = LoggerFactory.getLogger(ProjectService.class); 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) { public Project findById(UUID id) {
return projectRepository.findById(id) return projectRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Project not found with id: " + id)); .orElseThrow(() -> new RuntimeException("Project not found with id: " + id));
} }
/**
* Получение всех проектов пользователя (для Dashboard).
* Возвращает список проектов, принадлежащих конкретному владельцу.
*/
public List<Project> findAllByOwnerId(Long ownerId) { public List<Project> findAllByOwnerId(Long ownerId) {
_logger.info("Запрос списка проектов для пользователя: {}", ownerId);
return projectRepository.findAllByUserId(ownerId); return projectRepository.findAllByUserId(ownerId);
} }
/** // --- Управление конфигурацией и создание ---
* Получение всех проектов в системе (для Admin Panel).
* Позволяет администратору видеть состояние всех развернутых мощностей.
*/
public List<Project> findAllProjectsForAdmin() {
_logger.info("Запрос всех проектов системы администратором");
return projectRepository.findAll();
}
/**
* Получение развернутой информации о проекте (Project + Config).
* Полезно для детального отображения в Dashboard.
*/
public ProjectDetailResponse getProjectDetails(UUID projectId) {
Project project = findById(projectId);
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
.orElseThrow(() -> new RuntimeException("Конфигурация не найдена"));
return new ProjectDetailResponse(project, config);
}
/**
* Безопасное получение деталей проекта.
* @param projectId ID проекта
* @param currentUserId ID пользователя, совершающего запрос
* @param isAdmin флаг, является ли пользователь администратором
*/
public ProjectDetailResponse getProjectDetailsSecure(UUID projectId, Long currentUserId, boolean isAdmin) {
Project project = findById(projectId);
// ПРОВЕРКА БЕЗОПАСНОСТИ:
// Если пользователь не админ и не владелец проекта — доступ запрещен
if (!isAdmin && !Objects.equals(project.getUser().getId(), currentUserId)) {
_logger.warn("Security Alert: User {} tried to access project {} owned by {}",
currentUserId, projectId, project.getUser().getId());
throw new AccessDeniedException("У вас нет прав для просмотра этого проекта");
}
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
.orElseThrow(() -> new RuntimeException("Конфигурация не найдена"));
return new ProjectDetailResponse(project, config);
}
@Transactional @Transactional
public Project createProject(String projectName, String rawComposeContent, User owner) { public Project createProject(String projectName, String rawComposeContent, User owner) {
if (projectRepository.existsByNameAndUserId(projectName, owner.getId())) { if (projectRepository.existsByNameAndUserId(projectName, owner.getId())) {
throw new ProjectAlreadyExistsException( throw new RuntimeException("Проект с таким именем уже существует");
"Проект с именем '" + projectName + "' уже существует у данного пользователя"
);
} }
_logger.info("Валидация пройдена. Создание проекта: {}", projectName);
Project project = new Project(); Project project = new Project();
project.setName(projectName); project.setName(projectName);
project.setUser(owner); project.setUser(owner);
project.setStatus(ProjectStatus.CREATED); project.setStatus(ProjectStatus.CREATED);
project = projectRepository.save(project); project = projectRepository.save(project);
UUID projectId = project.getId();
try { try {
// Используем структуру из GlobalConstants для создания рабочей директории Path projectDir = GlobalConstants.PATH_PROJECTS.resolve(project.getId().toString());
Path projectDir = GlobalConstants.PATH_PROJECTS.resolve(projectId.toString());
Files.createDirectories(projectDir); Files.createDirectories(projectDir);
project.setLocalPath(projectDir.toString()); project.setLocalPath(projectDir.toString());
projectRepository.save(project);
ProjectConfig config = ProjectConfig.builder() ProjectConfig config = ProjectConfig.builder()
.projectId(String.valueOf(project.getId())) .projectId(String.valueOf(project.getId()))
.rawComposeContent(rawComposeContent) .rawComposeContent(rawComposeContent)
.serviceNames(extractServiceNames(rawComposeContent))
.build(); .build();
configRepository.save(config); configRepository.save(config);
return project; return projectRepository.save(project);
} catch (IOException e) { } catch (IOException e) {
_logger.error("FS Error during project creation: {}", projectId, e); _logger.error("Ошибка при создании директории проекта", e);
throw new RuntimeException("Ошибка инициализации хранилища"); throw new RuntimeException("Ошибка инициализации хранилища");
} }
} }
/** @Transactional
* Основной процесс деплоя. public void updateProjectConfig(UUID projectId, ProjectConfig newConfig) {
* Метод не помечен @Transactional целиком, чтобы статус STARTING ProjectConfig existing = configRepository.findByProjectId(String.valueOf(projectId))
* зафиксировался в БД до начала выполнения долгой команды docker compose. .orElseThrow(() -> new RuntimeException("Configuration not found"));
*/
public void deployProject(Project project) {
_logger.info("invoke deployProject"); // Валидация поддоменов перед сохранением
// 1. Устанавливаем промежуточный статус validateSubdomains(newConfig);
updateStatusInNewTransaction(project.getId(), ProjectStatus.STARTING);
_logger.info("{}: chenget status to{}",project.getId(),project.getStatus());
try {
// 2. Путь берем из сущности (он был инициализирован в createProject через GlobalConstants)
Path projectPath = Paths.get(project.getLocalPath());
// 3. Генерируем файлы конфигурации existing.setRawComposeContent(newConfig.getRawComposeContent());
prepareFiles(project, projectPath); existing.setServiceConfigs(newConfig.getServiceConfigs());
_logger.info("Ready to deploy project at: {}", projectPath); existing.setServiceNames(extractServiceNames(newConfig.getRawComposeContent()));
existing.setVersion(existing.getVersion() + 1);
configRepository.save(existing);
}
// 4. Запускаем Docker Compose (блокирующая операция) private void validateSubdomains(ProjectConfig config) {
int exitCode = runComposeUp(projectPath,project.getId()); if (config.getServiceConfigs() == null) return;
for (var entry : config.getServiceConfigs().values()) {
// 5. Финализируем статус на основе результата String sub = entry.getSubdomain();
if (exitCode == 0) { if (sub != null && !sub.isEmpty()) {
updateStatusInNewTransaction(project.getId(), ProjectStatus.RUNNING); if (!sub.matches("^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")) {
_logger.info("Project {} is now RUNNING", project.getId()); throw new RuntimeException("Некорректный формат поддомена (только латиница, цифры и дефис): " + sub);
} else { }
updateStatusInNewTransaction(project.getId(), ProjectStatus.ERROR); // Проверка на занятость поддомена другими проектами
_logger.error("Docker Compose failed with exit code: {}", exitCode); List<ProjectConfig> 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<String> extractServiceNames(String yamlContent) {
try {
Yaml yaml = new Yaml();
Map<String, Object> obj = yaml.load(yamlContent);
Map<String, Object> services = (Map<String, Object>) obj.get("services");
return services != null ? new ArrayList<>(services.keySet()) : List.of();
} catch (Exception e) { return List.of(); }
}
// --- Жизненный цикл (Deploy, Stop, Delete) ---
@Transactional
public void deployProject(Project project) {
UUID projectId = project.getId();
String projectIdStr = String.valueOf(projectId);
Path projectPath = Paths.get(project.getLocalPath());
logToConsole(projectId, "Инициация созидания окружения...");
updateStatusInNewTransaction(projectId, ProjectStatus.STARTING);
// 1. Извлекаем доменные настройки из MongoDB
ProjectConfig config = configRepository.findByProjectId(projectIdStr).orElse(null);
// 2. Внедряем метки Caddy в пользовательский docker-compose.yml
assert config != null;
String finalComposeContent = injectCaddyLabels(config.getRawComposeContent(), config);
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<String, Object> composeMap = yaml.load(rawComposeContent);
Map<String, Object> services = (Map<String, Object>) composeMap.get("services");
if (services == null) {
return rawComposeContent;
}
// Перебираем конфигурации сервисов, сохраненные в MongoDB
config.getServiceConfigs().forEach((serviceName, serviceSetting) -> {
if (services.containsKey(serviceName) && serviceSetting.getSubdomain() != null && !serviceSetting.getSubdomain().isEmpty()) {
Map<String, Object> service = (Map<String, Object>) services.get(serviceName);
// Получаем существующий блок labels или создаем новый, если его нет
Map<String, String> labels = (Map<String, String>) 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<String, Object> compose = yaml.load(config.getRawComposeContent());
Map<String, Object> services = (Map<String, Object>) compose.get("services");
if (services != null) {
services.forEach((name, obj) -> {
Map<String, Object> service = (Map<String, Object>) obj;
// Уникальное имя контейнера для изоляции
service.put("container_name", "jambotron-" + project.getId() + "-" + name);
// Инъекция меток Caddy для автоматического SSL и проксирования
if (config.getServiceConfigs() != null && config.getServiceConfigs().containsKey(name)) {
var settings = config.getServiceConfigs().get(name);
if (settings.getSubdomain() != null && !settings.getSubdomain().isEmpty()) {
Map<String, String> labels = (Map<String, String>) service.getOrDefault("labels", new HashMap<String, String>());
labels.put("caddy", settings.getSubdomain() + ".jambotron.com");
labels.put("caddy.reverse_proxy", "{{upstreams " + settings.getPort() + "}}");
service.put("labels", labels);
}
}
});
}
Files.writeString(projectPath.resolve("docker-compose.yml"), yaml.dump(compose));
}
private DumperOptions getDumperOptions() {
DumperOptions options = new DumperOptions();
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
options.setPrettyFlow(true);
return options;
}
private int runComposeUp(Path projectPath, UUID projectId) {
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d", "--build");
pb.directory(projectPath.toFile());
pb.redirectErrorStream(true);
try {
Process process = pb.start();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
messagingTemplate.convertAndSend("/topic/deploy/" + projectId, line);
}
}
return process.waitFor();
} catch (Exception e) { return -1; }
}
@Transactional
public void stopProject(Project project) {
UUID projectId = project.getId();
logToConsole(projectId, "Остановка служб...");
try {
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down");
pb.directory(Paths.get(project.getLocalPath()).toFile());
if (pb.start().waitFor() == 0) {
project.setStatus(ProjectStatus.STOPPED);
logToConsole(projectId, "Службы остановлены.");
}
} catch (Exception e) { logToConsole(projectId, "Ошибка: " + e.getMessage()); }
projectRepository.save(project);
}
@Transactional
public void deleteProject(UUID projectId) {
Project project = findById(projectId);
Path projectPath = Paths.get(project.getLocalPath());
logToConsole(projectId, "ПОЛНОЕ УДАЛЕНИЕ ресурсов...");
try {
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down", "-v", "--rmi", "all");
pb.directory(projectPath.toFile());
pb.start().waitFor();
if (Files.exists(projectPath)) {
try (var stream = Files.walk(projectPath)) {
stream.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(java.io.File::delete);
}
}
configRepository.deleteByProjectId(String.valueOf(projectId));
projectRepository.delete(project);
logToConsole(projectId, "Проект удален.");
} catch (Exception e) { logToConsole(projectId, "Ошибка: " + e.getMessage()); }
}
@Transactional @Transactional
public void updateStatusInNewTransaction(UUID projectId, ProjectStatus status) { public void updateStatusInNewTransaction(UUID projectId, ProjectStatus status) {
Project project = findById(projectId); Project project = findById(projectId);
@@ -186,191 +333,118 @@ public class ProjectService {
projectRepository.save(project); 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")); * Добавлен флаг --all, чтобы показывать даже выключенные службы.
*/
// Убеждаемся, что директория существует public List<ContainerStatus> getProjectContainers(UUID projectId) {
if (!Files.exists(projectPath)) { Project project = findById(projectId);
Files.createDirectories(projectPath); List<ContainerStatus> statuses = new ArrayList<>();
}
// docker-compose.yml
Path composeFile = projectPath.resolve("docker-compose.yml");
Files.writeString(composeFile, config.getRawComposeContent());
// .env
if (config.getEnvVariables() != null && !config.getEnvVariables().isEmpty()) {
String envContent = config.getEnvVariables().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.collect(Collectors.joining("\n"));
Files.writeString(projectPath.resolve(".env"), envContent);
_logger.info(".env file generated for project: {}", project.getId());
}
}
private int runComposeUp(Path projectPath, UUID projectId) {
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d");
pb.directory(projectPath.toFile());
pb.redirectErrorStream(true);
try { 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(); 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; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
// Логируем в консоль сервера (из этого потока наш DockerLogsHandler if (line.trim().isEmpty()) continue;
// будет забирать данные для передачи в Angular)
_logger.info("[Docker-{}]: {}", projectId, line);
// УДАЛЕНО: messagingTemplate.convertAndSend(...) // Парсим строку вывода и собираем неизменяемый record
// Теперь логи транслирует DockerLogsHandler напрямую из Docker-сокета. statuses.add(new ContainerStatus(
extractValue(line, "ID"),
extractValue(line, "Name"),
extractValue(line, "Image"),
extractValue(line, "State"),
extractValue(line, "Status")
));
} }
} }
process.waitFor();
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
// ... остальная логика завершения процесса ...
return process.exitValue();
} catch (IOException | InterruptedException e) {
_logger.error("Критическая ошибка при выполнении docker compose: {}", e.getMessage());
return -1;
}
}
@Transactional
public void stopProject(Project project) {
Path projectPath = Paths.get(project.getLocalPath());
_logger.info("Stopping project: {}", project.getId());
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down");
pb.directory(projectPath.toFile());
pb.inheritIO();
try {
Process process = pb.start();
int exitCode = process.waitFor();
if (exitCode == 0) {
project.setStatus(ProjectStatus.STOPPED);
_logger.info("Project {} stopped successfully.", project.getId());
} else {
project.setStatus(ProjectStatus.ERROR);
_logger.error("Failed to stop project. Exit code: {}", exitCode);
}
} catch (Exception e) { } catch (Exception e) {
project.setStatus(ProjectStatus.ERROR); _logger.error("Ошибка при получении статусов всех контейнеров для проекта {}", projectId, e);
_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);
} }
return statuses;
} }
/** /**
* Универсальный метод для запуска быстрых команд Docker без чтения логов в реальном времени * Перезапуск конкретного контейнера внутри проекта.
* Выполняет команду: docker compose restart <containerId>
*/ */
private void runCommand(Path directory, String... command) throws IOException, InterruptedException { public void restartContainer(UUID projectId, String containerId) {
ProcessBuilder pb = new ProcessBuilder(command); executeContainerAction(projectId, containerId, "restart", "перезапущен");
pb.directory(directory.toFile());
pb.inheritIO();
Process process = pb.start();
int exitCode = process.waitFor();
if (exitCode != 0) {
_logger.warn("Команда {} завершилась с кодом {}", String.join(" ", command), exitCode);
}
} }
public List<ContainerStatus> getProjectContainers(UUID projectId) { /**
Project project = findById(projectId); * Остановка конкретного контейнера внутри проекта.
Path projectPath = Paths.get(project.getLocalPath()); * Выполняет команду: docker compose stop <containerId>
*/
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}}" * Выполняет команду: docker compose start <containerId>
); */
pb.directory(projectPath.toFile()); 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 { try {
// Вызываем напрямую docker stop/start/restart <id> без compose
ProcessBuilder pb = new ProcessBuilder("docker", action, containerId);
Process process = pb.start(); Process process = pb.start();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { readProcessOutputAsync(pIdStr, process);
return reader.lines()
.map(line -> { int exitCode = process.waitFor();
String[] parts = line.split("\\|"); if (exitCode == 0) {
if (parts.length >= 5) { logToConsole(projectId, "Контейнер " + containerId + " успешно " + actionPastTense + ".");
return new ContainerStatus(parts[0], parts[1], parts[2], parts[3], parts[4]); } else {
} logToConsole(projectId, "Ошибка операции [" + action + "]. Код возврата: " + exitCode);
return null;
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
} }
} catch (IOException e) {
_logger.error("Ошибка при получении списка контейнеров для проекта {}: {}", projectId, e.getMessage()); } catch (IOException | InterruptedException e) {
return List.of(); _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();
}
} }
@@ -2,15 +2,24 @@ package com.jambotronGroup.jambotron.repository;
import com.jambotronGroup.jambotron.model.ProjectConfig; import com.jambotronGroup.jambotron.model.ProjectConfig;
import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
public interface ProjectConfigRepository extends MongoRepository<ProjectConfig, String> { public interface ProjectConfigRepository extends MongoRepository<ProjectConfig, String> {
// Нам часто нужно будет доставать конфиг именно по ID проекта из Postgres // Достаем конфиг по ID проекта из Postgres
Optional<ProjectConfig> findByProjectId(String projectId); Optional<ProjectConfig> findByProjectId(String projectId);
// Удаление конфига при удалении проекта // Удаление конфига при удалении проекта
void deleteByProjectId(String projectId); void deleteByProjectId(String projectId);
}
/**
* Поиск всех конфигураций, где хотя бы у одного сервиса в Map совпадает subdomain.
* Используем dot-notation для поиска по значениям в Map (serviceConfigs.values.subdomain).
*/
@Query("{ 'serviceConfigs.values.subdomain': ?0 }")
List<ProjectConfig> findAllByAnyServiceSubdomain(String subdomain);
}