feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
993
app/templates/pages/dockerfiles/build.html
Normal file
993
app/templates/pages/dockerfiles/build.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user