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,431 @@
{% extends "base.html" %}
{% block title %}Playbook: {{ playbook.name }} - DevOpsLab{% endblock %}
{% block page_title %}Playbook: {{ playbook.name }}{% endblock %}
{% block header_actions %}
<a href="/playbooks/{{ playbook.id }}/edit" class="btn btn-primary btn-sm me-2">
<i class="fas fa-edit me-2"></i>
Редактировать
</a>
<a href="/playbooks" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
{% endblock %}
{% block content %}
<div x-data="playbookManager({{ playbook.id }})">
<div class="row">
<div class="col-12 col-lg-8">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Информация</h5>
</div>
<div class="card-body">
{% if playbook.description %}
<p class="mb-3">{{ playbook.description }}</p>
{% endif %}
<div class="mb-2">
<strong>Роли:</strong>
{% for role in playbook.roles %}
<span class="badge bg-info me-1">{{ role }}</span>
{% endfor %}
</div>
<div class="mb-2">
<strong>Статус:</strong>
<span class="badge {% if playbook.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ playbook.status }}
</span>
</div>
<div class="mb-2">
<strong>Создан:</strong> {{ playbook.created_at.strftime('%d.%m.%Y %H:%M') if playbook.created_at else 'N/A' }}
</div>
{% if playbook.updated_at %}
<div class="mb-2">
<strong>Обновлен:</strong> {{ playbook.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">Действия</h5>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-12 col-md-6">
<div class="mb-3">
<label class="form-label">Preset для тестирования</label>
<select x-model="testPreset" class="form-select">
<option value="default">default</option>
<option value="minimal">minimal</option>
<option value="all-images">all-images</option>
</select>
</div>
<button
@click="startTest"
:disabled="testRunning || deployRunning"
class="btn btn-success w-100"
>
<i class="fas fa-vial me-2" x-show="!testRunning"></i>
<i class="fas fa-spinner fa-spin me-2" x-show="testRunning"></i>
<span x-show="!testRunning">Запустить тест</span>
<span x-show="testRunning">Тест выполняется...</span>
</button>
</div>
<div class="col-12 col-md-6">
<div class="mb-3">
<label class="form-label">Inventory для деплоя</label>
<input
type="text"
x-model="deployInventory"
class="form-control"
placeholder="inventory/hosts.ini или путь к файлу"
:value="'{{ playbook.inventory or '' }}'"
>
</div>
<button
@click="startDeploy"
:disabled="testRunning || deployRunning"
class="btn btn-warning w-100"
>
<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>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Playbook (YAML)</h5>
</div>
<div class="card-body">
<textarea id="playbook-content" class="form-control" rows="15" readonly>{{ playbook.content }}</textarea>
</div>
</div>
{% if playbook.inventory %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Инвентарь</h5>
<button
@click="editInventory = !editInventory"
class="btn btn-sm btn-outline-primary"
>
<i class="fas fa-edit me-1"></i>
<span x-show="!editInventory">Редактировать</span>
<span x-show="editInventory">Отменить</span>
</button>
</div>
<div class="card-body">
<textarea
id="inventory-content"
class="form-control"
rows="10"
:readonly="!editInventory"
x-text="inventoryContent"
>{{ playbook.inventory }}</textarea>
<div class="mt-2" x-show="editInventory">
<button
@click="saveInventory"
class="btn btn-primary btn-sm"
>
<i class="fas fa-save me-1"></i>
Сохранить
</button>
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-12 col-lg-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Последние тесты</h5>
</div>
<div class="card-body">
{% if test_runs %}
<div class="list-group">
{% for test_run in test_runs %}
<div class="list-group-item">
<div class="d-flex justify-content-between">
<span>
<i class="fas fa-vial me-2"></i>
{{ test_run.preset_name or 'default' }}
</span>
<span class="badge {% if test_run.status == 'success' %}bg-success{% elif test_run.status == 'failed' %}bg-danger{% else %}bg-warning{% endif %}">
{{ test_run.status }}
</span>
</div>
<small class="text-muted">
{{ test_run.started_at.strftime('%d.%m.%Y %H:%M') if test_run.started_at else 'N/A' }}
</small>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0">Тесты еще не запускались</p>
{% endif %}
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Последние деплои</h5>
</div>
<div class="card-body">
{% if deployments %}
<div class="list-group">
{% for deployment in deployments %}
<div class="list-group-item">
<div class="d-flex justify-content-between">
<span>
<i class="fas fa-rocket me-2"></i>
Деплой #{{ deployment.id }}
</span>
<span class="badge {% if deployment.status == 'success' %}bg-success{% elif deployment.status == 'failed' %}bg-danger{% else %}bg-warning{% endif %}">
{{ deployment.status }}
</span>
</div>
<small class="text-muted">
{{ deployment.started_at.strftime('%d.%m.%Y %H:%M') if deployment.started_at else 'N/A' }}
</small>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0">Деплои еще не выполнялись</p>
{% endif %}
</div>
</div>
</div>
<!-- Live логи -->
<div class="card mt-3" x-show="testRunning || deployRunning || logs.length > 0">
<div class="card-header">
<h5 class="mb-0">Логи выполнения</h5>
</div>
<div class="card-body">
<div class="log-container" style="max-height: 500px; overflow-y: auto; font-family: monospace; font-size: 0.875rem; background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 0.25rem;" x-ref="logContainer">
<template x-for="log in logs" :key="log.id">
<div
class="log-line mb-1"
:class="{
'text-danger': log.type === 'error',
'text-warning': log.type === 'warning',
'text-info': log.type === 'info',
'text-success': log.type === 'success'
}"
x-html="log.data"
></div>
</template>
</div>
<div class="mt-3 d-flex gap-2">
<button
@click="clearLogs"
class="btn btn-outline-secondary btn-sm"
>
<i class="fas fa-trash me-2"></i>
Очистить
</button>
<button
@click="downloadLogs"
class="btn btn-outline-secondary btn-sm"
>
<i class="fas fa-download me-2"></i>
Скачать логи
</button>
</div>
</div>
</div>
</div>
<script>
function playbookManager(playbookId) {
return {
playbookId: playbookId,
testRunning: false,
deployRunning: false,
testPreset: 'default',
deployInventory: '{{ playbook.inventory or "" }}',
editInventory: false,
inventoryContent: '{{ playbook.inventory or "" }}',
logs: [],
ws: null,
async startTest() {
this.testRunning = true;
this.logs = [];
try {
const formData = new FormData();
formData.append('preset', this.testPreset);
const response = await fetch(`/api/v1/playbooks/${this.playbookId}/test`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.websocket_url) {
this.connectWebSocket(data.websocket_url, 'test');
} else {
this.addLog('error', 'Не удалось получить WebSocket URL');
this.testRunning = false;
}
} catch (error) {
this.addLog('error', `Ошибка запуска теста: ${error.message}`);
this.testRunning = false;
}
},
async startDeploy() {
if (!this.deployInventory) {
alert('Укажите inventory для деплоя');
return;
}
this.deployRunning = true;
this.logs = [];
try {
const formData = new FormData();
formData.append('inventory', this.deployInventory);
const response = await fetch(`/api/v1/playbooks/${this.playbookId}/deploy`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.websocket_url) {
this.connectWebSocket(data.websocket_url, 'deploy');
} else {
this.addLog('error', 'Не удалось получить WebSocket URL');
this.deployRunning = false;
}
} catch (error) {
this.addLog('error', `Ошибка запуска деплоя: ${error.message}`);
this.deployRunning = false;
}
},
connectWebSocket(url, type) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.ws = new WebSocket(`${protocol}//${window.location.host}${url}`);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
this.addLog(data.level || 'info', data.data);
} else if (data.type === 'complete') {
this.addLog('success', data.data || '✅ Выполнение завершено');
if (type === 'test') {
this.testRunning = false;
} else {
this.deployRunning = false;
}
this.ws.close();
} else if (data.type === 'error') {
this.addLog('error', data.data || '❌ Ошибка');
if (type === 'test') {
this.testRunning = false;
} else {
this.deployRunning = false;
}
this.ws.close();
}
};
this.ws.onerror = (error) => {
this.addLog('error', `Ошибка WebSocket: ${error}`);
if (type === 'test') {
this.testRunning = false;
} else {
this.deployRunning = false;
}
};
this.ws.onclose = () => {
if (type === 'test') {
this.testRunning = false;
} else {
this.deployRunning = false;
}
};
},
addLog(type, message) {
this.logs.push({
id: Date.now() + Math.random(),
type: type,
data: message.replace(/\n/g, '<br>')
});
this.$nextTick(() => {
const container = this.$refs.logContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
},
clearLogs() {
this.logs = [];
},
downloadLogs() {
const content = this.logs.map(l => l.data.replace(/<br>/g, '\n')).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 = `playbook-${this.playbookId}-${Date.now()}.log`;
a.click();
URL.revokeObjectURL(url);
},
async saveInventory() {
const content = document.getElementById('inventory-content').value;
// TODO: Реализовать сохранение через API
this.inventoryContent = content;
this.editInventory = false;
alert('Inventory сохранен (TODO: реализовать API)');
}
}
}
// Инициализация CodeMirror для playbook content
document.addEventListener('DOMContentLoaded', function() {
if (typeof CodeMirror !== 'undefined') {
const playbookEditor = CodeMirror.fromTextArea(
document.getElementById('playbook-content'),
{
mode: 'yaml',
theme: 'monokai',
readOnly: true,
lineNumbers: true
}
);
const inventoryEditor = document.getElementById('inventory-content');
if (inventoryEditor) {
let inventoryCodeMirror = CodeMirror.fromTextArea(inventoryEditor, {
mode: 'yaml',
theme: 'monokai',
readOnly: true,
lineNumbers: true
});
}
}
});
</script>
{% endblock %}