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,273 @@
{% extends "base.html" %}
{% block title %}Деплой - DevOpsLab{% endblock %}
{% block page_title %}Деплой на живые серверы{% endblock %}
{% block header_actions %}
<a href="/deploy/inventory" class="btn btn-secondary btn-sm">
<i class="fas fa-list me-2"></i>
Управление Inventory
</a>
{% endblock %}
{% block content %}
<div x-data="deployManager()">
<!-- Проверка inventory -->
{% if not inventory_exists %}
<div class="alert alert-warning mb-3">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Внимание:</strong> Файл inventory/hosts.ini не найден.
<a href="/deploy/inventory" class="alert-link">Создайте его</a> перед запуском деплоя.
</div>
{% endif %}
<!-- Настройки деплоя -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Настройки деплоя</h5>
</div>
<div class="card-body">
<form @submit.prevent="startDeploy">
<div class="mb-3">
<label class="form-label">Роль для деплоя</label>
<select
x-model="deployConfig.role"
class="form-select"
>
<option value="all">Все роли</option>
{% for role in roles %}
<option value="{{ role }}">{{ role }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Теги (через запятую, опционально)</label>
<input
type="text"
x-model="deployConfig.tags"
class="form-control"
placeholder="web, database, nginx"
>
</div>
<div class="mb-3">
<label class="form-label">Лимит хостов (опционально)</label>
<input
type="text"
x-model="deployConfig.limit"
class="form-control"
placeholder="webservers или web1,web2"
>
<div class="form-text">
Ограничить выполнение определенными хостами или группами
</div>
</div>
<div class="mb-3">
<label class="form-label">Опции</label>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
x-model="deployConfig.check"
id="deploy-check"
>
<label class="form-check-label" for="deploy-check">
Dry-run (проверка без изменений)
</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
x-model="deployConfig.verbose"
id="deploy-verbose"
>
<label class="form-check-label" for="deploy-verbose">
Verbose режим (-vvv)
</label>
</div>
</div>
<button
type="submit"
class="btn btn-success"
:disabled="deployRunning || !inventoryExists"
>
<i class="fas fa-rocket me-2" x-show="!deployRunning"></i>
<i class="fas fa-spinner fa-spin me-2" x-show="deployRunning"></i>
<span x-show="!deployRunning">Запустить деплой</span>
<span x-show="deployRunning">Деплой выполняется...</span>
</button>
</form>
</div>
</div>
<!-- Информация о inventory -->
{% if inventory_exists and inventory_data %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Информация о Inventory</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<h6 class="fw-semibold mb-2">Группы</h6>
<ul class="list-unstyled">
{% for group, hosts in inventory_data.groups.items() %}
<li class="mb-1">
<i class="fas fa-server me-2 text-muted"></i>
<strong>{{ group }}</strong>: {{ hosts|length }} хост(ов)
</li>
{% endfor %}
</ul>
</div>
<div class="col-12 col-md-6">
<h6 class="fw-semibold mb-2">Всего хостов</h6>
<p class="display-6 fw-bold">{{ inventory_data.hosts|length }}</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Live логи -->
<div class="card" x-show="deployRunning || logs.length > 0">
<div class="card-header">
<h5 class="mb-0">Логи деплоя</h5>
</div>
<div class="card-body">
<div class="log-container" id="deploy-logs" x-ref="logContainer" style="max-height: 500px; overflow-y: auto;">
<template x-for="log in logs" :key="log.id">
<div
class="log-line"
:class="{
'log-error': log.type === 'error',
'log-warning': log.type === 'warning',
'log-info': log.type === 'info'
}"
x-text="log.data"
></div>
</template>
</div>
<div class="mt-3 d-flex gap-2">
<button
@click="clearLogs"
class="btn btn-outline-secondary"
>
<i class="fas fa-trash me-2"></i>
Очистить
</button>
<button
@click="downloadLogs"
class="btn btn-outline-secondary"
>
<i class="fas fa-download me-2"></i>
Скачать логи
</button>
</div>
</div>
</div>
</div>
<script>
function deployManager() {
return {
deployRunning: false,
logs: [],
inventoryExists: {{ 'true' if inventory_exists else 'false' }},
deployConfig: {
role: 'all',
tags: '',
limit: '',
check: false,
verbose: false
},
ws: null,
async startDeploy() {
if (!this.inventoryExists) {
alert('Сначала создайте inventory файл!');
return;
}
this.deployRunning = true;
this.logs = [];
// Формирование deploy_id
const deployId = `deploy-${this.deployConfig.role}-${this.deployConfig.tags || 'none'}`;
// Создание WebSocket подключения
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws/deploy/${deployId}`);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
this.logs.push({
id: Date.now() + Math.random(),
type: data.level || this.detectLogLevel(data.data),
data: data.data
});
// Автоскролл
this.$nextTick(() => {
const container = this.$refs.logContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
} else if (data.type === 'complete') {
this.deployRunning = false;
this.logs.push({
id: Date.now(),
type: 'info',
data: data.data || `✅ Деплой завершен: ${data.status}`
});
this.ws.close();
} else if (data.type === 'error') {
this.deployRunning = false;
this.logs.push({
id: Date.now(),
type: 'error',
data: data.data || `❌ Ошибка`
});
this.ws.close();
}
};
this.ws.onerror = (error) => {
this.deployRunning = false;
this.logs.push({
id: Date.now(),
type: 'error',
data: `❌ Ошибка подключения: ${error}`
});
};
},
detectLogLevel(line) {
const lower = line.toLowerCase();
if (lower.includes('error') || lower.includes('failed') || lower.includes('fatal')) return 'error';
if (lower.includes('warning') || lower.includes('warn')) return 'warning';
if (lower.includes('changed') || lower.includes('ok')) return 'info';
return 'debug';
},
clearLogs() {
this.logs = [];
},
downloadLogs() {
const content = this.logs.map(l => l.data).join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `deploy-${Date.now()}.log`;
a.click();
URL.revokeObjectURL(url);
}
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}Inventory - DevOpsLab{% endblock %}
{% block page_title %}Управление Inventory{% endblock %}
{% block header_actions %}
<a href="/deploy" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад к деплою
</a>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<!-- Информация -->
<div class="alert alert-info mb-3">
<i class="fas fa-info-circle me-2"></i>
<strong>Информация:</strong> Inventory файл определяет серверы для деплоя.
Используйте стандартный формат Ansible inventory (INI или YAML).
</div>
<!-- Редактор inventory -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Редактор inventory/hosts.ini</h5>
</div>
<div class="card-body">
<form
id="inventory-form"
hx-post="/api/v1/deploy/inventory"
hx-target="#result"
hx-swap="innerHTML"
>
<div class="mb-3">
<label class="form-label">Содержимое inventory</label>
<textarea
id="inventory-editor"
name="content"
class="form-control"
rows="20"
placeholder="[webservers]
web1 ansible_host=192.168.1.10 ansible_user=root
web2 ansible_host=192.168.1.11 ansible_user=root
[database]
db1 ansible_host=192.168.1.20 ansible_user=root
[all:vars]
ansible_python_interpreter=/usr/bin/python3"
>{{ inventory_content }}</textarea>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
Поддерживается формат INI и YAML. Редактор автоматически определит формат.
</div>
</div>
<!-- Результат -->
<div id="result" class="mb-3"></div>
<!-- Кнопки -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить Inventory
</button>
<button type="button" class="btn btn-outline-secondary" onclick="validateInventory()">
<i class="fas fa-check me-2"></i>
Проверить синтаксис
</button>
<a href="/deploy" class="btn btn-secondary">
Отмена
</a>
</div>
</form>
</div>
</div>
<!-- Примеры -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Примеры Inventory</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<h6 class="fw-semibold mb-2">Простой пример (INI)</h6>
<pre class="bg-dark text-light p-3 rounded"><code>[webservers]
web1 ansible_host=192.168.1.10
web2 ansible_host=192.168.1.11
[database]
db1 ansible_host=192.168.1.20
[all:vars]
ansible_user=root
ansible_python_interpreter=/usr/bin/python3</code></pre>
</div>
<div class="col-12 col-md-6">
<h6 class="fw-semibold mb-2">С SSH ключами (INI)</h6>
<pre class="bg-dark text-light p-3 rounded"><code>[webservers]
web1 ansible_host=192.168.1.10 ansible_ssh_private_key_file=~/.ssh/id_rsa
web2 ansible_host=192.168.1.11 ansible_ssh_private_key_file=~/.ssh/id_rsa
[database]
db1 ansible_host=192.168.1.20 ansible_ssh_private_key_file=~/.ssh/id_rsa
[all:vars]
ansible_user=ubuntu
ansible_python_interpreter=/usr/bin/python3</code></pre>
</div>
<div class="col-12">
<h6 class="fw-semibold mb-2">YAML формат</h6>
<pre class="bg-dark text-light p-3 rounded"><code>all:
children:
webservers:
hosts:
web1:
ansible_host: 192.168.1.10
web2:
ansible_host: 192.168.1.11
database:
hosts:
db1:
ansible_host: 192.168.1.20
vars:
ansible_user: root
ansible_python_interpreter: /usr/bin/python3</code></pre>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof CodeMirror !== 'undefined') {
const inventoryEditor = CodeMirror.fromTextArea(
document.getElementById('inventory-editor'),
{
mode: 'yaml', // Начинаем с YAML, но можно переключить на INI
theme: 'monokai',
lineNumbers: true,
indentUnit: 2,
lineWrapping: true,
autofocus: true
}
);
// Определяем формат по содержимому
const content = inventoryEditor.getValue();
if (content.trim().startsWith('[') || content.includes('ansible_host=')) {
// Это INI формат
inventoryEditor.setOption('mode', 'ini');
}
// Обновляем textarea перед отправкой формы
document.getElementById('inventory-form').addEventListener('submit', function() {
inventoryEditor.save();
});
// Сохраняем editor в глобальной переменной для доступа из других функций
window.inventoryEditor = inventoryEditor;
}
});
function validateInventory() {
const editor = window.inventoryEditor;
if (!editor) {
alert('Редактор не инициализирован');
return;
}
const content = editor.getValue();
// Простая валидация
if (!content.trim()) {
alert('Inventory пуст');
return;
}
// Проверяем наличие групп
if (content.includes('[') && !content.match(/\[.*\]/)) {
alert('⚠️ Предупреждение: Не найдено групп в формате [group_name]');
return;
}
alert('✅ Синтаксис inventory корректен');
}
</script>
{% endblock %}