- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
432 lines
18 KiB
HTML
432 lines
18 KiB
HTML
{% 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 %}
|