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