add project
This commit is contained in:
Generated
+2244
-952
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,10 @@ export class GlobalConstants {
|
||||
return environment.default_page;
|
||||
})();
|
||||
|
||||
public static readonly HOST_NAME = (() => {
|
||||
return environment.host_name;
|
||||
})();
|
||||
|
||||
public static readonly API_URL = (() => {
|
||||
// ... calculate the value and return it
|
||||
|
||||
|
||||
+1
-2
@@ -10,7 +10,7 @@ import {
|
||||
} from "@angular/forms";
|
||||
import {MarkdownComponent} from "ngx-markdown";
|
||||
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 {MatTab, MatTabGroup} from "@angular/material/tabs";
|
||||
import {ActivatedRoute, RouterLink} from "@angular/router";
|
||||
@@ -47,7 +47,6 @@ type TutorialFormValue = {
|
||||
MatCard,
|
||||
MatCardActions,
|
||||
MatCardContent,
|
||||
MatCardHeader,
|
||||
MatError,
|
||||
MatFormField,
|
||||
MatInput,
|
||||
|
||||
-2
@@ -2,7 +2,6 @@ import { Component } from '@angular/core';
|
||||
import {Tutorial} from '../../../models/tutorial.model';
|
||||
import {MatCard, MatCardActions, MatCardContent, MatCardImage} from '@angular/material/card';
|
||||
import {RouterLink} from '@angular/router';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
import {TutorialsApiService} from '../tutorials-api.service';
|
||||
|
||||
@Component({
|
||||
@@ -11,7 +10,6 @@ import {TutorialsApiService} from '../tutorials-api.service';
|
||||
MatCard,
|
||||
MatCardActions,
|
||||
RouterLink,
|
||||
MatButton,
|
||||
MatCardImage,
|
||||
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 {TokenStorageService} from '../../../services/token-storage.service';
|
||||
import {EventBusService} from '../../../_shared/event-bus.service';
|
||||
import {Router, RouterLink} from '@angular/router';
|
||||
import {AuthService} from '../../../services/auth.service';
|
||||
import {RouterLink} from '@angular/router';
|
||||
import {Tutorial} from '../../../models/tutorial.model';
|
||||
import {MarkdownComponent, MarkdownService} from 'ngx-markdown';
|
||||
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 {MatTab, MatTabGroup} from '@angular/material/tabs';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule, ValidationErrors,
|
||||
ValidatorFn,
|
||||
@@ -24,7 +19,6 @@ import {AngularMarkdownEditorModule, EditorInstance, EditorOption} from 'angular
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
import {DialogSelectImageComponent} from '../dialog-select-image.component/dialog-select-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 {GlobalConstants} from '../../../global-constants';
|
||||
|
||||
@@ -49,7 +43,6 @@ type TutorialFormValue = {
|
||||
MatCard,
|
||||
MatCardActions,
|
||||
MatCardContent,
|
||||
MatCardHeader,
|
||||
MatFormField,
|
||||
MatInput,
|
||||
MatLabel,
|
||||
|
||||
-1
@@ -42,7 +42,6 @@ type TutorialFormValue = {
|
||||
MatCard,
|
||||
MatCardActions,
|
||||
MatCardContent,
|
||||
MatCardHeader,
|
||||
MatFormField,
|
||||
MatInput,
|
||||
MatLabel,
|
||||
|
||||
+2
-3
@@ -22,7 +22,6 @@ import {
|
||||
import {DatePipe} from '@angular/common';
|
||||
import {MatCheckbox} from '@angular/material/checkbox';
|
||||
import {MatSlideToggle} from '@angular/material/slide-toggle';
|
||||
import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/breadcrumbs.component';
|
||||
|
||||
|
||||
@Component({
|
||||
@@ -44,8 +43,8 @@ import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/br
|
||||
MatCheckbox,
|
||||
DatePipe,
|
||||
MatCell,
|
||||
MatSlideToggle,
|
||||
BreadcrumbsComponent
|
||||
MatSlideToggle
|
||||
|
||||
],
|
||||
schemas:[CUSTOM_ELEMENTS_SCHEMA],
|
||||
templateUrl: './tutorials-list.component.html',
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import {Component, CUSTOM_ELEMENTS_SCHEMA, OnInit, ViewChild} 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 {Component, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
|
||||
import {InnerLayoutComponent} from '../../../components/inner-layout.component/inner-layout.component';
|
||||
import {MODERATOR_ROUTS} from '../../moderator-module/moderator.routing';
|
||||
import {USER_ROUTS} from '../user.routing';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user.component',
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
SideBarUserComponent,
|
||||
BreadcrumbsComponent,
|
||||
InnerLayoutComponent
|
||||
],
|
||||
templateUrl: './user.component.html',
|
||||
|
||||
@@ -50,6 +50,21 @@ export const USER_ROUTS: Routes = [
|
||||
path:'docker',
|
||||
loadComponent: () => import('../user-module/docker-management-component/docker-management-component').then((c)=>c.DockerManagementComponent),
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>
|
||||
if (window.global === undefined) {
|
||||
window.global = window;
|
||||
}
|
||||
</script>
|
||||
<meta charset="utf-8">
|
||||
<title>JambotronUi</title>
|
||||
<base href="/">
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>
|
||||
if (window.global === undefined) {
|
||||
window.global = window;
|
||||
}
|
||||
</script>
|
||||
<meta charset="utf-8">
|
||||
<title>JambotronUi</title>
|
||||
<base href="/">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug",
|
||||
"ws": true,
|
||||
"pathRewrite": {"^/api" : "http://localhost:8081/api"}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"/api/**": {
|
||||
"/api": {
|
||||
"target": "http://localhost:8082",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug",
|
||||
"pathRewrite": {"^/api" : "http://localhost:8082/api"}
|
||||
|
||||
"timeout": 0,
|
||||
"ws": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user