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

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

View File

@@ -0,0 +1,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 %}

View 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 %}

View File

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

View 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 %}

View 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 %}

View 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 %}

View 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 %}