feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
352
app/templates/pages/dockerfiles/all-build-logs.html
Normal file
352
app/templates/pages/dockerfiles/all-build-logs.html
Normal file
@@ -0,0 +1,352 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Логи сборок - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Логи сборок{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/dockerfiles" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Назад к Dockerfile
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="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>
|
||||
<div>
|
||||
<span class="badge bg-info">Всего: {{ total }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if logs %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Dockerfile</th>
|
||||
<th>Образ</th>
|
||||
<th>Тип</th>
|
||||
<th>Платформы</th>
|
||||
<th>Статус</th>
|
||||
<th>Начало</th>
|
||||
<th>Длительность</th>
|
||||
<th>Пользователь</th>
|
||||
<th style="min-width: 120px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
{% if log.dockerfile_id in dockerfiles_map %}
|
||||
<a href="/dockerfiles/{{ log.dockerfile_id }}" class="text-decoration-none">
|
||||
<i class="fas fa-file-code me-1 text-primary"></i>
|
||||
{{ dockerfiles_map[log.dockerfile_id].name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">ID: {{ log.dockerfile_id }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<code>{{ log.image_name }}{% if log.tag %}:{{ log.tag }}{% endif %}</code>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
|
||||
<span class="badge bg-info">
|
||||
<i class="fas fa-upload me-1"></i>Push
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="fas fa-hammer me-1"></i>Build
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
|
||||
{# Для push показываем registry вместо платформ #}
|
||||
{% if log.extra_data.get('registry') %}
|
||||
<span class="badge bg-primary">
|
||||
<i class="fas fa-server me-1"></i>{{ log.extra_data.get('registry') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
{% elif log.platforms %}
|
||||
{% for platform in log.platforms %}
|
||||
<span class="badge bg-secondary me-1">{{ platform }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if log.started_at %}
|
||||
<small>{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if log.duration %}
|
||||
<small>{{ log.duration }} сек</small>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if log.user %}
|
||||
<small>{{ log.user }}</small>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
onclick="showLogDetail({{ log.id }})"
|
||||
title="Просмотр логов"
|
||||
>
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger"
|
||||
onclick="deleteLog({{ log.id }})"
|
||||
title="Удалить лог"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Пагинация -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="card-footer">
|
||||
<nav aria-label="Навигация по страницам">
|
||||
<ul class="pagination mb-0 justify-content-center">
|
||||
{% if page > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page - 1 }}&per_page={{ per_page }}">Предыдущая</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ p }}</span>
|
||||
</li>
|
||||
{% elif p <= 3 or p > total_pages - 3 or (p >= page - 1 and p <= page + 1) %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ p }}&per_page={{ per_page }}">{{ p }}</a>
|
||||
</li>
|
||||
{% elif p == 4 or p == total_pages - 3 %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page + 1 }}&per_page={{ per_page }}">Следующая</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted mb-3">Логи сборок пока нет</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
const logElement = document.querySelector(`[onclick*="deleteLog(${logId})"]`).closest('tr');
|
||||
if (logElement) {
|
||||
logElement.remove();
|
||||
}
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(`Ошибка при удалении: ${data.detail || 'Неизвестная ошибка'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Ошибка при удалении: ${error.message}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Стили для модального окна с логами на весь экран */
|
||||
#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;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
243
app/templates/pages/dockerfiles/build-logs.html
Normal file
243
app/templates/pages/dockerfiles/build-logs.html
Normal file
@@ -0,0 +1,243 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}История сборок - {{ dockerfile.name }} - DevOpsLab{% endblock %}
|
||||
{% block page_title %}История сборок: {{ dockerfile.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="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>
|
||||
История сборок Dockerfile
|
||||
</h5>
|
||||
<div>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="clearLogs()">
|
||||
<i class="fas fa-trash me-2"></i>
|
||||
Очистить логи
|
||||
</button>
|
||||
<a href="/dockerfiles/{{ dockerfile.id }}/edit" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Назад
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if logs %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Образ</th>
|
||||
<th>Тип</th>
|
||||
<th>Платформы</th>
|
||||
<th>Статус</th>
|
||||
<th>Начало</th>
|
||||
<th>Длительность</th>
|
||||
<th>Пользователь</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.id }}</td>
|
||||
<td>
|
||||
<code>{{ log.image_name }}{% if log.tag %}:{{ log.tag }}{% endif %}</code>
|
||||
</td>
|
||||
<td>
|
||||
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
|
||||
<span class="badge bg-info">
|
||||
<i class="fas fa-upload me-1"></i>Push
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="fas fa-hammer me-1"></i>Build
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
|
||||
{# Для push показываем registry вместо платформ #}
|
||||
{% if log.extra_data.get('registry') %}
|
||||
<span class="badge bg-primary">
|
||||
<i class="fas fa-server me-1"></i>{{ log.extra_data.get('registry') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
{% elif log.platforms %}
|
||||
{% for platform in log.platforms %}
|
||||
<span class="badge bg-info me-1">{{ platform }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td>
|
||||
{% if log.started_at %}
|
||||
{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if log.duration %}
|
||||
{{ log.duration }} сек
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ log.user or '-' }}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="showLogDetail({{ log.id }})">
|
||||
<i class="fas fa-eye me-1"></i>
|
||||
Просмотр
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="deleteLog({{ log.id }})">
|
||||
<i class="fas fa-trash me-1"></i>
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Пагинация -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav aria-label="Навигация по страницам">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page - 1 }}">Предыдущая</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ p }}</span>
|
||||
</li>
|
||||
{% elif p <= 3 or p >= total_pages - 2 or (p >= page - 1 and p <= page + 1) %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ p }}">{{ p }}</a>
|
||||
</li>
|
||||
{% elif p == 4 or p == total_pages - 3 %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page + 1 }}">Следующая</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Логи сборки отсутствуют
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для просмотра лога -->
|
||||
<div class="modal fade" id="logDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<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" style="height: 500px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 1rem; font-family: 'Courier New', monospace; font-size: 0.875rem; white-space: pre-wrap;">
|
||||
Загрузка...
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function showLogDetail(logId) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('logDetailModal'));
|
||||
const content = document.getElementById('log-detail-content');
|
||||
content.textContent = 'Загрузка...';
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLog(logId) {
|
||||
const confirmed = await showConfirmModal('Вы уверены, что хотите удалить этот лог?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/dockerfiles/build-logs/${logId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка при удалении лога');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Ошибка: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLogs() {
|
||||
const confirmed = await showConfirmModal('Вы уверены, что хотите очистить все логи сборки для этого Dockerfile?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/dockerfiles/{{ dockerfile.id }}/build-logs`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка при очистке логов');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Ошибка: ${error.message}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
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 %}
|
||||
163
app/templates/pages/dockerfiles/create.html
Normal file
163
app/templates/pages/dockerfiles/create.html
Normal file
@@ -0,0 +1,163 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Создать Dockerfile - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Создать Dockerfile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form
|
||||
hx-post="/api/v1/dockerfiles"
|
||||
hx-swap="none"
|
||||
id="dockerfile-form"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
class="form-control"
|
||||
placeholder="ubuntu22"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
title="Только строчные буквы, цифры и дефисы"
|
||||
>
|
||||
<div class="form-text">
|
||||
Только строчные буквы, цифры и дефисы
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Базовый образ</label>
|
||||
<input
|
||||
type="text"
|
||||
name="base_image"
|
||||
class="form-control"
|
||||
placeholder="ubuntu:22.04"
|
||||
>
|
||||
<div class="form-text">
|
||||
Базовый образ (например, ubuntu:22.04)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Теги</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tags"
|
||||
class="form-control"
|
||||
placeholder="latest, v1.0, stable"
|
||||
>
|
||||
<div class="form-text">
|
||||
Теги образа через запятую (например: latest, v1.0, stable)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Описание</label>
|
||||
<textarea
|
||||
name="description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="Описание Dockerfile..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Dockerfile</label>
|
||||
<textarea
|
||||
id="dockerfile-content-editor"
|
||||
class="form-control font-monospace"
|
||||
rows="35"
|
||||
placeholder="# Dockerfile
|
||||
FROM ubuntu:22.04
|
||||
|
||||
# Установка пакетов
|
||||
RUN apt-get update && apt-get install -y ..."
|
||||
></textarea>
|
||||
<input type="hidden" name="content" id="dockerfile-content-hidden">
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Содержимое Dockerfile. Подсветка синтаксиса включена.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Создать Dockerfile
|
||||
</button>
|
||||
<a href="/dockerfiles" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-2"></i>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Инициализация редактора Dockerfile
|
||||
if (typeof CodeEditor !== 'undefined') {
|
||||
const dockerfileEditor = CodeEditor.init('dockerfile-content-editor', 'dockerfile', {
|
||||
theme: 'monokai',
|
||||
lineNumbers: true,
|
||||
foldGutter: true
|
||||
});
|
||||
|
||||
if (dockerfileEditor) {
|
||||
dockerfileEditor.on('change', function() {
|
||||
const content = dockerfileEditor.getValue();
|
||||
// Сохраняем в hidden поле для отправки формы
|
||||
document.getElementById('dockerfile-content-hidden').value = content;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка формы
|
||||
const form = document.getElementById('dockerfile-form');
|
||||
|
||||
form.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.xhr.status === 200) {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
// Показываем модальное окно с успешным сообщением
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(
|
||||
response.message || 'Dockerfile создан успешно',
|
||||
'success',
|
||||
'Успешно',
|
||||
function() {
|
||||
// После закрытия модального окна перенаправляем на страницу деталей
|
||||
window.location.href = `/dockerfiles/${response.id}`;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Если функция недоступна, просто перенаправляем
|
||||
window.location.href = `/dockerfiles/${response.id}`;
|
||||
}
|
||||
} else {
|
||||
// Ошибка - показываем в модальном окне
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
const errorMessage = response.detail || response.message || 'Ошибка при создании Dockerfile';
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(errorMessage, 'error');
|
||||
} else {
|
||||
alert(errorMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при создании Dockerfile', 'error');
|
||||
} else {
|
||||
alert('Ошибка при создании Dockerfile');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
186
app/templates/pages/dockerfiles/detail.html
Normal file
186
app/templates/pages/dockerfiles/detail.html
Normal file
@@ -0,0 +1,186 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dockerfile: {{ dockerfile.name }} - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Dockerfile: {{ dockerfile.name }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="/dockerfiles/{{ dockerfile.id }}/edit" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-edit me-2"></i>
|
||||
Редактировать
|
||||
</a>
|
||||
<a href="/dockerfiles" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Назад
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Информация</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if dockerfile.description %}
|
||||
<p class="mb-3">{{ dockerfile.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if dockerfile.base_image %}
|
||||
<div class="mb-2">
|
||||
<strong>Базовый образ:</strong> {{ dockerfile.base_image }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if dockerfile.tags %}
|
||||
<div class="mb-2">
|
||||
<strong>Теги:</strong>
|
||||
{% for tag in dockerfile.tags %}
|
||||
<span class="badge bg-info me-1">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-2">
|
||||
<strong>Статус:</strong>
|
||||
<span class="badge {% if dockerfile.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{{ dockerfile.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<strong>Создан:</strong> {{ dockerfile.created_at.strftime('%d.%m.%Y %H:%M') if dockerfile.created_at else 'N/A' }}
|
||||
</div>
|
||||
{% if dockerfile.updated_at %}
|
||||
<div class="mb-2">
|
||||
<strong>Обновлен:</strong> {{ dockerfile.updated_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Dockerfile</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="dockerfile-code-wrapper">
|
||||
<pre class="bg-light p-3 rounded mb-0"><code>{{ dockerfile.content }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">История сборок</h5>
|
||||
<a href="/dockerfiles/{{ dockerfile.id }}/build-logs" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-list me-1"></i>
|
||||
Все логи
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if build_logs %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Образ</th>
|
||||
<th>Тип</th>
|
||||
<th>Платформы</th>
|
||||
<th>Статус</th>
|
||||
<th>Начало</th>
|
||||
<th>Длительность</th>
|
||||
<th>Пользователь</th>
|
||||
<th style="min-width: 100px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in build_logs %}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
<code>{{ log.image_name }}{% if log.tag %}:{{ log.tag }}{% endif %}</code>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
|
||||
<span class="badge bg-info">
|
||||
<i class="fas fa-upload me-1"></i>Push
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="fas fa-hammer me-1"></i>Build
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
|
||||
{# Для push показываем registry вместо платформ #}
|
||||
{% if log.extra_data.get('registry') %}
|
||||
<span class="badge bg-primary">
|
||||
<i class="fas fa-server me-1"></i>{{ log.extra_data.get('registry') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
{% elif log.platforms %}
|
||||
{% for platform in log.platforms %}
|
||||
<span class="badge bg-secondary me-1">{{ platform }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if log.started_at %}
|
||||
{{ log.started_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if log.duration %}
|
||||
{{ log.duration }} сек
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if log.user %}
|
||||
{{ log.user }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="/dockerfiles/{{ dockerfile.id }}/build-logs?log_id={{ log.id }}" class="btn btn-outline-primary" title="Просмотр логов">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mb-0 m-3">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
История сборок пуста
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
122
app/templates/pages/dockerfiles/edit.html
Normal file
122
app/templates/pages/dockerfiles/edit.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Редактировать Dockerfile - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Редактировать Dockerfile: {{ dockerfile.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form
|
||||
hx-put="/api/v1/dockerfiles/{{ dockerfile.id }}"
|
||||
hx-swap="none"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
class="form-control"
|
||||
value="{{ dockerfile.name }}"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Базовый образ</label>
|
||||
<input
|
||||
type="text"
|
||||
name="base_image"
|
||||
class="form-control"
|
||||
value="{{ dockerfile.base_image or '' }}"
|
||||
placeholder="ubuntu:22.04"
|
||||
>
|
||||
<div class="form-text">
|
||||
Базовый образ (например, ubuntu:22.04)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Теги</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tags"
|
||||
class="form-control"
|
||||
value="{% if dockerfile.tags %}{{ dockerfile.tags | join(', ') }}{% endif %}"
|
||||
placeholder="latest, v1.0, stable"
|
||||
>
|
||||
<div class="form-text">
|
||||
Теги образа через запятую (например: latest, v1.0, stable)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Описание</label>
|
||||
<textarea
|
||||
name="description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
>{{ dockerfile.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Dockerfile</label>
|
||||
<textarea
|
||||
id="dockerfile-content-editor"
|
||||
class="form-control font-monospace"
|
||||
rows="35"
|
||||
>{{ dockerfile.content }}</textarea>
|
||||
<input type="hidden" name="content" id="dockerfile-content-hidden">
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Редактируйте содержимое Dockerfile. Подсветка синтаксиса включена.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
<a href="/dockerfiles/{{ dockerfile.id }}/build" class="btn btn-success">
|
||||
<i class="fas fa-hammer me-2"></i>
|
||||
Сборка
|
||||
</a>
|
||||
<a href="/dockerfiles/{{ dockerfile.id }}/build-logs" class="btn btn-outline-primary">
|
||||
<i class="fas fa-history me-2"></i>
|
||||
История сборок
|
||||
</a>
|
||||
<a href="/dockerfiles/{{ dockerfile.id }}" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-2"></i>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Инициализация редактора Dockerfile
|
||||
if (typeof CodeEditor !== 'undefined') {
|
||||
const dockerfileEditor = CodeEditor.init('dockerfile-content-editor', 'dockerfile', {
|
||||
theme: 'monokai',
|
||||
lineNumbers: true,
|
||||
foldGutter: true
|
||||
});
|
||||
|
||||
if (dockerfileEditor) {
|
||||
dockerfileEditor.on('change', function() {
|
||||
const content = dockerfileEditor.getValue();
|
||||
// Сохраняем в hidden поле для отправки формы
|
||||
document.getElementById('dockerfile-content-hidden').value = content;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
122
app/templates/pages/dockerfiles/list.html
Normal file
122
app/templates/pages/dockerfiles/list.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dockerfile - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Dockerfile{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/dockerfiles/create" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-plus me-2"></i>
|
||||
Создать Dockerfile
|
||||
</a>
|
||||
<a href="/dockerfiles/build-logs" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-history me-2"></i>
|
||||
Логи сборок
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Список Dockerfile</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if dockerfiles %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Описание</th>
|
||||
<th>Базовый образ</th>
|
||||
<th>Теги</th>
|
||||
<th>Статус</th>
|
||||
<th style="min-width: 140px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dockerfile in dockerfiles %}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
<i class="fas fa-file-code me-2 text-primary"></i>
|
||||
<a href="/dockerfiles/{{ dockerfile.id }}" class="text-decoration-none fw-semibold">
|
||||
{{ dockerfile.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if dockerfile.description %}
|
||||
<span class="text-truncate d-inline-block" style="max-width: 300px;" title="{{ dockerfile.description }}">
|
||||
{{ dockerfile.description }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if dockerfile.base_image %}
|
||||
<span class="badge bg-info">{{ dockerfile.base_image }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if dockerfile.tags %}
|
||||
{% for tag in dockerfile.tags %}
|
||||
<span class="badge bg-secondary me-1">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<span class="badge {% if dockerfile.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{{ dockerfile.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a
|
||||
href="/dockerfiles/{{ dockerfile.id }}/edit"
|
||||
class="btn btn-outline-primary"
|
||||
title="Редактировать"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<a
|
||||
href="/dockerfiles/{{ dockerfile.id }}"
|
||||
class="btn btn-outline-info"
|
||||
title="Детали"
|
||||
>
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</a>
|
||||
<button
|
||||
hx-delete="/api/v1/dockerfiles/{{ dockerfile.id }}"
|
||||
hx-confirm="Удалить Dockerfile '{{ dockerfile.name }}'?"
|
||||
hx-target="closest tr"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-outline-danger"
|
||||
title="Удалить"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted mb-3">Dockerfile'ов пока нет</p>
|
||||
<a href="/dockerfiles/create" class="btn btn-primary">
|
||||
<i class="fas fa-plus me-2"></i>
|
||||
Создать первый Dockerfile
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user