EDIT subdomen
This commit is contained in:
+108
-7
@@ -16,15 +16,116 @@
|
||||
<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>
|
||||
<mat-card-content class="flex flex-col p-4">
|
||||
|
||||
<div class="card-layout flex w-full justify-between items-start gap-4">
|
||||
|
||||
<div class="card-left flex-1 min-w-0">
|
||||
<span class="c-name block text-base font-bold text-gray-150 truncate" [title]="container.name">
|
||||
{{ container.name }}
|
||||
</span>
|
||||
<span class="c-image block text-xs text-gray-500 font-mono mt-0.5 truncate" [title]="container.image">
|
||||
{{ container.image }}
|
||||
</span>
|
||||
|
||||
@if (containerStats.get(container.id); as stats) {
|
||||
<div class="stats-row" style="font-size: 11px; color: #888; margin-top: 6px;">
|
||||
CPU: {{ stats.cpuUsage | number:'1.1-1' }}% | RAM: {{ stats.memoryPercent | number:'1.0-0' }}%
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card-right flex flex-col align-items-end justify-between items-end gap-2 shrink-0">
|
||||
<div class="actions-row flex gap-1">
|
||||
<button mat-icon-button (click)="toggleContainer(container)"
|
||||
[color]="container.state === 'running' ? 'warn' : 'primary'" class="!w-8 !h-8 !line-height-[32px]">
|
||||
<mat-icon class="!text-lg">{{ container.state === 'running' ? 'stop' : 'play_arrow' }}</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="restartContainer(container.id)" style="color: #ffbd2e;" class="!w-8 !h-8 !line-height-[32px]">
|
||||
<mat-icon class="!text-lg">refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<div class="state-dot" [class.running]="container.state === 'running'"></div>
|
||||
<span class="uptime text-xs text-gray-400 font-mono">{{ container.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="card-right">
|
||||
<div class="state-dot" [class.running]="container.state === 'running'"></div>
|
||||
<span class="uptime">{{ container.status }}</span>
|
||||
|
||||
<div class="w-full mt-4">
|
||||
<div class="p-2.5 bg-gray-950/40 rounded-lg border border-gray-850 flex flex-col justify-center min-h-[52px]">
|
||||
|
||||
<div *ngIf="!editingDomains[container.id]" class="flex items-center justify-between gap-2 w-full">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-[10px] uppercase tracking-wider font-bold text-gray-500">Маршрутизация сети:</span>
|
||||
|
||||
<ng-container *ngIf="getContainerDomain(container.name) as domain; else noDomain">
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<a [href]="'https://' + domain" target="_blank" class="text-xs font-semibold text-blue-400 hover:text-blue-300 hover:underline flex items-center gap-1 font-mono">
|
||||
<svg class="w-3 h-3 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h14a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
<span class="truncate max-w-[180px]">{{ domain }}</span>
|
||||
</a>
|
||||
<span class="text-[10px] text-gray-500 font-mono bg-gray-900 px-1.5 py-0.25 rounded border border-gray-800 shrink-0">
|
||||
:{{ getContainerPort(container.name) }}
|
||||
</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #noDomain>
|
||||
<span class="text-xs text-gray-600 italic mt-0.5">Локальный стек (нет публичного прокси)</span>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<button (click)="startEditDomain(container)" class="p-1 text-gray-500 hover:text-blue-400 hover:bg-gray-900 rounded border border-transparent hover:border-gray-850 transition shadow-sm shrink-0" title="Изменить настройки Caddy">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="editingDomains[container.id]" class="w-full space-y-2 animate-fadeIn">
|
||||
<div class="flex items-center justify-between border-b border-gray-850 pb-1 mb-1">
|
||||
<span class="text-[9px] uppercase tracking-wider font-bold text-blue-400">Настройка шлюза</span>
|
||||
<div class="flex gap-1.5">
|
||||
<button (click)="saveDomainConfig(container)" class="bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold px-2 py-0.5 rounded transition shadow-md">
|
||||
OK
|
||||
</button>
|
||||
<button (click)="cancelEditDomain(container.id)" class="bg-gray-800 hover:bg-gray-750 text-gray-400 text-[10px] font-medium px-2 py-0.5 rounded transition">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-1.5">
|
||||
<div class="col-span-2 relative flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="tempSubdomain"
|
||||
placeholder="subdomain"
|
||||
class="w-full bg-gray-900 border border-gray-800 rounded pl-2 pr-12 py-1 text-xs text-gray-200 font-mono focus:outline-none focus:border-blue-600 lowercase"
|
||||
/>
|
||||
<span class="absolute right-2 text-[9px] font-mono text-gray-600 pointer-events-none">.jamb</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="tempPort"
|
||||
placeholder="Порт"
|
||||
class="w-full bg-gray-900 border border-gray-800 rounded px-2 py-1 text-xs text-gray-200 font-mono focus:outline-none focus:border-blue-600"
|
||||
title="Внутренний порт службы внутри docker-compose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
+207
-7
@@ -3,18 +3,26 @@ import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked }
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Subscription, timer, switchMap, catchError, of } from 'rxjs';
|
||||
import { ProjectService, Project, DockerContainer, ProjectDetailResponse } from '../../../services/project.service';
|
||||
import {
|
||||
ProjectService,
|
||||
Project,
|
||||
DockerContainer,
|
||||
ProjectDetailResponse,
|
||||
ContainerStats
|
||||
} from '../../../services/project.service';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatCard, MatCardContent } from '@angular/material/card';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-details',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatProgressSpinnerModule,
|
||||
@@ -66,23 +74,39 @@ export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChec
|
||||
});
|
||||
}
|
||||
|
||||
// Опитування стану контейнерів кожні 5 секунд
|
||||
private startMonitoring(id: string): void {
|
||||
this.pollingSub = timer(0, 5000).pipe(
|
||||
switchMap(() => this.projectService.getProjectContainers(id).pipe(
|
||||
catchError(() => of([])) // У разі помилки повертаємо порожній список
|
||||
catchError(() => of([]))
|
||||
))
|
||||
).subscribe({
|
||||
next: (data: DockerContainer[]) => {
|
||||
const activeIds = data.map(c => c.id);
|
||||
|
||||
// Очистка подписок для исчезнувших контейнеров
|
||||
this.statsSubs.forEach((sub, containerId) => {
|
||||
if (!activeIds.includes(containerId)) {
|
||||
sub.unsubscribe();
|
||||
this.statsSubs.delete(containerId);
|
||||
this.containerStats.delete(containerId);
|
||||
}
|
||||
});
|
||||
|
||||
this.containers = data;
|
||||
|
||||
// Якщо з'явилися контейнери і ми ще не підключені до логів — підключаємося
|
||||
// Инициация подписок для новых запущенных контейнеров
|
||||
this.containers.forEach(container => {
|
||||
if (container.state === 'running' && !this.statsSubs.has(container.id)) {
|
||||
this.subscribeToContainerStats(container.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Логика подключения к логам (сохранена)
|
||||
if (this.containers.length > 0 && !this.logSub) {
|
||||
const mainContainer = this.containers.find(c => c.state === 'running') || this.containers[0];
|
||||
this.connectLogs(mainContainer.id);
|
||||
}
|
||||
},
|
||||
error: (err) => console.error('Помилка моніторингу:', err)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,6 +135,16 @@ export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChec
|
||||
deploy(): void {
|
||||
if (this.project) {
|
||||
this.loading = true;
|
||||
this.logs = []; // Очищаем окно перед новым деплоем
|
||||
|
||||
// Подписываемся на системный поток деплоя
|
||||
this.projectService.connectToDeployLogs(this.project.id).subscribe({
|
||||
next: (line) => {
|
||||
this.logs.push(line);
|
||||
if (this.logs.length > 200) this.logs.shift();
|
||||
}
|
||||
});
|
||||
|
||||
this.projectService.deployProject(this.project.id).subscribe({
|
||||
next: () => this.loading = false,
|
||||
error: () => this.loading = false
|
||||
@@ -148,7 +182,12 @@ export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChec
|
||||
ngOnDestroy(): void {
|
||||
this.pollingSub?.unsubscribe();
|
||||
this.logSub?.unsubscribe();
|
||||
this.projectService.disconnectLogs(); // Закриваємо з'єднання з брокером
|
||||
|
||||
// Очищаем все активные подписки на метрики контейнеров
|
||||
this.statsSubs.forEach(sub => sub.unsubscribe());
|
||||
this.statsSubs.clear();
|
||||
|
||||
this.projectService.disconnectLogs(); // Закрываем основное STOMP соединение
|
||||
}
|
||||
|
||||
// Форматування для відображення кольорів у терміналі
|
||||
@@ -167,4 +206,165 @@ export class ProjectDetailsComponent implements OnInit, OnDestroy, AfterViewChec
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
|
||||
containerStats: Map<string, ContainerStats> = new Map();
|
||||
private statsSubs: Map<string, Subscription> = new Map();
|
||||
|
||||
// Новый метод для живых метрик
|
||||
private subscribeToContainerStats(containerId: string): void {
|
||||
const sub = this.projectService.connectToStats(containerId).subscribe({
|
||||
next: (stats) => {
|
||||
this.containerStats.set(containerId, stats);
|
||||
},
|
||||
error: (err) => console.error(`Stats error for ${containerId}:`, err)
|
||||
});
|
||||
this.statsSubs.set(containerId, sub);
|
||||
}
|
||||
|
||||
// Переключение состояния (Start/Stop)
|
||||
toggleContainer(container: DockerContainer): void {
|
||||
if (!this.project) return;
|
||||
|
||||
const obs = container.state === 'running'
|
||||
? this.projectService.stopContainer(this.project.id, container.id)
|
||||
: this.projectService.startContainer(this.project.id, container.id);
|
||||
|
||||
obs.subscribe({
|
||||
next: () => console.log(`Статус контейнера ${container.id} изменен`),
|
||||
error: (err) => console.error('Ошибка управления:', err)
|
||||
});
|
||||
}
|
||||
|
||||
// Рестарт
|
||||
restartContainer(containerId: string): void {
|
||||
if (this.project) {
|
||||
this.projectService.restartContainer(this.project.id, containerId).subscribe({
|
||||
next: () => console.log(`Контейнер ${containerId} перезапущен`),
|
||||
error: (err) => console.error('Ошибка рестарта:', err)
|
||||
});
|
||||
}
|
||||
}
|
||||
// ==========================================
|
||||
// CADDY ROUTING & INLINE EDITING MANAGEMENT
|
||||
// ==========================================
|
||||
|
||||
// Хранит состояние редактирования для каждого контейнера: { [containerId]: boolean }
|
||||
editingDomains: { [key: string]: boolean } = {};
|
||||
|
||||
// Временные переменные для хранения ввода пользователя во время редактирования
|
||||
tempSubdomain: string = '';
|
||||
tempPort: number = 80;
|
||||
|
||||
/**
|
||||
* Извлекает имя сервиса (например, 'web') из полного имени Docker-контейнера
|
||||
*/
|
||||
getServiceName(containerName: string): string | null {
|
||||
if (!containerName || !this.project?.id) return null;
|
||||
|
||||
// Проверяем префикс по ID проекта
|
||||
const prefixId = `jambotron-${this.project.id}-`;
|
||||
// Резервный префикс по имени проекта (на случай, если бэкенд использует его)
|
||||
const prefixName = `jambotron-${this.project.name}-`;
|
||||
|
||||
let servicePart = '';
|
||||
|
||||
if (containerName.startsWith(prefixId)) {
|
||||
servicePart = containerName.substring(prefixId.length);
|
||||
} else if (containerName.startsWith(prefixName)) {
|
||||
servicePart = containerName.substring(prefixName.length);
|
||||
} else {
|
||||
// Фолбэк-разбор для коротких имён, если префиксы не подошли
|
||||
const parts = containerName.split('-');
|
||||
if (parts.length >= 3 && parts[0] === 'jambotron') {
|
||||
servicePart = parts.slice(2).join('-');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Отрезаем индекс реплики Compose (например, "-1")
|
||||
if (servicePart.endsWith('-1')) {
|
||||
servicePart = servicePart.substring(0, servicePart.length - 2);
|
||||
}
|
||||
return servicePart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает текущий сформированный домен для отображения
|
||||
*/
|
||||
getContainerDomain(containerName: string): string | null {
|
||||
const serviceName = this.getServiceName(containerName);
|
||||
if (!serviceName || !this.config?.serviceConfigs) return null;
|
||||
|
||||
const serviceConfig = this.config.serviceConfigs[serviceName];
|
||||
return serviceConfig && serviceConfig.subdomain ? `${serviceConfig.subdomain}.jambotron.com` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает текущий порт сервиса
|
||||
*/
|
||||
getContainerPort(containerName: string): number {
|
||||
const serviceName = this.getServiceName(containerName);
|
||||
if (!serviceName || !this.config?.serviceConfigs) return 80;
|
||||
|
||||
const serviceConfig = this.config.serviceConfigs[serviceName];
|
||||
return serviceConfig && serviceConfig.port ? serviceConfig.port : 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вход в режим редактирования плашки
|
||||
*/
|
||||
startEditDomain(container: any) {
|
||||
const serviceName = this.getServiceName(container.name);
|
||||
if (!serviceName) return;
|
||||
|
||||
this.editingDomains[container.id] = true;
|
||||
|
||||
// Инициализируем временные переменные текущими значениями из Mongo конфигурации
|
||||
const currentConfig = this.config?.serviceConfigs?.[serviceName];
|
||||
this.tempSubdomain = currentConfig?.subdomain || '';
|
||||
this.tempPort = currentConfig?.port || 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение изменённого домена и порта на бэкенд
|
||||
*/
|
||||
saveDomainConfig(container: any) {
|
||||
const serviceName = this.getServiceName(container.name);
|
||||
if (!serviceName || !this.project?.id || !this.config) return;
|
||||
|
||||
// Гарантируем существование мапы конфигураций
|
||||
if (!this.config.serviceConfigs) {
|
||||
this.config.serviceConfigs = {};
|
||||
}
|
||||
|
||||
const subdomainClean = this.tempSubdomain.trim().toLowerCase();
|
||||
|
||||
if (!subdomainClean) {
|
||||
// Если поддомен стёрли — удаляем настройку проксирования для этого сервиса
|
||||
delete this.config.serviceConfigs[serviceName];
|
||||
} else {
|
||||
this.config.serviceConfigs[serviceName] = {
|
||||
subdomain: subdomainClean,
|
||||
port: this.tempPort || 80
|
||||
};
|
||||
}
|
||||
|
||||
// Отправляем монолитный конфиг в базу MongoDB через твой сохранённый сервис
|
||||
this.projectService.updateProjectConfig(this.project.id, this.config).subscribe({
|
||||
next: (updatedConfig) => {
|
||||
this.config = updatedConfig;
|
||||
this.editingDomains[container.id] = false;
|
||||
console.log(`Конфигурация Caddy для ${serviceName} успешно зафиксирована.`);
|
||||
},
|
||||
error: (err) => {
|
||||
alert(err.error?.message || 'Ошибка валидации доменного имени.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelEditDomain(containerId: string) {
|
||||
this.editingDomains[containerId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,29 @@ export interface ProjectDetailResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContainerStats {
|
||||
containerId: string;
|
||||
cpuUsage: number;
|
||||
memoryUsage: number;
|
||||
memoryLimit: number;
|
||||
memoryPercent: number;
|
||||
}
|
||||
// Описание структуры конфигурации доменов созидания
|
||||
export interface ServiceSettings {
|
||||
subdomain?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface ProjectConfig {
|
||||
id?: string;
|
||||
projectId: string;
|
||||
rawComposeContent: string;
|
||||
serviceNames: string[];
|
||||
serviceConfigs: { [key: string]: ServiceSettings };
|
||||
version?: number;
|
||||
}
|
||||
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -123,4 +146,62 @@ export class ProjectService {
|
||||
getProjectContainers(projectId: string): Observable<DockerContainer[]> {
|
||||
return this.http.get<DockerContainer[]>(`${this.baseUrl}/user/projects/${projectId}/containers`);
|
||||
}
|
||||
// Добавь в project.service.ts
|
||||
public connectToDeployLogs(projectId: string): Observable<string> {
|
||||
const deploySubject = new Subject<string>();
|
||||
const socket = new SockJS(`${this.baseUrl}/ws-jambotron`);
|
||||
|
||||
const client = new Client({
|
||||
webSocketFactory: () => socket,
|
||||
onConnect: () => {
|
||||
// Подписываемся на системный топик деплоя
|
||||
client.subscribe(`/topic/deploy/${projectId}`, (message: IMessage) => {
|
||||
deploySubject.next(message.body);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
client.activate();
|
||||
return deploySubject.asObservable();
|
||||
}
|
||||
|
||||
public connectToStats(containerId: string): Observable<ContainerStats> {
|
||||
const statsSubject = new Subject<ContainerStats>();
|
||||
const socket = new SockJS(`${this.baseUrl}/ws-jambotron`);
|
||||
const client = new Client({
|
||||
webSocketFactory: () => socket,
|
||||
onConnect: () => {
|
||||
client.subscribe(`/topic/stats/${containerId}`, (message) => {
|
||||
statsSubject.next(JSON.parse(message.body));
|
||||
});
|
||||
// Сигнал к началу сбора
|
||||
client.publish({ destination: '/app/start-stats', body: containerId });
|
||||
}
|
||||
});
|
||||
client.activate();
|
||||
return statsSubject.asObservable();
|
||||
}
|
||||
|
||||
restartContainer(projectId: string, containerId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/restart`, {});
|
||||
}
|
||||
|
||||
stopContainer(projectId: string, containerId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/stop`, {});
|
||||
}
|
||||
|
||||
startContainer(projectId: string, containerId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/user/projects/${projectId}/containers/${containerId}/start`, {});
|
||||
}
|
||||
|
||||
// Получить конфигурацию поддоменов из MongoDB
|
||||
getProjectConfig(projectId: string): Observable<ProjectConfig> {
|
||||
return this.http.get<ProjectConfig>(`${this.baseUrl}/user/projects/${projectId}/config`);
|
||||
}
|
||||
|
||||
// Обновить конфигурацию (сохранить измененные домены и порты)
|
||||
updateProjectConfig(projectId: string, config: ProjectConfig): Observable<ProjectConfig> {
|
||||
return this.http.put<ProjectConfig>(`${this.baseUrl}/user/projects/${projectId}/config`, config);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user