Files
DevOpsLab/app/templates/pages/playbooks/detail.html
Сергей Антропов 1fbf9185a2 feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок
- Для push операций отображается registry вместо платформ
- Сохранение пользователя при создании push лога
- Исправлена ошибка с logger в push_docker_image endpoint
- Улучшено отображение истории сборок с визуальными индикаторами
2026-02-15 22:59:02 +03:00

432 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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