add project
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
# This is an example configuration file
|
||||||
|
# To learn more, see the full config.yaml reference: https://docs.continue.dev/reference
|
||||||
|
|
||||||
|
name: Example Config
|
||||||
|
version: 1.0.0
|
||||||
|
schema: v1
|
||||||
|
|
||||||
|
# Define which models can be used
|
||||||
|
# https://docs.continue.dev/customization/models
|
||||||
|
models:
|
||||||
|
- name: my gpt-5
|
||||||
|
provider: openai
|
||||||
|
model: gpt-5
|
||||||
|
apiKey: YOUR_OPENAI_API_KEY_HERE
|
||||||
|
- uses: ollama/qwen2.5-coder-7b
|
||||||
|
- uses: anthropic/claude-4-sonnet
|
||||||
|
with:
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
|
||||||
|
# MCP Servers that Continue can access
|
||||||
|
# https://docs.continue.dev/customization/mcp-tools
|
||||||
|
mcpServers:
|
||||||
|
- uses: anthropic/memory-mcp
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
logs/
|
||||||
|
jambotron_data/
|
||||||
+1
-1
@@ -26,7 +26,7 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
|
implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||||
// --- НОВОЕ: Реактивная MongoDB 6.0 для метаданных PaaS ---
|
// --- НОВОЕ: Реактивная MongoDB 6.0 для метаданных PaaS ---
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
|
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
|
||||||
|
|
||||||
|
|||||||
Generated
+2243
-951
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,10 @@ export class GlobalConstants {
|
|||||||
return environment.default_page;
|
return environment.default_page;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
public static readonly HOST_NAME = (() => {
|
||||||
|
return environment.host_name;
|
||||||
|
})();
|
||||||
|
|
||||||
public static readonly API_URL = (() => {
|
public static readonly API_URL = (() => {
|
||||||
// ... calculate the value and return it
|
// ... calculate the value and return it
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "@angular/forms";
|
} from "@angular/forms";
|
||||||
import {MarkdownComponent} from "ngx-markdown";
|
import {MarkdownComponent} from "ngx-markdown";
|
||||||
import {MatButton} from "@angular/material/button";
|
import {MatButton} from "@angular/material/button";
|
||||||
import {MatCard, MatCardActions, MatCardContent, MatCardHeader} from "@angular/material/card";
|
import {MatCard, MatCardActions, MatCardContent} from "@angular/material/card";
|
||||||
import {MatError, MatFormField, MatInput, MatLabel} from "@angular/material/input";
|
import {MatError, MatFormField, MatInput, MatLabel} from "@angular/material/input";
|
||||||
import {MatTab, MatTabGroup} from "@angular/material/tabs";
|
import {MatTab, MatTabGroup} from "@angular/material/tabs";
|
||||||
import {ActivatedRoute, RouterLink} from "@angular/router";
|
import {ActivatedRoute, RouterLink} from "@angular/router";
|
||||||
@@ -47,7 +47,6 @@ type TutorialFormValue = {
|
|||||||
MatCard,
|
MatCard,
|
||||||
MatCardActions,
|
MatCardActions,
|
||||||
MatCardContent,
|
MatCardContent,
|
||||||
MatCardHeader,
|
|
||||||
MatError,
|
MatError,
|
||||||
MatFormField,
|
MatFormField,
|
||||||
MatInput,
|
MatInput,
|
||||||
|
|||||||
-2
@@ -2,7 +2,6 @@ import { Component } from '@angular/core';
|
|||||||
import {Tutorial} from '../../../models/tutorial.model';
|
import {Tutorial} from '../../../models/tutorial.model';
|
||||||
import {MatCard, MatCardActions, MatCardContent, MatCardImage} from '@angular/material/card';
|
import {MatCard, MatCardActions, MatCardContent, MatCardImage} from '@angular/material/card';
|
||||||
import {RouterLink} from '@angular/router';
|
import {RouterLink} from '@angular/router';
|
||||||
import {MatButton} from '@angular/material/button';
|
|
||||||
import {TutorialsApiService} from '../tutorials-api.service';
|
import {TutorialsApiService} from '../tutorials-api.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -11,7 +10,6 @@ import {TutorialsApiService} from '../tutorials-api.service';
|
|||||||
MatCard,
|
MatCard,
|
||||||
MatCardActions,
|
MatCardActions,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
MatButton,
|
|
||||||
MatCardImage,
|
MatCardImage,
|
||||||
MatCardContent
|
MatCardContent
|
||||||
],
|
],
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
<div class="add-project-container p-4">
|
||||||
|
<mat-card>
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Create New SaaS Project</mat-card-title>
|
||||||
|
<mat-card-subtitle>Define your container infrastructure</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content class="mt-4">
|
||||||
|
<form [formGroup]="projectForm" (ngSubmit)="onSubmit()">
|
||||||
|
<!-- Имя проекта -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Project Name</mat-label>
|
||||||
|
<input matInput formControlName="name" placeholder="e.g. My-Awesome-App">
|
||||||
|
<mat-icon matSuffix>label</mat-icon>
|
||||||
|
<mat-error *ngIf="projectForm.get('name')?.hasError('required')">Name is required</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Конфигурация Docker Compose -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width mt-2">
|
||||||
|
<mat-label>Docker Compose YAML</mat-label>
|
||||||
|
<textarea matInput formControlName="rawComposeContent"
|
||||||
|
rows="15"
|
||||||
|
placeholder="version: '3.8'..."
|
||||||
|
class="yaml-editor"></textarea>
|
||||||
|
<mat-hint>Paste your docker-compose.yml content here</mat-hint>
|
||||||
|
<mat-error *ngIf="projectForm.get('rawComposeContent')?.hasError('required')">
|
||||||
|
Configuration content is mandatory
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="actions mt-4 d-flex justify-content-end gap-2">
|
||||||
|
<button mat-button type="button" routerLink="../">Cancel</button>
|
||||||
|
<button mat-raised-button color="primary" type="submit" [disabled]="projectForm.invalid || isSubmitting">
|
||||||
|
<mat-icon *ngIf="!isSubmitting">save</mat-icon>
|
||||||
|
<mat-spinner diameter="20" *ngIf="isSubmitting"></mat-spinner>
|
||||||
|
Create & Initialize
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaml-editor {
|
||||||
|
font-family: 'Fira Code', 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
color: #2c3e50;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-card {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ProjectAddComponent } from './project-add.component';
|
||||||
|
|
||||||
|
describe('ProjectAddComponent', () => {
|
||||||
|
let component: ProjectAddComponent;
|
||||||
|
let fixture: ComponentFixture<ProjectAddComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ProjectAddComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ProjectAddComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import {Component, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
|
||||||
|
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||||
|
import {ProjectService} from '../../../services/project.service';
|
||||||
|
import {Router, RouterLink} from '@angular/router';
|
||||||
|
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardSubtitle, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {MatError, MatFormField, MatHint, MatInputModule, MatLabel} from '@angular/material/input';
|
||||||
|
import {MatIcon} from '@angular/material/icon';
|
||||||
|
import {NgIf} from '@angular/common';
|
||||||
|
import {MatProgressSpinner} from '@angular/material/progress-spinner';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-project-add.component',
|
||||||
|
imports: [
|
||||||
|
MatInputModule,
|
||||||
|
MatCard,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
MatCardSubtitle,
|
||||||
|
MatCardContent,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatFormField,
|
||||||
|
MatLabel,
|
||||||
|
MatIcon,
|
||||||
|
MatError,
|
||||||
|
MatHint,
|
||||||
|
NgIf,
|
||||||
|
MatProgressSpinner,
|
||||||
|
MatButton,
|
||||||
|
RouterLink
|
||||||
|
],
|
||||||
|
schemas:[CUSTOM_ELEMENTS_SCHEMA],
|
||||||
|
templateUrl: './project-add.component.html',
|
||||||
|
styleUrl: './project-add.component.scss',
|
||||||
|
})
|
||||||
|
export class ProjectAddComponent {
|
||||||
|
projectForm: FormGroup;
|
||||||
|
isSubmitting = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private projectService: ProjectService,
|
||||||
|
private router: Router,
|
||||||
|
private snackBar: MatSnackBar
|
||||||
|
) {
|
||||||
|
this.projectForm = this.fb.group({
|
||||||
|
name: ['', [Validators.required, Validators.minLength(3)]],
|
||||||
|
rawComposeContent: ['', Validators.required]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.projectForm.valid) {
|
||||||
|
this.isSubmitting = true;
|
||||||
|
// Отправляем запрос согласно ProjectCreateRequest в Java[cite: 1, 4]
|
||||||
|
this.projectService.createProject(this.projectForm.value).subscribe({
|
||||||
|
next: (project) => {
|
||||||
|
this.snackBar.open(`Project ${project.name} created!`, 'OK', { duration: 3000 });
|
||||||
|
this.router.navigate(['/main/user/projects']).then(r => console.log('Navigation result:', r)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
console.error('Creation error:', err);
|
||||||
|
this.snackBar.open('Failed to create project. Check YAML syntax.', 'Close');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
<div class="project-details-wrapper">
|
||||||
|
<div class="main-header">
|
||||||
|
<h1>{{ project?.name || 'nt6' }}</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="deploy()">▶ DEPLOY</button>
|
||||||
|
<button mat-flat-button (click)="stop()">■ STOP</button>
|
||||||
|
<button mat-flat-button color="warn" (click)="confirmDelete()">🗑 DELETE PROJECT</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-divider">
|
||||||
|
<mat-icon>layers</mat-icon>
|
||||||
|
<h2>CONTAINERS</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="containers-grid">
|
||||||
|
@for (container of containers; track container.name) {
|
||||||
|
<mat-card class="container-card">
|
||||||
|
<mat-card-content class="card-layout">
|
||||||
|
<div class="card-left">
|
||||||
|
<span class="c-name">{{ container.name }}</span>
|
||||||
|
<span class="c-image">{{ container.image }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-right">
|
||||||
|
<div class="state-dot" [class.running]="container.state === 'running'"></div>
|
||||||
|
<span class="uptime">{{ container.status }}</span>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-section-terminal">
|
||||||
|
<div class="terminal-header">
|
||||||
|
<h3>>_ DOCKER COMPOSE OUTPUT</h3>
|
||||||
|
<button mat-icon-button (click)="logs = []"><mat-icon>delete_sweep</mat-icon></button>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-window" #consoleScroll>
|
||||||
|
@for (line of logs; track $index) {
|
||||||
|
<div class="log-line" [innerHTML]="formatLogLine(line)"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
.project-details-wrapper {
|
||||||
|
padding: 24px;
|
||||||
|
background-color: #fff; // Основной фон страницы как на скрине
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h1 { font-size: 48px; font-weight: 900; margin: 0; text-transform: uppercase; }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 0; // На скрине кнопки острые
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 40px;
|
||||||
|
border-bottom: 2px solid #000;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
|
||||||
|
h2 { font-size: 24px; font-weight: 900; text-transform: uppercase; margin: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Исправляем карточки под скриншот */
|
||||||
|
.containers-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-card {
|
||||||
|
background: #f0f0f0 !important; // Серый фон карточки
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
border-bottom: 4px solid #333 !important; // Твоя фирменная черта
|
||||||
|
border-radius: 4px !important;
|
||||||
|
box-shadow: none !important; // Убираем Material-тень
|
||||||
|
|
||||||
|
.card-layout {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
.card-left {
|
||||||
|
.c-name { font-size: 20px; font-weight: 800; display: block; }
|
||||||
|
.c-image { font-size: 12px; color: #666; font-family: 'Fira Code', monospace; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.state-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ccc;
|
||||||
|
&.running {
|
||||||
|
background: #00ff41;
|
||||||
|
box-shadow: 0 0 8px #00ff41;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.uptime { font-size: 10px; color: #999; text-transform: uppercase; margin-top: 30px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Терминал — черная бездна */
|
||||||
|
.content-section-terminal {
|
||||||
|
margin-top: 50px;
|
||||||
|
|
||||||
|
.terminal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
h3 { font-size: 22px; font-weight: 800; text-transform: uppercase; margin: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-window {
|
||||||
|
background: #000; // Чистый черный
|
||||||
|
color: #00ff41; // Терминальный зеленый
|
||||||
|
padding: 20px;
|
||||||
|
font-family: 'Fira Code', 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
border-radius: 0;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ProjectDetailsComponent } from './project-details.component';
|
||||||
|
|
||||||
|
describe('ProjectDetailsComponent', () => {
|
||||||
|
let component: ProjectDetailsComponent;
|
||||||
|
let fixture: ComponentFixture<ProjectDetailsComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ProjectDetailsComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ProjectDetailsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core';
|
||||||
|
import {ActivatedRoute, Router} from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Subscription, timer, switchMap } from 'rxjs';
|
||||||
|
import { ProjectService, Project, DockerContainer, ProjectDetailResponse } from '../../../services/project.service';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import {MatCard, MatCardContent} from '@angular/material/card';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-project-details',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatDividerModule,
|
||||||
|
MatCard,
|
||||||
|
MatCardContent
|
||||||
|
],
|
||||||
|
templateUrl: './project-details.component.html',
|
||||||
|
styleUrls: ['./project-details.component.scss']
|
||||||
|
})
|
||||||
|
export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChecked {
|
||||||
|
@ViewChild('consoleScroll') private consoleContainer!: ElementRef; // Привязка к #consoleScroll
|
||||||
|
|
||||||
|
project?: Project;
|
||||||
|
config?: any;
|
||||||
|
containers: DockerContainer[] = [];
|
||||||
|
logs: string[] = [];
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
private pollingSub?: Subscription;
|
||||||
|
private logSub?: Subscription;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private projectService: ProjectService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const id = this.route.snapshot.paramMap.get('id');
|
||||||
|
if (id) {
|
||||||
|
this.loadData(id);
|
||||||
|
this.startMonitoring(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadData(id: string): void {
|
||||||
|
this.projectService.getProjectDetails(id).subscribe({
|
||||||
|
next: (data: ProjectDetailResponse) => {
|
||||||
|
this.project = data.project;
|
||||||
|
this.config = data.config;
|
||||||
|
this.loading = false;
|
||||||
|
this.connectLogs(id);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Ошибка загрузки данных проекта:', err);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startMonitoring(id: string): void {
|
||||||
|
// Опрос состояния контейнеров каждые 5 секунд[cite: 5, 6]
|
||||||
|
this.pollingSub = timer(0, 5000).pipe(
|
||||||
|
switchMap(() => this.projectService.getProjectContainers(id))
|
||||||
|
).subscribe({
|
||||||
|
next: (data) => this.containers = data,
|
||||||
|
error: (err) => console.error('Ошибка мониторинга контейнеров:', err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private connectLogs(id: string): void {
|
||||||
|
this.logSub = this.projectService.connectToDockerLogs(id).subscribe({
|
||||||
|
next: (msg) => this.logs.push(msg),
|
||||||
|
error: (err) => console.error('WS Error:', err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy(): void {
|
||||||
|
if (this.project) this.projectService.deployProject(this.project.id).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.project) this.projectService.stopProject(this.project.id).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewChecked(): void {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollToBottom(): void {
|
||||||
|
try {
|
||||||
|
this.consoleContainer.nativeElement.scrollTop = this.consoleContainer.nativeElement.scrollHeight;
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.pollingSub?.unsubscribe();
|
||||||
|
this.logSub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete() {
|
||||||
|
if (confirm("Уничтожить проект навсегда?")) {
|
||||||
|
// 1. Сначала «ослепляем» компонент, чтобы он перестал слать запросы
|
||||||
|
this.pollingSub?.unsubscribe(); // Твой метод, который делает subscription.unsubscribe()
|
||||||
|
|
||||||
|
// 2. Только теперь отправляем команду на ликвидацию
|
||||||
|
if (this.project) this.projectService.deleteProject(this.project.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
console.log("Проект стерт, уходим на дашборд");
|
||||||
|
this.router.navigate(['/main/user/projects']);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error("Ошибка при удалении, но проект мог уже исчезнуть", err);
|
||||||
|
// Всё равно уходим, так как оставаться на странице 404 нет смысла
|
||||||
|
this.router.navigate(['/main/user/projects']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Метод для окрашивания логов в терминале
|
||||||
|
formatLogLine(line: string): string {
|
||||||
|
if (!line) return '';
|
||||||
|
|
||||||
|
// Окрашиваем ключевые слова для лучшей читаемости
|
||||||
|
let formatted = line;
|
||||||
|
|
||||||
|
if (line.includes('ERROR') || line.includes('Error') || line.includes('failed')) {
|
||||||
|
formatted = `<span style="color: #ff5f56; font-weight: bold;">${line}</span>`;
|
||||||
|
} else if (line.includes('WARN') || line.includes('Warning') || line.includes('Conflict')) {
|
||||||
|
formatted = `<span style="color: #ffbd2e;">${line}</span>`;
|
||||||
|
} else if (line.includes('INFO') || line.includes('success') || line.includes('Started')) {
|
||||||
|
formatted = `<span style="color: #27c93f;">${line}</span>`;
|
||||||
|
} else if (line.includes('DEBUG')) {
|
||||||
|
formatted = `<span style="color: #ae81ff;">${line}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
<p>project-edit.component works!</p>
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ProjectEditComponent } from './project-edit.component';
|
||||||
|
|
||||||
|
describe('ProjectEditComponent', () => {
|
||||||
|
let component: ProjectEditComponent;
|
||||||
|
let fixture: ComponentFixture<ProjectEditComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ProjectEditComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ProjectEditComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-project-edit.component',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './project-edit.component.html',
|
||||||
|
styleUrl: './project-edit.component.scss',
|
||||||
|
})
|
||||||
|
export class ProjectEditComponent {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<div class="dashboard-container">
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<h1>Jumbotron Dashboard</h1>
|
||||||
|
<button mat-flat-button color="primary" class="new-project-btn" (click)='createNewProject()'>
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
NEW PROJECT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isLoading) {
|
||||||
|
<div class="loader-container">
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="projects-grid">
|
||||||
|
@for (project of projects; track project.id) {
|
||||||
|
<mat-card class="project-card" (click)="openProjectDetails(project.id)">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ project.name }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="status-row">
|
||||||
|
<mat-icon [style.color]="getStatusColor(project.status)">circle</mat-icon>
|
||||||
|
<span class="status-text">{{ project.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-info">
|
||||||
|
<span class="date-label">Создан:</span>
|
||||||
|
<span class="date-value">{{ project.createdAt | date:'dd.MM.yyyy HH:mm' }}</span>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
} @empty {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon>folder_open</mat-icon>
|
||||||
|
<p>Проектов пока нет. Начните созидание!</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
.dashboard-container {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
color: #3e87d9; // Белый для контраста с темным фоном
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-project-btn {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
background: rgba(255, 255, 255, 0.95) !important;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(0,0,0,0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
box-shadow: 0 12px 20px rgba(0,0,0,0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-card-header {
|
||||||
|
padding: 16px 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-card-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
|
||||||
|
.date-label { color: #888; }
|
||||||
|
.date-value { color: #444; font-weight: 500; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-container {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ProjectsComponent } from './projects.component';
|
||||||
|
|
||||||
|
describe('ProjectsComponent', () => {
|
||||||
|
let component: ProjectsComponent;
|
||||||
|
let fixture: ComponentFixture<ProjectsComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ProjectsComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ProjectsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { Project, ProjectService } from '../../../services/project.service';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { MatCard, MatCardContent, MatCardHeader, MatCardTitle } from '@angular/material/card';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-projects',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
MatIcon,
|
||||||
|
MatProgressBar,
|
||||||
|
MatButton,
|
||||||
|
MatCard,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
MatCardContent,
|
||||||
|
DatePipe
|
||||||
|
],
|
||||||
|
templateUrl: './projects.component.html',
|
||||||
|
styleUrl: './projects.component.scss',
|
||||||
|
})
|
||||||
|
export class ProjectsComponent implements OnInit {
|
||||||
|
projects: Project[] = [];
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private projectService: ProjectService,
|
||||||
|
private router: Router
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProjects(): void {
|
||||||
|
this.projectService.getUserProjects().subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
// Фикс бага с JSON в имени
|
||||||
|
this.projects = data.map(p => {
|
||||||
|
if (p.name && p.name.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(p.name);
|
||||||
|
p.name = parsed.name || 'Untitled Project';
|
||||||
|
} catch (e) {
|
||||||
|
p.name = 'Invalid Data';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
this.isLoading = false;
|
||||||
|
},
|
||||||
|
error: () => this.isLoading = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openProjectDetails(projectId: string): void {
|
||||||
|
this.router.navigate(['/main/user/projects', projectId, 'details']);
|
||||||
|
}
|
||||||
|
|
||||||
|
createNewProject(): void {
|
||||||
|
this.router.navigate(['/main/user/projects/add']);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'RUNNING': return '#4caf50';
|
||||||
|
case 'STARTING': return '#ffc107';
|
||||||
|
case 'STOPPED': return '#9e9e9e';
|
||||||
|
case 'ERROR': return '#f44336';
|
||||||
|
default: return '#bdbdbd';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-10
@@ -1,20 +1,15 @@
|
|||||||
import {Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit} from '@angular/core';
|
import {Component, CUSTOM_ELEMENTS_SCHEMA, inject} from '@angular/core';
|
||||||
import {UserApiService} from '../user-api.service';
|
import {UserApiService} from '../user-api.service';
|
||||||
import {TokenStorageService} from '../../../services/token-storage.service';
|
import {RouterLink} from '@angular/router';
|
||||||
import {EventBusService} from '../../../_shared/event-bus.service';
|
|
||||||
import {Router, RouterLink} from '@angular/router';
|
|
||||||
import {AuthService} from '../../../services/auth.service';
|
|
||||||
import {Tutorial} from '../../../models/tutorial.model';
|
import {Tutorial} from '../../../models/tutorial.model';
|
||||||
import {MarkdownComponent, MarkdownService} from 'ngx-markdown';
|
import {MarkdownComponent, MarkdownService} from 'ngx-markdown';
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {MatCard, MatCardActions, MatCardContent, MatCardHeader} from '@angular/material/card';
|
import {MatCard, MatCardActions, MatCardContent} from '@angular/material/card';
|
||||||
import {MatError, MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
import {MatError, MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
||||||
import {MatTab, MatTabGroup} from '@angular/material/tabs';
|
import {MatTab, MatTabGroup} from '@angular/material/tabs';
|
||||||
import {
|
import {
|
||||||
AbstractControl,
|
AbstractControl,
|
||||||
FormBuilder,
|
FormBuilder,
|
||||||
FormControl,
|
|
||||||
FormGroup,
|
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule, ValidationErrors,
|
ReactiveFormsModule, ValidationErrors,
|
||||||
ValidatorFn,
|
ValidatorFn,
|
||||||
@@ -24,7 +19,6 @@ import {AngularMarkdownEditorModule, EditorInstance, EditorOption} from 'angular
|
|||||||
import {MatDialog} from '@angular/material/dialog';
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
import {DialogSelectImageComponent} from '../dialog-select-image.component/dialog-select-image.component';
|
import {DialogSelectImageComponent} from '../dialog-select-image.component/dialog-select-image.component';
|
||||||
import {DialogUploadImageComponent} from '../dialog-upload-image.component/dialog-upload-image.component';
|
import {DialogUploadImageComponent} from '../dialog-upload-image.component/dialog-upload-image.component';
|
||||||
import {FileUploadComponent} from '../../../components/file-upload.component/file-upload.component';
|
|
||||||
import {MatDivider} from '@angular/material/divider';
|
import {MatDivider} from '@angular/material/divider';
|
||||||
import {GlobalConstants} from '../../../global-constants';
|
import {GlobalConstants} from '../../../global-constants';
|
||||||
|
|
||||||
@@ -49,7 +43,6 @@ type TutorialFormValue = {
|
|||||||
MatCard,
|
MatCard,
|
||||||
MatCardActions,
|
MatCardActions,
|
||||||
MatCardContent,
|
MatCardContent,
|
||||||
MatCardHeader,
|
|
||||||
MatFormField,
|
MatFormField,
|
||||||
MatInput,
|
MatInput,
|
||||||
MatLabel,
|
MatLabel,
|
||||||
|
|||||||
-1
@@ -42,7 +42,6 @@ type TutorialFormValue = {
|
|||||||
MatCard,
|
MatCard,
|
||||||
MatCardActions,
|
MatCardActions,
|
||||||
MatCardContent,
|
MatCardContent,
|
||||||
MatCardHeader,
|
|
||||||
MatFormField,
|
MatFormField,
|
||||||
MatInput,
|
MatInput,
|
||||||
MatLabel,
|
MatLabel,
|
||||||
|
|||||||
+2
-3
@@ -22,7 +22,6 @@ import {
|
|||||||
import {DatePipe} from '@angular/common';
|
import {DatePipe} from '@angular/common';
|
||||||
import {MatCheckbox} from '@angular/material/checkbox';
|
import {MatCheckbox} from '@angular/material/checkbox';
|
||||||
import {MatSlideToggle} from '@angular/material/slide-toggle';
|
import {MatSlideToggle} from '@angular/material/slide-toggle';
|
||||||
import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/breadcrumbs.component';
|
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -44,8 +43,8 @@ import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/br
|
|||||||
MatCheckbox,
|
MatCheckbox,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
MatCell,
|
MatCell,
|
||||||
MatSlideToggle,
|
MatSlideToggle
|
||||||
BreadcrumbsComponent
|
|
||||||
],
|
],
|
||||||
schemas:[CUSTOM_ELEMENTS_SCHEMA],
|
schemas:[CUSTOM_ELEMENTS_SCHEMA],
|
||||||
templateUrl: './tutorials-list.component.html',
|
templateUrl: './tutorials-list.component.html',
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import {Component, CUSTOM_ELEMENTS_SCHEMA, OnInit, ViewChild} from '@angular/core';
|
import {Component, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
|
||||||
import {RouterOutlet} from '@angular/router';
|
|
||||||
import {MatSidenav} from '@angular/material/sidenav';
|
|
||||||
import {BreakpointObserver} from '@angular/cdk/layout';
|
|
||||||
import {SideBarUserComponent} from '../side-bar-user.component/side-bar-user.component';
|
|
||||||
import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/breadcrumbs.component';
|
|
||||||
import {InnerLayoutComponent} from '../../../components/inner-layout.component/inner-layout.component';
|
import {InnerLayoutComponent} from '../../../components/inner-layout.component/inner-layout.component';
|
||||||
import {MODERATOR_ROUTS} from '../../moderator-module/moderator.routing';
|
|
||||||
import {USER_ROUTS} from '../user.routing';
|
import {USER_ROUTS} from '../user.routing';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user.component',
|
selector: 'app-user.component',
|
||||||
imports: [
|
imports: [
|
||||||
RouterOutlet,
|
|
||||||
SideBarUserComponent,
|
|
||||||
BreadcrumbsComponent,
|
|
||||||
InnerLayoutComponent
|
InnerLayoutComponent
|
||||||
],
|
],
|
||||||
templateUrl: './user.component.html',
|
templateUrl: './user.component.html',
|
||||||
|
|||||||
@@ -50,6 +50,21 @@ export const USER_ROUTS: Routes = [
|
|||||||
path:'docker',
|
path:'docker',
|
||||||
loadComponent: () => import('../user-module/docker-management-component/docker-management-component').then((c)=>c.DockerManagementComponent),
|
loadComponent: () => import('../user-module/docker-management-component/docker-management-component').then((c)=>c.DockerManagementComponent),
|
||||||
data:{title: 'Docker', icon: 'docker', nav:true}
|
data:{title: 'Docker', icon: 'docker', nav:true}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects',
|
||||||
|
loadComponent: () => import('../user-module/projects.component/projects.component').then((c) => c.ProjectsComponent),
|
||||||
|
data: { title: 'Dashboard', icon: 'dashboard', nav: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects/:id/details',
|
||||||
|
loadComponent: () => import('../user-module/project-details.component/project-details.component').then((c) => c.ProjectDetailsComponent),
|
||||||
|
data: { title: 'Project Details' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects/add',
|
||||||
|
loadComponent: () => import('../user-module/project-add.component/project-add.component').then((c) => c.ProjectAddComponent),
|
||||||
|
data: { title: 'New Project' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { GlobalConstants } from '../global-constants';
|
||||||
|
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
|
||||||
|
|
||||||
|
// Унифицируем название интерфейса для списка контейнеров
|
||||||
|
export interface DockerContainer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
state: string; // running, exited, etc.
|
||||||
|
status: string; // Up 2 hours, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерфейс Project в соответствии с Project.java[cite: 3]
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
user?: any; // Соответствует полю private User user в Java[cite: 3]
|
||||||
|
status: string;
|
||||||
|
localPath: string;
|
||||||
|
createdAt: Date;
|
||||||
|
containers?: DockerContainer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCreateRequest {
|
||||||
|
name: string;
|
||||||
|
rawComposeContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDetailResponse {
|
||||||
|
project: Project;
|
||||||
|
config: {
|
||||||
|
projectId: string;
|
||||||
|
rawComposeContent: string;
|
||||||
|
envVariables: { [key: string]: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ProjectService {
|
||||||
|
// Используем baseUrl, как определено в начале класса
|
||||||
|
private baseUrl = GlobalConstants.API_URL;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
// Создание нового проекта[cite: 4, 5]
|
||||||
|
createProject(request: ProjectCreateRequest): Observable<Project> {
|
||||||
|
return this.http.post<Project>(`${this.baseUrl}/user/projects`, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Методы для Dashboard[cite: 4, 5]
|
||||||
|
getUserProjects(): Observable<Project[]> {
|
||||||
|
return this.http.get<Project[]>(`${this.baseUrl}/user/projects`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProjectDetails(id: string): Observable<ProjectDetailResponse> {
|
||||||
|
return this.http.get<ProjectDetailResponse>(`${this.baseUrl}/user/projects/${id}/details`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Управление жизненным циклом (Docker Compose)[cite: 3, 4]
|
||||||
|
deployProject(id: string): Observable<string> {
|
||||||
|
return this.http.post(`${this.baseUrl}/user/projects/${id}/deploy`, {}, { responseType: 'text' });
|
||||||
|
}
|
||||||
|
|
||||||
|
stopProject(id: string): Observable<string> {
|
||||||
|
return this.http.post(`${this.baseUrl}/user/projects/${id}/stop`, {}, { responseType: 'text' });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteProject(id: string): Observable<any> {
|
||||||
|
return this.http.delete(`${this.baseUrl}/user/projects/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Администрирование[cite: 4]
|
||||||
|
getAllProjectsAdmin(): Observable<Project[]> {
|
||||||
|
return this.http.get<Project[]>(`${this.baseUrl}/admin/projects/all`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket для трансляции логов в реальном времени[cite: 3, 5]
|
||||||
|
public connectToDockerLogs(projectId: string): WebSocketSubject<any> {
|
||||||
|
const socketUrl = this.baseUrl.replace('http', 'ws') + '/ws-jambotron/' + projectId;
|
||||||
|
return webSocket({
|
||||||
|
url: socketUrl,
|
||||||
|
deserializer: (msg) => msg.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ИСПРАВЛЕНО: возвращаем DockerContainer и используем правильный baseUrl[cite: 5]
|
||||||
|
getProjectContainers(projectId: string): Observable<DockerContainer[]> {
|
||||||
|
return this.http.get<DockerContainer[]>(`${this.baseUrl}/user/projects/${projectId}/containers`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<script>
|
||||||
|
if (window.global === undefined) {
|
||||||
|
window.global = window;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>JambotronUi</title>
|
<title>JambotronUi</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<script>
|
||||||
|
if (window.global === undefined) {
|
||||||
|
window.global = window;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>JambotronUi</title>
|
<title>JambotronUi</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"secure": false,
|
"secure": false,
|
||||||
"changeOrigin": true,
|
"changeOrigin": true,
|
||||||
"logLevel": "debug",
|
"logLevel": "debug",
|
||||||
|
"ws": true,
|
||||||
"pathRewrite": {"^/api" : "http://localhost:8081/api"}
|
"pathRewrite": {"^/api" : "http://localhost:8081/api"}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"/api/**": {
|
"/api": {
|
||||||
"target": "http://localhost:8082",
|
"target": "http://localhost:8082",
|
||||||
"secure": false,
|
"secure": false,
|
||||||
"changeOrigin": true,
|
"changeOrigin": true,
|
||||||
"logLevel": "debug",
|
"logLevel": "debug",
|
||||||
"pathRewrite": {"^/api" : "http://localhost:8082/api"}
|
"timeout": 0,
|
||||||
|
"ws": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.jambotronGroup.jambotron.WebSocket;
|
||||||
|
|
||||||
|
import com.github.dockerjava.api.DockerClient;
|
||||||
|
import com.github.dockerjava.api.async.ResultCallback;
|
||||||
|
|
||||||
|
import com.github.dockerjava.api.model.Container;
|
||||||
|
import com.github.dockerjava.api.model.Frame;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.TextMessage;
|
||||||
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||||
|
import org.springframework.web.util.UriTemplate;
|
||||||
|
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class DockerLogsHandler extends TextWebSocketHandler {
|
||||||
|
|
||||||
|
private static final Logger _logger = LoggerFactory.getLogger(DockerLogsHandler.class);
|
||||||
|
private final DockerClient dockerClient;
|
||||||
|
|
||||||
|
// ИСПРАВЛЕНО: Используем базовый интерфейс ResultCallback для хранения подписок
|
||||||
|
private final Map<String, ResultCallback<Frame>> activeSubscriptions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public DockerLogsHandler(DockerClient dockerClient) {
|
||||||
|
this.dockerClient = dockerClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||||
|
UriTemplate template = new UriTemplate("/api/ws-jambotron/{projectId}");
|
||||||
|
Map<String, String> variables = template.match(session.getUri().getPath());
|
||||||
|
String projectId = variables.get("projectId");
|
||||||
|
|
||||||
|
if (projectId != null) {
|
||||||
|
_logger.info("Установлено WS соединение для проекта: {}", projectId);
|
||||||
|
startStreamingLogs(session, projectId);
|
||||||
|
} else {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startStreamingLogs(WebSocketSession session, String projectId) {
|
||||||
|
try {
|
||||||
|
// 1. Ищем реальный ID контейнера по метке проекта Compose
|
||||||
|
// По умолчанию Docker Compose использует ID папки (наш projectId) как имя проекта
|
||||||
|
List<Container> containers = dockerClient.listContainersCmd()
|
||||||
|
.withLabelFilter(Map.of("com.docker.compose.project", projectId))
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
if (containers.isEmpty()) {
|
||||||
|
_logger.warn("Контейнеры для проекта {} не найдены в Docker", projectId);
|
||||||
|
session.sendMessage(new TextMessage("Система: Контейнеры не запущены. Ожидание деплоя..."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Берем первый контейнер из проекта для трансляции логов
|
||||||
|
String realContainerId = containers.get(0).getId();
|
||||||
|
_logger.info("Трансляция логов из контейнера: {}", realContainerId);
|
||||||
|
|
||||||
|
// 2. Настраиваем команду логов[cite: 5]
|
||||||
|
var logCmd = dockerClient.logContainerCmd(realContainerId)
|
||||||
|
.withStdOut(true)
|
||||||
|
.withStdErr(true)
|
||||||
|
//.withFollow(true)
|
||||||
|
.withTail(100);
|
||||||
|
|
||||||
|
// 3. Используем адаптер без опасных приведений типов[cite: 5]
|
||||||
|
ResultCallback.Adapter<Frame> callback = new ResultCallback.Adapter<>() {
|
||||||
|
@Override
|
||||||
|
public void onNext(Frame item) {
|
||||||
|
try {
|
||||||
|
if (session.isOpen() && item != null) {
|
||||||
|
// Декодируем байты лога в строку для терминала Angular[cite: 4, 5]
|
||||||
|
session.sendMessage(new TextMessage(new String(item.getPayload())));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
this.onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable throwable) {
|
||||||
|
_logger.error("Ошибка в потоке логов: {}", throwable.getMessage());
|
||||||
|
this.onComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logCmd.exec(callback);
|
||||||
|
activeSubscriptions.put(session.getId(), callback);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
_logger.error("Критическая ошибка при запуске логов: ", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionClosed(WebSocketSession session, org.springframework.web.socket.CloseStatus status) {
|
||||||
|
// ИСПРАВЛЕНО: Безопасное закрытие через интерфейс[cite: 5]
|
||||||
|
ResultCallback<Frame> callback = activeSubscriptions.remove(session.getId());
|
||||||
|
if (callback != null) {
|
||||||
|
try {
|
||||||
|
callback.close();
|
||||||
|
_logger.info("Стрим логов закрыт для сессии: {}", session.getId());
|
||||||
|
} catch (IOException e) {
|
||||||
|
_logger.error("Ошибка при закрытии подписки: ", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.jambotronGroup.jambotron.WebSocket;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocket
|
||||||
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
|
||||||
|
private final DockerLogsHandler dockerLogsHandler;
|
||||||
|
|
||||||
|
public WebSocketConfig(DockerLogsHandler dockerLogsHandler) {
|
||||||
|
this.dockerLogsHandler = dockerLogsHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
|
registry.addHandler(dockerLogsHandler, "/api/ws-jambotron/{projectId}")
|
||||||
|
// Поскольку есть proxy.conf.json, запросы приходят как Same-Origin.
|
||||||
|
// Но для стабильности разработки лучше явно указать localhost.
|
||||||
|
.setAllowedOrigins("http://localhost:8082", "http://localhost:4200");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,63 +2,208 @@ 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.ProjectStatus;
|
||||||
|
import com.jambotronGroup.jambotron.model.User;
|
||||||
|
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.ProjectService;
|
import com.jambotronGroup.jambotron.projects.ProjectService;
|
||||||
|
import com.jambotronGroup.jambotron.repository.UserRepository;
|
||||||
|
import com.jambotronGroup.jambotron.security.AuthenticationFacade;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
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.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/projects")
|
@RequestMapping("/api")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ProjectController {
|
public class ProjectController {
|
||||||
|
|
||||||
|
private static final Logger _logger = LoggerFactory.getLogger(ProjectController.class);
|
||||||
|
|
||||||
private final ProjectService projectService;
|
private final ProjectService projectService;
|
||||||
|
|
||||||
@PostMapping
|
private final UserRepository userRepository;
|
||||||
public ResponseEntity<Project> create(@Valid @RequestBody ProjectCreateRequest request) {
|
private final AuthenticationFacade authenticationFacade;
|
||||||
Project newProject = projectService.createProject(request);
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(newProject);
|
//-------------------- Просмотр и Dashboard ----------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение списка проектов текущего пользователя (Dashboard)[cite: 2, 3].
|
||||||
|
*/
|
||||||
|
@GetMapping("/user/projects")
|
||||||
|
public ResponseEntity<List<Project>> getUserProjects() {
|
||||||
|
try {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
List<Project> projects = projectService.findAllByOwnerId(user.getId());
|
||||||
|
|
||||||
|
if (projects.isEmpty()) {
|
||||||
|
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
|
||||||
|
}
|
||||||
|
return new ResponseEntity<>(projects, HttpStatus.OK);
|
||||||
|
} catch (Exception e) {
|
||||||
|
_logger.error("Error fetching user projects", e);
|
||||||
|
return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/deploy")
|
/**
|
||||||
|
* Детали проекта (Project + Config). Доступно владельцу или админу.
|
||||||
|
*/
|
||||||
|
@GetMapping("/user/projects/{id}/details")
|
||||||
|
public ResponseEntity<ProjectDetailResponse> getProjectDetails(@PathVariable UUID id) {
|
||||||
|
try {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
boolean isAdmin = checkIfAdmin(user);
|
||||||
|
|
||||||
|
// Метод сервиса сам проверит права владельца, если isAdmin = false
|
||||||
|
ProjectDetailResponse details = projectService.getProjectDetailsSecure(
|
||||||
|
id,
|
||||||
|
user.getId(),
|
||||||
|
isAdmin
|
||||||
|
);
|
||||||
|
|
||||||
|
return new ResponseEntity<>(details, HttpStatus.OK);
|
||||||
|
} catch (AccessDeniedException e) {
|
||||||
|
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//-------------------- Управление Жизненным Циклом (User & Admin) ----------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Деплой проекта (Docker Compose Up). Доступно владельцу или админу[cite: 1, 4].
|
||||||
|
*/
|
||||||
|
@PostMapping("/user/projects/{id}/deploy")
|
||||||
public ResponseEntity<String> deployProject(@PathVariable UUID id) {
|
public ResponseEntity<String> deployProject(@PathVariable UUID id) {
|
||||||
// 1. Находим проект в БД
|
// Получаем текущего пользователя (твой метод)
|
||||||
|
User user = getCurrentUser();
|
||||||
|
|
||||||
|
// Используем метод сервиса findById, а не репозиторий напрямую!
|
||||||
Project project = projectService.findById(id);
|
Project project = projectService.findById(id);
|
||||||
|
|
||||||
// 2. Вызываем магию деплоя
|
// Выполняем проверку безопасности (через String.valueOf, как мы выяснили)
|
||||||
projectService.deployProject(project);
|
if (checkIfAdmin(user) ||
|
||||||
|
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
|
||||||
|
|
||||||
return ResponseEntity.ok("Deployment started for project: " + project.getName());
|
// ВАЖНО: В твоем сервисе метод называется deployProject(Project project)
|
||||||
|
projectService.deployProject(project);
|
||||||
|
return ResponseEntity.ok("Процесс созидания запущен...");
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/stop")
|
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) {
|
public ResponseEntity<String> stopProject(@PathVariable UUID id) {
|
||||||
// 1. Ищем проект в базе
|
User user = getCurrentUser();
|
||||||
Project project = projectService.findById(id);
|
Project project = projectService.findById(id);
|
||||||
|
|
||||||
// 2. Останавливаем контейнеры
|
if (!checkIfAdmin(user) && !project.getUser().getId().equals(String.valueOf(user.getId()))) {
|
||||||
projectService.stopProject(project);
|
return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. (Опционально) Обновляем статус в базе
|
projectService.stopProject(project);
|
||||||
project.setStatus(ProjectStatus.STOPPED);
|
project.setStatus(ProjectStatus.STOPPED);
|
||||||
projectService.save(project); // Не забудь добавить метод save в сервис
|
projectService.save(project);
|
||||||
|
|
||||||
return ResponseEntity.ok("Project stopped: " + project.getName());
|
return ResponseEntity.ok("Project stopped: " + project.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}/config")
|
/**
|
||||||
public ResponseEntity<String> updateConfig(
|
* Удаление проекта (Очистка Docker + Файлы + БД). Доступно владельцу или админу[cite: 1, 2].
|
||||||
@PathVariable UUID id,
|
*/
|
||||||
@RequestBody String newContent) {
|
@DeleteMapping("/user/projects/{id}")
|
||||||
|
public ResponseEntity<String> deleteProject(@PathVariable UUID id) {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
Project project = projectService.findById(id);
|
||||||
|
|
||||||
|
// Безопасная проверка владельца (уже ставшая стандартом для нашего "сосуда")
|
||||||
|
if (checkIfAdmin(user) ||
|
||||||
|
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
|
||||||
|
|
||||||
|
_logger.info("Начато полное удаление проекта: {}", id);
|
||||||
|
projectService.deleteProject(id); // Вызов того самого метода из ProjectService_2.java
|
||||||
|
return ResponseEntity.ok("Проект полностью удален из системы");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.status(403).body("Access Denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
//-------------------- Администрирование PaaS ----------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список всех проектов системы для глобального мониторинга[cite: 2].
|
||||||
|
*/
|
||||||
|
@GetMapping("/admin/projects/all")
|
||||||
|
public ResponseEntity<List<Project>> getAllProjectsForAdmin() {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
if (!checkIfAdmin(user)) {
|
||||||
|
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Project> allProjects = projectService.findAllProjectsForAdmin();
|
||||||
|
return new ResponseEntity<>(allProjects, HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
//-------------------- Создание и Конфигурация ----------------------------
|
||||||
|
|
||||||
|
@PostMapping("/user/projects")
|
||||||
|
public ResponseEntity<Project> create(@Valid @RequestBody ProjectCreateRequest request) {
|
||||||
|
|
||||||
|
User user = getCurrentUser();
|
||||||
|
Project newProject = projectService.createProject(request.name(), request.rawComposeContent(), user);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(newProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/user/projects/{id}/config")
|
||||||
|
public ResponseEntity<String> updateConfig(@PathVariable UUID id, @RequestBody String newContent) {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
Project project = projectService.findById(id);
|
||||||
|
|
||||||
|
if (!checkIfAdmin(user) && !project.getUser().getId().equals(String.valueOf(user.getId()))) {
|
||||||
|
return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
// Обновляем данные в базе
|
|
||||||
projectService.updateProjectConfig(id, newContent);
|
projectService.updateProjectConfig(id, newContent);
|
||||||
|
return ResponseEntity.ok("Configuration updated.");
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok("Configuration updated. Re-deploy to apply changes.");
|
//-------------------- Вспомогательные методы ----------------------------
|
||||||
|
|
||||||
|
private User getCurrentUser() {
|
||||||
|
return userRepository.findById(authenticationFacade.getUserDetails().getId())
|
||||||
|
.orElseThrow(() -> new RuntimeException("Current user not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean checkIfAdmin(User user) {
|
||||||
|
return user.getRoles() != null && (user.getRoles().equals("ADMIN") || user.getRoles().equals("ROLE_ADMIN"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/user/projects/{id}/containers")
|
||||||
|
public ResponseEntity<List<ContainerStatus>> getContainers(@PathVariable UUID id) {
|
||||||
|
User user = getCurrentUser();
|
||||||
|
Project project = projectService.findById(id);
|
||||||
|
|
||||||
|
// Применяем нашу "золотую формулу" сравнения ID
|
||||||
|
if (checkIfAdmin(user) ||
|
||||||
|
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
|
||||||
|
|
||||||
|
return ResponseEntity.ok(projectService.getProjectContainers(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,8 +18,9 @@ public class Project {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Column(name = "owner_id", nullable = false)
|
@ManyToOne(fetch = FetchType.EAGER)//, cascade = CascadeType.PERSIST)
|
||||||
private UUID ownerId;
|
@JoinColumn(name = "userID", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
@Column(name = "local_path")
|
@Column(name = "local_path")
|
||||||
private String localPath; // Путь к папке проекта на диске
|
private String localPath; // Путь к папке проекта на диске
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.jambotronGroup.jambotron.model;
|
package com.jambotronGroup.jambotron.model;
|
||||||
|
|
||||||
public enum ProjectStatus {
|
public enum ProjectStatus {
|
||||||
CREATED, // Проект инициализирован в БД
|
CREATED, // Папка создана, конфиги в Mongo есть
|
||||||
DEPLOYING, // Запущен процесс docker-compose up
|
STARTING, // Процесс docker compose up запущен
|
||||||
RUNNING, // Контейнеры активны
|
RUNNING, // Контейнеры успешно поднялись
|
||||||
STOPPED, // Контейнеры остановлены
|
ERROR, // Docker вернул exit code != 0[cite: 3]
|
||||||
ERROR // Ошибка инфраструктуры
|
STOPPING, // Выполняется docker compose down[cite: 3]
|
||||||
|
STOPPED // Проект успешно остановлен
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.jambotronGroup.jambotron.projects;
|
||||||
|
|
||||||
|
import com.jambotronGroup.jambotron.globalConstants.GlobalConstants;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public class ConfigService {
|
||||||
|
|
||||||
|
public void generateEnvFile(UUID projectUuid, Map<String, String> configMap) {
|
||||||
|
// 1. Формируем путь к папке configs через GlobalConstants
|
||||||
|
Path configPath = GlobalConstants.PATH_PROJECTS
|
||||||
|
.resolve(projectUuid.toString())
|
||||||
|
.resolve("configs");
|
||||||
|
|
||||||
|
// 2. Подготавливаем содержимое .env файла
|
||||||
|
String envContent = configMap.entrySet().stream()
|
||||||
|
.map(entry -> entry.getKey() + "=" + entry.getValue())
|
||||||
|
.collect(Collectors.joining("\n"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. Создаем директорию, если её нет
|
||||||
|
Files.createDirectories(configPath);
|
||||||
|
|
||||||
|
// 4. Записываем файл project.env
|
||||||
|
Path envFile = configPath.resolve("project.env");
|
||||||
|
Files.writeString(envFile, envContent);
|
||||||
|
|
||||||
|
System.out.println("Конфиг успешно записан: " + envFile.toAbsolutePath());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Ошибка записи конфига для проекта " + projectUuid, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.jambotronGroup.jambotron.projects;
|
||||||
|
|
||||||
|
public record ContainerStatus(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String image,
|
||||||
|
String state,
|
||||||
|
String status
|
||||||
|
) {}
|
||||||
@@ -10,6 +10,6 @@ public record ProjectCreateRequest(
|
|||||||
@Size(min = 3, max = 50, message = "Имя проекта должно быть от 3 до 50 символов")
|
@Size(min = 3, max = 50, message = "Имя проекта должно быть от 3 до 50 символов")
|
||||||
String name,
|
String name,
|
||||||
|
|
||||||
@NotNull(message = "ID владельца обязателен")
|
@NotBlank(message = " not null")
|
||||||
UUID ownerId
|
String rawComposeContent
|
||||||
) {}
|
) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.jambotronGroup.jambotron.projects;
|
||||||
|
|
||||||
|
import com.jambotronGroup.jambotron.model.Project;
|
||||||
|
import com.jambotronGroup.jambotron.model.ProjectConfig;
|
||||||
|
|
||||||
|
public record ProjectDetailResponse(
|
||||||
|
Project project,
|
||||||
|
ProjectConfig config
|
||||||
|
) {}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
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.initData.InitDataService;
|
|
||||||
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 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;
|
||||||
@@ -11,15 +12,22 @@ import lombok.RequiredArgsConstructor;
|
|||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
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.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -29,6 +37,7 @@ public class ProjectService {
|
|||||||
|
|
||||||
private final ProjectConfigRepository configRepository;
|
private final ProjectConfigRepository configRepository;
|
||||||
|
|
||||||
|
|
||||||
private static final Logger _logger = LoggerFactory.getLogger(ProjectService.class);
|
private static final Logger _logger = LoggerFactory.getLogger(ProjectService.class);
|
||||||
|
|
||||||
public Project findById(UUID id) {
|
public Project findById(UUID id) {
|
||||||
@@ -36,92 +45,210 @@ public class ProjectService {
|
|||||||
.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) {
|
||||||
|
_logger.info("Запрос списка проектов для пользователя: {}", ownerId);
|
||||||
|
return projectRepository.findAllByUserId(ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение всех проектов в системе (для Admin Panel).
|
||||||
|
* Позволяет администратору видеть состояние всех развернутых мощностей.
|
||||||
|
*/
|
||||||
|
public List<Project> findAllProjectsForAdmin() {
|
||||||
|
_logger.info("Запрос всех проектов системы администратором");
|
||||||
|
return projectRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение развернутой информации о проекте (Project + Config).
|
||||||
|
* Полезно для детального отображения в Dashboard.
|
||||||
|
*/
|
||||||
|
public ProjectDetailResponse getProjectDetails(UUID projectId) {
|
||||||
|
Project project = findById(projectId);
|
||||||
|
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
|
||||||
|
.orElseThrow(() -> new RuntimeException("Конфигурация не найдена"));
|
||||||
|
|
||||||
|
return new ProjectDetailResponse(project, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Безопасное получение деталей проекта.
|
||||||
|
* @param projectId ID проекта
|
||||||
|
* @param currentUserId ID пользователя, совершающего запрос
|
||||||
|
* @param isAdmin флаг, является ли пользователь администратором
|
||||||
|
*/
|
||||||
|
public ProjectDetailResponse getProjectDetailsSecure(UUID projectId, Long currentUserId, boolean isAdmin) {
|
||||||
|
Project project = findById(projectId);
|
||||||
|
|
||||||
|
// ПРОВЕРКА БЕЗОПАСНОСТИ:
|
||||||
|
// Если пользователь не админ и не владелец проекта — доступ запрещен
|
||||||
|
if (!isAdmin && !Objects.equals(project.getUser().getId(), currentUserId)) {
|
||||||
|
_logger.warn("Security Alert: User {} tried to access project {} owned by {}",
|
||||||
|
currentUserId, projectId, project.getUser().getId());
|
||||||
|
throw new AccessDeniedException("У вас нет прав для просмотра этого проекта");
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
|
||||||
|
.orElseThrow(() -> new RuntimeException("Конфигурация не найдена"));
|
||||||
|
|
||||||
|
return new ProjectDetailResponse(project, config);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Project createProject(ProjectCreateRequest request) {
|
public Project createProject(String projectName, String rawComposeContent, User owner) {
|
||||||
// 1. Проверка на уникальность имени внутри аккаунта пользователя
|
if (projectRepository.existsByNameAndUserId(projectName, owner.getId())) {
|
||||||
if (projectRepository.existsByNameAndOwnerId(request.name(), request.ownerId())) {
|
|
||||||
throw new ProjectAlreadyExistsException(
|
throw new ProjectAlreadyExistsException(
|
||||||
"Проект с именем '" + request.name() + "' уже существует у данного пользователя"
|
"Проект с именем '" + projectName + "' уже существует у данного пользователя"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.info("Валидация пройдена. Создание проекта: {}", request.name());
|
_logger.info("Валидация пройдена. Создание проекта: {}", projectName);
|
||||||
|
|
||||||
Project project = new Project();
|
Project project = new Project();
|
||||||
project.setName(request.name());
|
project.setName(projectName);
|
||||||
project.setOwnerId(request.ownerId());
|
|
||||||
|
|
||||||
|
|
||||||
|
project.setUser(owner);
|
||||||
|
project.setStatus(ProjectStatus.CREATED);
|
||||||
project = projectRepository.save(project);
|
project = projectRepository.save(project);
|
||||||
|
|
||||||
UUID projectId = project.getId();
|
UUID projectId = project.getId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. FS: Используем GlobalConstants[cite: 1]
|
// Используем структуру из GlobalConstants для создания рабочей директории
|
||||||
Path projectDir = GlobalConstants.PATH_PROJECTS.resolve(projectId.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);
|
projectRepository.save(project);
|
||||||
|
|
||||||
// 3. MongoDB[cite: 1]
|
|
||||||
ProjectConfig config = ProjectConfig.builder()
|
ProjectConfig config = ProjectConfig.builder()
|
||||||
.projectId(String.valueOf(project.getId()))
|
.projectId(String.valueOf(project.getId()))
|
||||||
.rawComposeContent("version: '3.8'\nservices:\n app:\n image: nginx:alpine\n ports:\n - \"8080:80\"")
|
.rawComposeContent(rawComposeContent)
|
||||||
.build();
|
.build();
|
||||||
configRepository.save(config);
|
configRepository.save(config);
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
_logger.error("FS Error: {}", projectId, e);
|
_logger.error("FS Error during project creation: {}", projectId, e);
|
||||||
throw new RuntimeException("Ошибка инициализации хранилища");
|
throw new RuntimeException("Ошибка инициализации хранилища");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основной процесс деплоя.
|
||||||
|
* Метод не помечен @Transactional целиком, чтобы статус STARTING
|
||||||
|
* зафиксировался в БД до начала выполнения долгой команды docker compose.
|
||||||
|
*/
|
||||||
public void deployProject(Project project) {
|
public void deployProject(Project project) {
|
||||||
// 1. Получаем конфиг из Mongo
|
|
||||||
ProjectConfig config = configRepository.findByProjectId(String.valueOf(project.getId()))
|
|
||||||
.orElseThrow(() -> new RuntimeException("Config not found"));
|
|
||||||
|
|
||||||
// 2. Путь к папке проекта (уже создан при POST-запросе)
|
_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());
|
Path projectPath = Paths.get(project.getLocalPath());
|
||||||
File composeFile = projectPath.resolve("docker-compose.yml").toFile();
|
|
||||||
|
|
||||||
try {
|
// 3. Генерируем файлы конфигурации
|
||||||
// 3. Записываем YAML контент в файл
|
prepareFiles(project, projectPath);
|
||||||
Files.writeString(composeFile.toPath(), config.getRawComposeContent());
|
_logger.info("Ready to deploy project at: {}", projectPath);
|
||||||
_logger.info("Docker Compose file created at: {}", composeFile.getAbsolutePath());
|
|
||||||
|
|
||||||
// 4. Запускаем проект через ProcessBuilder (команда docker compose up)
|
// 4. Запускаем Docker Compose (блокирующая операция)
|
||||||
runComposeUp(projectPath);
|
int exitCode = runComposeUp(projectPath,project.getId());
|
||||||
|
|
||||||
} catch (IOException e) {
|
// 5. Финализируем статус на основе результата
|
||||||
throw new RuntimeException("Failed to deploy project files", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void runComposeUp(Path projectPath) {
|
|
||||||
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d");
|
|
||||||
pb.directory(projectPath.toFile()); // Выполняем команду внутри папки проекта
|
|
||||||
pb.inheritIO(); // Перенаправляем логи докера в консоль Spring
|
|
||||||
|
|
||||||
try {
|
|
||||||
Process process = pb.start();
|
|
||||||
int exitCode = process.waitFor();
|
|
||||||
if (exitCode == 0) {
|
if (exitCode == 0) {
|
||||||
_logger.info("Project deployed successfully via Docker Compose!");
|
updateStatusInNewTransaction(project.getId(), ProjectStatus.RUNNING);
|
||||||
|
_logger.info("Project {} is now RUNNING", project.getId());
|
||||||
} else {
|
} else {
|
||||||
|
updateStatusInNewTransaction(project.getId(), ProjectStatus.ERROR);
|
||||||
_logger.error("Docker Compose failed with exit code: {}", exitCode);
|
_logger.error("Docker Compose failed with exit code: {}", exitCode);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
_logger.error("Error executing Docker Compose", e);
|
_logger.error("Critical error during deploy: {}", e.getMessage());
|
||||||
|
updateStatusInNewTransaction(project.getId(), ProjectStatus.ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вспомогательный метод для атомарного обновления статуса
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void updateStatusInNewTransaction(UUID projectId, ProjectStatus status) {
|
||||||
|
Project project = findById(projectId);
|
||||||
|
project.setStatus(status);
|
||||||
|
projectRepository.save(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prepareFiles(Project project, Path projectPath) throws IOException {
|
||||||
|
ProjectConfig config = configRepository.findByProjectId(String.valueOf(project.getId()))
|
||||||
|
.orElseThrow(() -> new RuntimeException("Config not found"));
|
||||||
|
|
||||||
|
// Убеждаемся, что директория существует
|
||||||
|
if (!Files.exists(projectPath)) {
|
||||||
|
Files.createDirectories(projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// docker-compose.yml
|
||||||
|
Path composeFile = projectPath.resolve("docker-compose.yml");
|
||||||
|
Files.writeString(composeFile, config.getRawComposeContent());
|
||||||
|
|
||||||
|
// .env
|
||||||
|
if (config.getEnvVariables() != null && !config.getEnvVariables().isEmpty()) {
|
||||||
|
String envContent = config.getEnvVariables().entrySet().stream()
|
||||||
|
.map(e -> e.getKey() + "=" + e.getValue())
|
||||||
|
.collect(Collectors.joining("\n"));
|
||||||
|
|
||||||
|
Files.writeString(projectPath.resolve(".env"), envContent);
|
||||||
|
_logger.info(".env file generated for project: {}", project.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int runComposeUp(Path projectPath, UUID projectId) {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d");
|
||||||
|
pb.directory(projectPath.toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.info("Запуск docker compose up для проекта: {}", projectId);
|
||||||
|
Process process = pb.start();
|
||||||
|
|
||||||
|
// 1. Читаем поток вывода в реальном времени
|
||||||
|
try (BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
// Логируем в консоль сервера (из этого потока наш DockerLogsHandler
|
||||||
|
// будет забирать данные для передачи в Angular)
|
||||||
|
_logger.info("[Docker-{}]: {}", projectId, line);
|
||||||
|
|
||||||
|
// УДАЛЕНО: messagingTemplate.convertAndSend(...)
|
||||||
|
// Теперь логи транслирует DockerLogsHandler напрямую из Docker-сокета.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
|
||||||
|
// ... остальная логика завершения процесса ...
|
||||||
|
return process.exitValue();
|
||||||
|
|
||||||
|
} catch (IOException | InterruptedException e) {
|
||||||
|
_logger.error("Критическая ошибка при выполнении docker compose: {}", e.getMessage());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public void stopProject(Project project) {
|
public void stopProject(Project project) {
|
||||||
Path projectPath = Paths.get(project.getLocalPath());
|
Path projectPath = Paths.get(project.getLocalPath());
|
||||||
|
|
||||||
_logger.info("Stopping project: {}", project.getId());
|
_logger.info("Stopping project: {}", project.getId());
|
||||||
|
|
||||||
// ProcessBuilder для выполнения docker compose down
|
|
||||||
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down");
|
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down");
|
||||||
pb.directory(projectPath.toFile());
|
pb.directory(projectPath.toFile());
|
||||||
pb.inheritIO();
|
pb.inheritIO();
|
||||||
@@ -131,30 +258,119 @@ public class ProjectService {
|
|||||||
int exitCode = process.waitFor();
|
int exitCode = process.waitFor();
|
||||||
|
|
||||||
if (exitCode == 0) {
|
if (exitCode == 0) {
|
||||||
|
project.setStatus(ProjectStatus.STOPPED);
|
||||||
_logger.info("Project {} stopped successfully.", project.getId());
|
_logger.info("Project {} stopped successfully.", project.getId());
|
||||||
} else {
|
} else {
|
||||||
|
project.setStatus(ProjectStatus.ERROR);
|
||||||
_logger.error("Failed to stop project. Exit code: {}", exitCode);
|
_logger.error("Failed to stop project. Exit code: {}", exitCode);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
project.setStatus(ProjectStatus.ERROR);
|
||||||
_logger.error("Error during docker compose down", e);
|
_logger.error("Error during docker compose down", e);
|
||||||
throw new RuntimeException("Could not stop docker project", e);
|
} finally {
|
||||||
|
projectRepository.save(project);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public void save(Project project) {
|
public void save(Project project) {
|
||||||
projectRepository.save(project);
|
projectRepository.save(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public void updateProjectConfig(UUID projectId, String newYaml) {
|
public void updateProjectConfig(UUID projectId, String newYaml) {
|
||||||
// 1. Ищем конфиг в MongoDB
|
|
||||||
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
|
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
|
||||||
.orElseThrow(() -> new RuntimeException("Config not found for project: " + projectId));
|
.orElseThrow(() -> new RuntimeException("Config not found for project: " + projectId));
|
||||||
|
|
||||||
// 2. Обновляем поле с сырым YAML
|
|
||||||
config.setRawComposeContent(newYaml);
|
config.setRawComposeContent(newYaml);
|
||||||
|
|
||||||
// 3. Сохраняем обратно в Mongo
|
|
||||||
configRepository.save(config);
|
configRepository.save(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteProject(UUID projectId) {
|
||||||
|
Project project = findById(projectId);
|
||||||
|
// Путь берем из проинициализированного поля localPath
|
||||||
|
Path projectPath = Paths.get(project.getLocalPath());
|
||||||
|
|
||||||
|
_logger.info("Запуск полного удаления проекта: {} (путь: {})", projectId, projectPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Останавливаем и удаляем всё в Docker (контейнеры, сети и ТОМА)
|
||||||
|
// Флаг -v критически важен для очистки места
|
||||||
|
runCommand(projectPath, "docker", "compose", "down", "-v");
|
||||||
|
|
||||||
|
if (!Files.isWritable(projectPath)) {
|
||||||
|
throw new RuntimeException("Директория проекта доступна только для чтения. Удаление невозможно.");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 2. Рекурсивно удаляем папку проекта в файловой системе
|
||||||
|
if (Files.exists(projectPath)) {
|
||||||
|
try (var stream = Files.walk(projectPath)) {
|
||||||
|
stream.sorted(Comparator.reverseOrder()) // Сначала файлы, потом папки
|
||||||
|
.map(Path::toFile)
|
||||||
|
.forEach(file -> {
|
||||||
|
if (!file.delete()) {
|
||||||
|
_logger.warn("Не удалось удалить файл: {}", file.getAbsolutePath());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_logger.info("Файловая система очищена для проекта: {}", projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Удаляем данные из БД[cite: 1]
|
||||||
|
// Сначала конфиг (связанная сущность), потом сам проект
|
||||||
|
configRepository.deleteByProjectId(String.valueOf(projectId));
|
||||||
|
projectRepository.delete(project);
|
||||||
|
|
||||||
|
_logger.info("Проект {} успешно удален из системы", projectId);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
_logger.error("Критическая ошибка при удалении проекта {}: {}", projectId, e.getMessage());
|
||||||
|
throw new RuntimeException("Ошибка при полном удалении проекта", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальный метод для запуска быстрых команд Docker без чтения логов в реальном времени
|
||||||
|
*/
|
||||||
|
private void runCommand(Path directory, String... command) throws IOException, InterruptedException {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.directory(directory.toFile());
|
||||||
|
pb.inheritIO();
|
||||||
|
Process process = pb.start();
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
if (exitCode != 0) {
|
||||||
|
_logger.warn("Команда {} завершилась с кодом {}", String.join(" ", command), exitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ContainerStatus> getProjectContainers(UUID projectId) {
|
||||||
|
Project project = findById(projectId);
|
||||||
|
Path projectPath = Paths.get(project.getLocalPath());
|
||||||
|
|
||||||
|
// Формат: ID | Service Name | Image | State | Status
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
|
"docker", "compose", "ps", "--format", "{{.ID}}|{{.Service}}|{{.Image}}|{{.State}}|{{.Status}}"
|
||||||
|
);
|
||||||
|
pb.directory(projectPath.toFile());
|
||||||
|
|
||||||
|
try {
|
||||||
|
Process process = pb.start();
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
return reader.lines()
|
||||||
|
.map(line -> {
|
||||||
|
String[] parts = line.split("\\|");
|
||||||
|
if (parts.length >= 5) {
|
||||||
|
return new ContainerStatus(parts[0], parts[1], parts[2], parts[3], parts[4]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
_logger.error("Ошибка при получении списка контейнеров для проекта {}: {}", projectId, e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,9 @@ import java.util.UUID;
|
|||||||
public interface ProjectRepository extends JpaRepository<Project, UUID> {
|
public interface ProjectRepository extends JpaRepository<Project, UUID> {
|
||||||
|
|
||||||
// Поиск всех проектов конкретного пользователя
|
// Поиск всех проектов конкретного пользователя
|
||||||
List<Project> findAllByOwnerId(UUID ownerId);
|
List<Project> findAllByUserId(Long userId);
|
||||||
|
|
||||||
// Поиск по имени (например, для проверки уникальности внутри аккаунта)
|
// Поиск по имени (например, для проверки уникальности внутри аккаунта)
|
||||||
boolean existsByNameAndOwnerId(String name, UUID ownerId);
|
boolean existsByNameAndUserId(String name, Long userId);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -117,8 +117,13 @@ public class WebSecurityConfig implements WebMvcConfigurer {// extends WebSecuri
|
|||||||
.requestMatchers("/api/public/zhipuai/image/**").permitAll()
|
.requestMatchers("/api/public/zhipuai/image/**").permitAll()
|
||||||
|
|
||||||
|
|
||||||
.requestMatchers("/api/v1/projects").permitAll()
|
.requestMatchers("/api/ws-jambotron/**").permitAll()
|
||||||
.requestMatchers("/api/v1/projects/**").permitAll()
|
|
||||||
|
.requestMatchers("/api/user/projects").permitAll()
|
||||||
|
.requestMatchers("/api/user/projects/**").permitAll()
|
||||||
|
|
||||||
|
.requestMatchers("/api/admin/projects").permitAll()
|
||||||
|
.requestMatchers("/api/admin/projects/**").permitAll()
|
||||||
|
|
||||||
// Access permitted for specific roles
|
// Access permitted for specific roles
|
||||||
.requestMatchers("/api/user/**").hasRole("USER")
|
.requestMatchers("/api/user/**").hasRole("USER")
|
||||||
@@ -137,10 +142,10 @@ public class WebSecurityConfig implements WebMvcConfigurer {// extends WebSecuri
|
|||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if(_environment.getProperty("server.port") != null &&
|
// if(_environment.getProperty("server.port") != null &&
|
||||||
_environment.getProperty("server.port").equalsIgnoreCase("443")) {
|
// _environment.getProperty("server.port").equalsIgnoreCase("443")) {
|
||||||
http.redirectToHttps(withDefaults());
|
// http.redirectToHttps(withDefaults());
|
||||||
}
|
// }
|
||||||
|
|
||||||
http.authenticationProvider(authenticationProvider());
|
http.authenticationProvider(authenticationProvider());
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
CREATE TABLE projects (
|
CREATE TABLE projects (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
owner_id UUID NOT NULL,
|
userID integer NOT NULL,
|
||||||
local_path TEXT, -- Путь к папке проекта
|
local_path TEXT, -- Путь к папке проекта
|
||||||
status VARCHAR(20), -- CREATED, RUNNING, ERROR
|
status VARCHAR(20), -- CREATED, RUNNING, ERROR
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
|||||||
Reference in New Issue
Block a user