EDIT subdomen

This commit is contained in:
2026-05-17 01:05:04 +03:00
parent bae059df3f
commit 584fa9e320
13 changed files with 1075 additions and 432 deletions
@@ -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>
}
@@ -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;
}
}