feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile

- Добавлена колонка 'Тип' во все таблицы истории сборок
- Для push операций отображается registry вместо платформ
- Сохранение пользователя при создании push лога
- Исправлена ошибка с logger в push_docker_image endpoint
- Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
Сергей Антропов
2026-02-15 22:59:02 +03:00
parent 23e1a6037b
commit 1fbf9185a2
232 changed files with 38075 additions and 5 deletions

View File

@@ -0,0 +1,993 @@
{% extends "base.html" %}
{% block title %}Сборка Dockerfile - {{ dockerfile.name }} - DevOpsLab{% endblock %}
{% block page_title %}Сборка Dockerfile: {{ dockerfile.name }}{% endblock %}
{% block content %}
<input type="hidden" id="dockerfile-content-hidden" value="{{ dockerfile.content | e }}">
<div class="row">
<!-- Левая колонка: Форма сборки и текущие логи -->
<div class="col-lg-8">
<!-- Форма сборки -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-hammer me-2"></i>
Параметры сборки
</h5>
</div>
<div class="card-body">
<form id="build-form">
<div class="mb-3">
<label class="form-label">Имя образа (без тега)</label>
<input type="text" id="build-image-name" class="form-control"
value="inecs/ansible-lab:{{ dockerfile.name }}" required>
<div class="form-text">Тег будет добавлен автоматически</div>
</div>
<div class="mb-3">
<label class="form-label">Тег</label>
<input type="text" id="build-tag" class="form-control" value="latest" required>
</div>
<div class="mb-3">
<label class="form-label">Платформы для сборки</label>
<div class="row">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/amd64" id="build-platform-amd64" checked>
<label class="form-check-label" for="build-platform-amd64">
<i class="fab fa-linux me-1"></i>linux/amd64 (x86_64)
</label>
</div>
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/386" id="build-platform-386" checked>
<label class="form-check-label" for="build-platform-386">
<i class="fab fa-linux me-1"></i>linux/386 (x86)
</label>
</div>
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/arm64" id="build-platform-arm64" checked>
<label class="form-check-label" for="build-platform-arm64">
<i class="fab fa-apple me-1"></i>linux/arm64 (macOS M1/M2)
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/arm/v7" id="build-platform-armv7">
<label class="form-check-label" for="build-platform-armv7">
<i class="fas fa-microchip me-1"></i>linux/arm/v7
</label>
</div>
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/riscv64" id="build-platform-riscv64">
<label class="form-check-label" for="build-platform-riscv64">
<i class="fas fa-server me-1"></i>linux/riscv64
</label>
</div>
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/ppc64le" id="build-platform-ppc64le">
<label class="form-check-label" for="build-platform-ppc64le">
<i class="fas fa-server me-1"></i>linux/ppc64le
</label>
</div>
</div>
</div>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
По умолчанию выбраны: linux/amd64, linux/386, linux/arm64
</div>
<input type="hidden" name="build-platforms-json" id="build-platforms-json-hidden">
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="build-no-cache" name="no_cache">
<label class="form-check-label" for="build-no-cache">
<i class="fas fa-ban me-1"></i>
Сборка без кеша (--no-cache)
</label>
</div>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
При включении сборка будет выполняться без использования кеша Docker
</div>
</div>
<button type="button" class="btn btn-primary" onclick="startBuild()">
<i class="fas fa-hammer me-2"></i>
Начать сборку
</button>
<a href="/dockerfiles/{{ dockerfile.id }}/edit" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
</form>
</div>
</div>
<!-- Логи сборки -->
<div class="card" id="build-logs-card" style="display: none;">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Логи сборки</h5>
<div class="d-flex gap-2">
<button type="button" id="push-image-btn" class="btn btn-sm btn-primary" onclick="startPush()" style="display: none;">
<i class="fas fa-upload me-1"></i>
Отправить образ
</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="clearLogs()">
<i class="fas fa-times me-1"></i>
Очистить
</button>
</div>
</div>
<div class="card-body p-0">
<div class="log-container" id="build-logs" style="height: 500px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 1rem; font-family: 'Courier New', monospace; font-size: 0.875rem;">
<!-- Логи будут добавлены через WebSocket -->
</div>
</div>
</div>
</div>
<!-- Правая колонка: История сборок -->
<div class="col-lg-4">
<div class="card" id="build-history-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-history me-2"></i>
История сборок
</h5>
<a href="/dockerfiles/{{ dockerfile.id }}/build-logs" class="btn btn-sm btn-primary">
<i class="fas fa-list me-1"></i>
Все логи
</a>
</div>
<div class="card-body">
{% if recent_logs %}
<div class="list-group list-group-flush">
{% for log in recent_logs %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1">
<code>{{ log.image_name }}{% if log.tag %}:{{ log.tag }}{% endif %}</code>
</h6>
<small class="text-muted">
{% if log.started_at %}
{{ log.started_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
{% if log.duration %}
• {{ log.duration }} сек
{% endif %}
</small>
<div class="mt-1">
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
<span class="badge bg-info me-1">
<i class="fas fa-upload me-1"></i>Push
</span>
{% else %}
<span class="badge bg-secondary me-1">
<i class="fas fa-hammer me-1"></i>Build
</span>
{% endif %}
{% if log.status == "success" %}
<span class="badge bg-success">Успешно</span>
{% elif log.status == "failed" %}
<span class="badge bg-danger">Ошибка</span>
{% else %}
<span class="badge bg-warning">Выполняется</span>
{% endif %}
</div>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="showLogDetail({{ log.id }})" title="Просмотр логов">
<i class="fas fa-eye"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteLog({{ log.id }})" title="Удалить лог">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info mb-0">
<i class="fas fa-info-circle me-2"></i>
История сборок пуста
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Модальное окно для просмотра лога -->
<div class="modal fade" id="logDetailModal" tabindex="-1">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Детали лога сборки</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="log-detail-content" class="log-container">
Загрузка...
</div>
</div>
</div>
</div>
</div>
<!-- Модальное окно для выбора registry -->
<div class="modal fade" id="pushRegistryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Выбор Registry для отправки образа</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Registry</label>
<select id="push-registry-select" class="form-select">
<option value="docker.io">Docker Hub (hub.docker.com)</option>
<option value="harbor">Harbor</option>
</select>
</div>
<div id="push-registry-info" class="alert alert-info">
<small>Будут использованы настройки из вашего профиля</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" onclick="confirmPush()">
<i class="fas fa-upload me-1"></i>
Отправить
</button>
</div>
</div>
</div>
</div>
<script>
// Сохраненные платформы из БД
const savedPlatforms = {{ dockerfile.platforms | tojson if dockerfile.platforms else '["linux/amd64", "linux/386", "linux/arm64"]' }};
// Загружаем последние логи при загрузке страницы
document.addEventListener('DOMContentLoaded', function() {
loadRecentLogs();
// Устанавливаем высоту после загрузки DOM
});
// Функция для обновления JSON платформ
function updateBuildPlatformsJson() {
const checkboxes = document.querySelectorAll('.build-platform-checkbox:checked');
const platforms = Array.from(checkboxes).map(cb => cb.value);
document.getElementById('build-platforms-json-hidden').value = JSON.stringify(platforms);
return platforms;
}
// Инициализация чекбоксов платформ
document.querySelectorAll('.build-platform-checkbox').forEach(cb => {
cb.addEventListener('change', updateBuildPlatformsJson);
// Устанавливаем checked для сохраненных платформ
if (savedPlatforms.includes(cb.value)) {
cb.checked = true;
}
});
updateBuildPlatformsJson(); // Инициализация
// Функция для запуска сборки
window.startBuild = function() {
const imageName = document.getElementById('build-image-name').value;
const tag = document.getElementById('build-tag').value;
const dockerfileContentHidden = document.getElementById('dockerfile-content-hidden');
const dockerfileContent = dockerfileContentHidden ? dockerfileContentHidden.value : '';
const noCache = document.getElementById('build-no-cache').checked;
// Получаем выбранные платформы из чекбоксов
const platforms = updateBuildPlatformsJson();
if (platforms.length === 0) {
alert('Выберите хотя бы одну платформу для сборки!');
return;
}
// Показываем карточку с логами и прокручиваем к ней
const logsCard = document.getElementById('build-logs-card');
logsCard.style.display = 'block';
logsCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
const logsContainer = document.getElementById('build-logs');
logsContainer.innerHTML = '<div class="text-info">🔗 Подключение к WebSocket...</div>';
// Скрываем кнопку отправки при начале новой сборки
const pushButton = document.getElementById('push-image-btn');
if (pushButton) {
pushButton.style.display = 'none';
}
// Сбрасываем глобальные переменные
currentImageName = null;
currentImageTag = null;
// Подключаемся к WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/dockerfile/build/{{ dockerfile.id }}`);
currentWebSocket = ws;
ws.onopen = () => {
logsContainer.innerHTML = '<div class="text-info">🚀 Запуск сборки...</div>';
ws.send(JSON.stringify({
image_name: imageName,
tag: tag,
platforms: platforms,
dockerfile_content: dockerfileContent,
no_cache: noCache
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
const logLine = document.createElement('div');
logLine.className = 'log-line';
if (data.level === 'error') {
logLine.classList.add('log-error');
} else if (data.level === 'warning') {
logLine.classList.add('log-warning');
} else if (data.level === 'info') {
logLine.classList.add('log-info');
}
logLine.textContent = data.data;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
} else if (data.type === 'info' && data.data) {
// Сохраняем build_log_id и celery_task_id если они есть
if (data.build_log_id) {
currentBuildLogId = data.build_log_id;
}
if (data.celery_task_id) {
currentCeleryTaskId = data.celery_task_id;
}
// Показываем кнопку остановки если сборка запущена
if (data.build_log_id || data.data.includes('запущена в фоне')) {
// Проверяем, не добавлена ли уже кнопка
if (!document.getElementById('stop-build-button-container')) {
const stopButtonContainer = document.createElement('div');
stopButtonContainer.className = 'mt-2';
stopButtonContainer.id = 'stop-build-button-container';
stopButtonContainer.innerHTML = `
<button id="stop-build-btn" class="btn btn-danger btn-sm" onclick="stopBuild()">
<i class="fas fa-stop me-1"></i>
Остановить сборку
</button>
`;
logsContainer.appendChild(stopButtonContainer);
}
}
// Показываем сообщение как обычный лог
const logLine = document.createElement('div');
logLine.className = 'log-line log-info';
logLine.textContent = data.data;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
} else if (data.type === 'complete') {
const logLine = document.createElement('div');
logLine.className = 'log-line log-info';
logLine.textContent = data.data || `✅ Сборка завершена: ${data.status}`;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
ws.close();
currentWebSocket = null;
// Удаляем кнопку остановки
const stopButtonContainer = document.getElementById('stop-build-button-container');
if (stopButtonContainer) {
stopButtonContainer.remove();
}
// Если сборка успешна, показываем кнопку Push в заголовке
if (data.status === 'success' && data.image_name && data.tag) {
currentImageName = data.image_name;
currentImageTag = data.tag;
const pushButton = document.getElementById('push-image-btn');
if (pushButton) {
pushButton.style.display = 'inline-block';
}
}
// Обновляем историю сборок без перезагрузки страницы
loadRecentLogs();
} else if (data.type === 'error') {
const logLine = document.createElement('div');
logLine.className = 'log-line log-error';
logLine.textContent = data.data || '❌ Ошибка при сборке';
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
ws.close();
}
};
ws.onerror = (error) => {
const logLine = document.createElement('div');
logLine.className = 'log-line log-error';
logLine.textContent = `❌ Ошибка подключения: ${error}`;
logsContainer.appendChild(logLine);
};
ws.onclose = () => {
const logLine = document.createElement('div');
logLine.className = 'log-line log-info';
logLine.textContent = '🔌 Соединение закрыто';
logsContainer.appendChild(logLine);
};
};
function clearLogs() {
const logsContainer = document.getElementById('build-logs');
logsContainer.innerHTML = '';
// Скрываем кнопку отправки при очистке логов
const pushButton = document.getElementById('push-image-btn');
if (pushButton) {
pushButton.style.display = 'none';
}
// Сбрасываем глобальные переменные
currentImageName = null;
currentImageTag = null;
}
async function showLogDetail(logId) {
const modalElement = document.getElementById('logDetailModal');
const modal = new bootstrap.Modal(modalElement);
const content = document.getElementById('log-detail-content');
content.textContent = 'Загрузка...';
// Функция для установки высоты
const setHeight = () => {
const modalBody = document.querySelector('#logDetailModal .modal-body');
const modalHeader = document.querySelector('#logDetailModal .modal-header');
if (modalBody && modalHeader) {
const headerHeight = modalHeader.offsetHeight;
const availableHeight = window.innerHeight - headerHeight;
modalBody.style.height = availableHeight + 'px';
modalBody.style.minHeight = availableHeight + 'px';
modalBody.style.maxHeight = availableHeight + 'px';
content.style.height = availableHeight + 'px';
content.style.minHeight = availableHeight + 'px';
content.style.maxHeight = availableHeight + 'px';
}
};
// Устанавливаем высоту при открытии модального окна
modalElement.addEventListener('shown.bs.modal', function onShown() {
setHeight();
// Также устанавливаем при изменении размера окна
window.addEventListener('resize', setHeight);
modalElement.removeEventListener('shown.bs.modal', onShown);
});
// Убираем обработчик resize при закрытии
modalElement.addEventListener('hidden.bs.modal', function onHidden() {
window.removeEventListener('resize', setHeight);
modalElement.removeEventListener('hidden.bs.modal', onHidden);
});
modal.show();
try {
const response = await fetch(`/api/v1/dockerfiles/build-logs/${logId}`);
const data = await response.json();
if (data.logs) {
content.textContent = data.logs;
} else {
content.textContent = 'Логи не найдены';
}
} catch (error) {
content.textContent = `Ошибка загрузки: ${error.message}`;
}
}
// Глобальные переменные для управления сборкой
let currentWebSocket = null;
let currentBuildLogId = null;
let currentCeleryTaskId = null;
let currentImageName = null;
let currentImageTag = null;
// Функция для установки высоты правой колонки равной левой
// Функция для загрузки последних логов сборки
async function loadRecentLogs() {
try {
const response = await fetch(`/api/v1/dockerfiles/{{ dockerfile.id }}/build-logs/recent?limit=5`);
const logs = await response.json();
// Находим контейнер для истории сборок
const cardBody = document.querySelector('#build-history-card .card-body');
if (!cardBody) return;
// Проверяем, есть ли уже список
let logsContainer = cardBody.querySelector('.list-group');
if (logs.length === 0) {
// Если логов нет, показываем сообщение
cardBody.innerHTML = `
<div class="alert alert-info mb-0">
<i class="fas fa-info-circle me-2"></i>
История сборок пуста
</div>
`;
return;
}
// Если списка нет, создаем его
if (!logsContainer) {
cardBody.innerHTML = '<div class="list-group list-group-flush"></div>';
logsContainer = cardBody.querySelector('.list-group');
}
// Очищаем текущий список
logsContainer.innerHTML = '';
// Добавляем логи
logs.forEach(log => {
const logItem = document.createElement('div');
logItem.className = 'list-group-item';
// Определяем тип операции (build или push)
const operationType = log.extra_data && log.extra_data.type === 'push' ? 'push' : 'build';
const operationBadge = operationType === 'push'
? '<span class="badge bg-info me-1"><i class="fas fa-upload me-1"></i>Push</span>'
: '<span class="badge bg-secondary me-1"><i class="fas fa-hammer me-1"></i>Build</span>';
const statusBadge = log.status === 'success'
? '<span class="badge bg-success">Успешно</span>'
: log.status === 'failed'
? '<span class="badge bg-danger">Ошибка</span>'
: '<span class="badge bg-warning">Выполняется</span>';
const startedAt = log.started_at
? new Date(log.started_at).toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: '';
const duration = log.duration ? `${log.duration} сек` : '';
logItem.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1">
<code>${log.image_name}${log.tag ? ':' + log.tag : ''}</code>
</h6>
<small class="text-muted">
${startedAt}${duration}
</small>
<div class="mt-1">
${operationBadge}
${statusBadge}
</div>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="showLogDetail(${log.id})" title="Просмотр логов">
<i class="fas fa-eye"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteLog(${log.id})" title="Удалить лог">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
logsContainer.appendChild(logItem);
});
} catch (error) {
console.error('Ошибка загрузки истории сборок:', error);
}
}
// Функция для остановки сборки
async function stopBuild() {
if (!currentBuildLogId) {
alert('ID сборки не найден');
return;
}
const confirmed = await showConfirmModal('Вы уверены, что хотите остановить сборку?');
if (!confirmed) {
return;
}
try {
const response = await fetch(`/api/v1/dockerfiles/build-logs/${currentBuildLogId}/cancel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
const logsContainer = document.getElementById('build-logs');
const logLine = document.createElement('div');
logLine.className = 'log-line log-warning';
logLine.textContent = '⚠️ Сборка остановлена пользователем';
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
// Удаляем кнопку остановки
const stopButtonContainer = document.getElementById('stop-build-button-container');
if (stopButtonContainer) {
stopButtonContainer.remove();
}
// Закрываем WebSocket
if (currentWebSocket) {
currentWebSocket.close();
currentWebSocket = null;
}
// Обновляем историю
setTimeout(() => {
loadRecentLogs();
}, 1000);
} else {
const errorData = await response.json();
alert(`Ошибка при остановке сборки: ${errorData.detail || 'Неизвестная ошибка'}`);
}
} catch (error) {
alert(`Ошибка при остановке сборки: ${error.message}`);
}
}
// Периодическое обновление истории сборок (каждые 5 секунд)
setInterval(() => {
loadRecentLogs();
}, 5000);
let selectedRegistry = 'docker.io';
function startPush() {
if (!currentImageName || !currentImageTag) {
alert('Информация об образе не найдена. Запустите сборку заново.');
return;
}
// Показываем модальное окно для выбора registry
const modal = new bootstrap.Modal(document.getElementById('pushRegistryModal'));
modal.show();
}
async function confirmPush() {
// Получаем выбранный registry
selectedRegistry = document.getElementById('push-registry-select').value;
// Закрываем модальное окно
const modal = bootstrap.Modal.getInstance(document.getElementById('pushRegistryModal'));
modal.hide();
// Запускаем push
await executePush();
}
async function executePush() {
if (!currentImageName || !currentImageTag) {
alert('Информация об образе не найдена. Запустите сборку заново.');
return;
}
const dockerfileId = {{ dockerfile.id }};
const pushButton = document.getElementById('push-image-btn');
if (pushButton) {
pushButton.disabled = true;
pushButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Отправка...';
}
const logsContainer = document.getElementById('build-logs');
const logLine = document.createElement('div');
logLine.className = 'log-line log-info';
logLine.textContent = `🚀 Запуск отправки образа ${currentImageName}:${currentImageTag} в ${selectedRegistry === 'docker.io' ? 'Docker Hub' : 'Harbor'}...`;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
try {
const response = await fetch(`/api/v1/dockerfiles/${dockerfileId}/push`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
image_name: currentImageName,
tag: currentImageTag,
registry: selectedRegistry
})
});
// Проверяем, успешен ли ответ
if (!response.ok) {
// Пытаемся получить JSON с ошибкой
let errorMessage = 'Неизвестная ошибка';
try {
const errorData = await response.json();
// Обрабатываем разные форматы ошибок
if (Array.isArray(errorData)) {
// Если это массив ошибок (например, от Pydantic)
errorMessage = errorData.map(err => {
if (typeof err === 'string') return err;
if (err.msg) return err.msg;
if (err.message) return err.message;
if (err.detail) return err.detail;
return JSON.stringify(err);
}).join(', ');
} else if (errorData.detail) {
// Стандартный формат FastAPI
if (Array.isArray(errorData.detail)) {
errorMessage = errorData.detail.map(err => {
if (typeof err === 'string') return err;
if (err.msg) return err.msg;
if (err.message) return err.message;
return JSON.stringify(err);
}).join(', ');
} else {
errorMessage = errorData.detail;
}
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
} else {
// Пытаемся извлечь любую строку из объекта
errorMessage = JSON.stringify(errorData);
}
} catch (e) {
// Если не удалось распарсить JSON, используем текст ответа
try {
errorMessage = await response.text() || `HTTP ${response.status}: ${response.statusText}`;
} catch (textError) {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
}
throw new Error(errorMessage);
}
const data = await response.json();
if (data.success) {
const successLine = document.createElement('div');
successLine.className = 'log-line log-success';
successLine.textContent = `${data.message}`;
logsContainer.appendChild(successLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
// Подключаемся к WebSocket для получения логов push
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/api/v1/dockerfiles/${dockerfileId}/push/ws`);
ws.onopen = () => {
// Отправляем данные образа при подключении, включая push_log_id если есть
ws.send(JSON.stringify({
image_name: currentImageName,
tag: currentImageTag,
registry: selectedRegistry,
push_log_id: data.push_log_id // Передаем ID лога из POST ответа
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log') {
const logLine = document.createElement('div');
logLine.className = 'log-line';
logLine.textContent = data.data;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
} else if (data.type === 'status') {
if (data.status === 'success') {
const successLine = document.createElement('div');
successLine.className = 'log-line log-success';
successLine.textContent = '✅ Образ успешно отправлен в registry';
logsContainer.appendChild(successLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
ws.close();
} else if (data.status === 'failed') {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = `❌ Ошибка при отправке: ${data.message || 'Неизвестная ошибка'}`;
logsContainer.appendChild(errorLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
ws.close();
}
}
};
ws.onerror = (error) => {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = `❌ Ошибка подключения WebSocket`;
logsContainer.appendChild(errorLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
};
ws.onclose = () => {
if (pushButton) {
pushButton.disabled = false;
pushButton.innerHTML = '<i class="fas fa-upload me-1"></i>Отправить образ';
}
};
} else {
throw new Error(data.detail || data.message || 'Неизвестная ошибка');
}
} catch (error) {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
// Правильно обрабатываем ошибку
let errorMessage = 'Неизвестная ошибка';
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
} else if (error && typeof error === 'object') {
// Пытаемся извлечь сообщение из объекта
if (error.message) {
errorMessage = error.message;
} else if (error.detail) {
errorMessage = error.detail;
} else if (Array.isArray(error)) {
errorMessage = error.map(err => {
if (typeof err === 'string') return err;
if (err.msg) return err.msg;
if (err.message) return err.message;
return JSON.stringify(err);
}).join(', ');
} else {
// Последняя попытка - преобразовать в строку
try {
errorMessage = JSON.stringify(error);
} catch (e) {
errorMessage = String(error);
}
}
} else {
errorMessage = String(error);
}
errorLine.textContent = `❌ Ошибка при запуске отправки: ${errorMessage}`;
logsContainer.appendChild(errorLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
if (pushButton) {
pushButton.disabled = false;
pushButton.innerHTML = '<i class="fas fa-upload me-1"></i>Отправить образ';
}
}
}
async function deleteLog(logId) {
const confirmed = await showConfirmModal('Вы уверены, что хотите удалить этот лог сборки?');
if (!confirmed) {
return;
}
try {
const response = await fetch(`/api/v1/dockerfiles/build-logs/${logId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
// Обновляем историю вместо удаления из DOM
loadRecentLogs();
} else {
const data = await response.json();
alert(`Ошибка при удалении: ${data.detail || 'Неизвестная ошибка'}`);
}
} catch (error) {
alert(`Ошибка при удалении: ${error.message}`);
}
}
</script>
<style>
.log-line {
margin-bottom: 0.25rem;
word-wrap: break-word;
}
.log-error {
color: #f48771;
}
.log-warning {
color: #dcdcaa;
}
.log-info {
color: #4ec9b0;
}
/* Стили для модального окна с логами на весь экран */
#logDetailModal .modal-dialog {
margin: 0 !important;
max-width: 100% !important;
width: 100% !important;
height: 100vh !important;
max-height: 100vh !important;
}
#logDetailModal .modal-content {
height: 100vh !important;
max-height: 100vh !important;
margin: 0 !important;
border: none !important;
border-radius: 0 !important;
display: flex !important;
flex-direction: column !important;
}
#logDetailModal .modal-header {
flex-shrink: 0 !important;
padding: 1rem !important;
height: auto !important;
}
#logDetailModal .modal-body {
flex: 1 1 0 !important;
overflow: hidden !important;
padding: 0 !important;
margin: 0 !important;
width: 100% !important;
min-height: 0 !important;
max-height: none !important;
height: auto !important;
display: flex !important;
flex-direction: column !important;
position: relative !important;
}
#log-detail-content {
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100% !important;
height: 100% !important;
min-height: 0 !important;
max-height: none !important;
box-sizing: border-box !important;
overflow: auto !important;
background: #1e1e1e !important;
color: #d4d4d4 !important;
padding: 1rem !important;
font-family: 'Courier New', monospace !important;
font-size: 0.875rem !important;
white-space: pre !important;
word-wrap: normal !important;
overflow-wrap: normal !important;
}
/* Дополнительные стили для гарантии полной высоты */
#logDetailModal.show .modal-body,
#logDetailModal.showing .modal-body {
height: calc(100vh - 120px) !important;
min-height: calc(100vh - 120px) !important;
max-height: calc(100vh - 120px) !important;
}
#logDetailModal.show #log-detail-content,
#logDetailModal.showing #log-detail-content {
height: calc(100vh - 120px) !important;
min-height: calc(100vh - 120px) !important;
max-height: calc(100vh - 120px) !important;
}
</style>
{% endblock %}