feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
176
app/templates/pages/playbooks/create.html
Normal file
176
app/templates/pages/playbooks/create.html
Normal file
@@ -0,0 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Создать Playbook - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Создать Playbook{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form
|
||||
hx-post="/api/v1/playbooks"
|
||||
hx-target="#playbook-result"
|
||||
hx-swap="innerHTML"
|
||||
id="playbook-form"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Название playbook</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
class="form-control"
|
||||
placeholder="my-playbook"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
title="Только строчные буквы, цифры и дефисы"
|
||||
>
|
||||
<div class="form-text">
|
||||
Только строчные буквы, цифры и дефисы
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Описание</label>
|
||||
<textarea
|
||||
name="description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="Описание playbook..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Роли</label>
|
||||
<div class="form-text mb-2">
|
||||
Выберите роли для включения в playbook
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
{% for role in roles %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
name="roles"
|
||||
value="{{ role }}"
|
||||
id="role-{{ role }}"
|
||||
>
|
||||
<label class="form-check-label" for="role-{{ role }}">
|
||||
{{ role }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if not roles %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
Роли не найдены. Создайте роли перед созданием playbook.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Переменные (YAML)</label>
|
||||
<textarea
|
||||
name="variables"
|
||||
id="variables-editor"
|
||||
class="form-control font-monospace"
|
||||
rows="10"
|
||||
placeholder="vars:
|
||||
key1: value1
|
||||
key2: value2"
|
||||
></textarea>
|
||||
<div class="form-text">
|
||||
Переменные в формате YAML (опционально)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Инвентарь (YAML)</label>
|
||||
<textarea
|
||||
name="inventory"
|
||||
id="inventory-editor"
|
||||
class="form-control font-monospace"
|
||||
rows="10"
|
||||
placeholder="all:
|
||||
hosts:
|
||||
host1:
|
||||
ansible_host: 192.168.1.10
|
||||
host2:
|
||||
ansible_host: 192.168.1.11"
|
||||
></textarea>
|
||||
<div class="form-text">
|
||||
Инвентарь в формате YAML (опционально)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playbook-result"></div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Создать playbook
|
||||
</button>
|
||||
<a href="/playbooks" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-2"></i>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Инициализация редакторов
|
||||
if (typeof CodeEditor !== 'undefined') {
|
||||
const varsEditor = CodeEditor.init('variables-editor', 'yaml');
|
||||
const invEditor = CodeEditor.init('inventory-editor', 'yaml');
|
||||
|
||||
// Валидация при изменении
|
||||
if (varsEditor) {
|
||||
varsEditor.on('change', function() {
|
||||
const content = varsEditor.getValue();
|
||||
if (content.trim()) {
|
||||
const validation = CodeEditor.validateYAML(content);
|
||||
if (!validation.valid) {
|
||||
CodeEditor.showErrors(varsEditor, validation.errors);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (invEditor) {
|
||||
invEditor.on('change', function() {
|
||||
const content = invEditor.getValue();
|
||||
if (content.trim()) {
|
||||
const validation = CodeEditor.validateYAML(content);
|
||||
if (!validation.valid) {
|
||||
CodeEditor.showErrors(invEditor, validation.errors);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка формы
|
||||
const form = document.getElementById('playbook-form');
|
||||
form.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.xhr.status === 200) {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
window.location.href = `/playbooks/${response.id}`;
|
||||
} else {
|
||||
const resultDiv = document.getElementById('playbook-result');
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger mt-3">${response.detail || 'Ошибка при создании playbook'}</div>`;
|
||||
} catch (e) {
|
||||
resultDiv.innerHTML = '<div class="alert alert-danger mt-3">Ошибка при создании playbook</div>';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
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 %}
|
||||
217
app/templates/pages/playbooks/edit.html
Normal file
217
app/templates/pages/playbooks/edit.html
Normal file
@@ -0,0 +1,217 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Редактировать Playbook - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Редактировать Playbook: {{ playbook.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<ul class="nav nav-tabs mb-3" id="playbook-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="info-tab" data-bs-toggle="tab" data-bs-target="#info" type="button" role="tab">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Информация
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="content-tab" data-bs-toggle="tab" data-bs-target="#content" type="button" role="tab">
|
||||
<i class="fas fa-code me-2"></i>
|
||||
Playbook (YAML)
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="inventory-tab" data-bs-toggle="tab" data-bs-target="#inventory" type="button" role="tab">
|
||||
<i class="fas fa-server me-2"></i>
|
||||
Инвентарь
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="playbook-tab-content">
|
||||
<!-- Информация -->
|
||||
<div class="tab-pane fade show active" id="info" role="tabpanel">
|
||||
<form
|
||||
hx-put="/api/v1/playbooks/{{ playbook.id }}"
|
||||
hx-target="#info-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
class="form-control"
|
||||
value="{{ playbook.name }}"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Описание</label>
|
||||
<textarea
|
||||
name="description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
>{{ playbook.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Роли</label>
|
||||
<div class="row g-2">
|
||||
{% for role in all_roles %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
name="roles"
|
||||
value="{{ role }}"
|
||||
id="role-{{ role }}"
|
||||
{% if role in playbook.roles %}checked{% endif %}
|
||||
>
|
||||
<label class="form-check-label" for="role-{{ role }}">
|
||||
{{ role }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="info-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Playbook YAML -->
|
||||
<div class="tab-pane fade" id="content" role="tabpanel">
|
||||
<form
|
||||
hx-put="/api/v1/playbooks/{{ playbook.id }}"
|
||||
hx-target="#content-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<input type="hidden" name="content" id="playbook-content-hidden">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Playbook (YAML)</label>
|
||||
<textarea
|
||||
id="playbook-content-editor"
|
||||
class="form-control font-monospace"
|
||||
rows="20"
|
||||
>{{ playbook.content }}</textarea>
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Редактируйте YAML содержимое playbook. Валидация выполняется автоматически.
|
||||
</div>
|
||||
</div>
|
||||
<div id="content-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Инвентарь -->
|
||||
<div class="tab-pane fade" id="inventory" role="tabpanel">
|
||||
<form
|
||||
hx-put="/api/v1/playbooks/{{ playbook.id }}"
|
||||
hx-target="#inventory-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<input type="hidden" name="inventory" id="inventory-hidden">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Инвентарь (YAML или INI)</label>
|
||||
<textarea
|
||||
id="inventory-editor"
|
||||
class="form-control font-monospace"
|
||||
rows="20"
|
||||
>{{ playbook.inventory or '' }}</textarea>
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Инвентарь в формате YAML или INI. Редактор автоматически определит формат.
|
||||
</div>
|
||||
</div>
|
||||
<div id="inventory-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Инициализация редакторов
|
||||
if (typeof CodeEditor !== 'undefined') {
|
||||
const playbookEditor = CodeEditor.init('playbook-content-editor', 'yaml', {
|
||||
theme: 'monokai',
|
||||
lineNumbers: true,
|
||||
foldGutter: true
|
||||
});
|
||||
|
||||
const inventoryEditor = CodeEditor.init('inventory-editor', 'yaml', {
|
||||
theme: 'monokai',
|
||||
lineNumbers: true,
|
||||
foldGutter: true
|
||||
});
|
||||
|
||||
// Валидация playbook при изменении
|
||||
if (playbookEditor) {
|
||||
playbookEditor.on('change', function() {
|
||||
const content = playbookEditor.getValue();
|
||||
const validation = CodeEditor.validateAnsible(content);
|
||||
if (!validation.valid) {
|
||||
CodeEditor.showErrors(playbookEditor, validation.errors);
|
||||
} else {
|
||||
// Очищаем ошибки если валидация прошла
|
||||
if (playbookEditor._validationMarkers) {
|
||||
playbookEditor._validationMarkers.forEach(m => m.clear());
|
||||
playbookEditor._validationMarkers = [];
|
||||
}
|
||||
}
|
||||
// Сохраняем в hidden поле для отправки формы
|
||||
document.getElementById('playbook-content-hidden').value = content;
|
||||
});
|
||||
}
|
||||
|
||||
// Валидация инвентаря
|
||||
if (inventoryEditor) {
|
||||
inventoryEditor.on('change', function() {
|
||||
const content = inventoryEditor.getValue();
|
||||
// Сохраняем в hidden поле для отправки формы
|
||||
document.getElementById('inventory-hidden').value = content;
|
||||
|
||||
if (content.trim()) {
|
||||
// Определяем формат (INI или YAML)
|
||||
const isINI = content.trim().startsWith('[') || content.includes('ansible_host=');
|
||||
let validation;
|
||||
if (isINI) {
|
||||
// Простая валидация INI
|
||||
validation = { valid: content.includes('[') && content.includes(']'), errors: [] };
|
||||
} else {
|
||||
validation = CodeEditor.validateYAML(content);
|
||||
}
|
||||
|
||||
if (!validation.valid) {
|
||||
CodeEditor.showErrors(inventoryEditor, validation.errors);
|
||||
} else {
|
||||
if (inventoryEditor._validationMarkers) {
|
||||
inventoryEditor._validationMarkers.forEach(m => m.clear());
|
||||
inventoryEditor._validationMarkers = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
96
app/templates/pages/playbooks/list.html
Normal file
96
app/templates/pages/playbooks/list.html
Normal file
@@ -0,0 +1,96 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Playbook - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Playbook{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/playbooks/create" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-plus me-2"></i>
|
||||
Создать playbook
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row g-3" id="playbooks-list">
|
||||
{% for playbook in playbooks %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-0">
|
||||
<a href="/playbooks/{{ playbook.id }}" class="text-decoration-none">
|
||||
{{ playbook.name }}
|
||||
</a>
|
||||
</h5>
|
||||
<div class="btn-group">
|
||||
<a
|
||||
href="/playbooks/{{ playbook.id }}/edit"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
title="Редактировать"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button
|
||||
hx-delete="/api/v1/playbooks/{{ playbook.id }}"
|
||||
hx-confirm="Удалить playbook '{{ playbook.name }}'?"
|
||||
hx-target="closest .col-12"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
title="Удалить"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if playbook.description %}
|
||||
<p class="card-text text-muted small mb-3">{{ playbook.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="small text-muted mb-2">
|
||||
<i class="fas fa-tasks me-1"></i>
|
||||
Ролей: <span class="fw-semibold">{{ playbook.roles|length }}</span>
|
||||
</div>
|
||||
{% if playbook.roles %}
|
||||
<div class="small text-muted mb-2">
|
||||
<i class="fas fa-list me-1"></i>
|
||||
Роли:
|
||||
{% for role in playbook.roles[:3] %}
|
||||
<span class="badge bg-info me-1">{{ role }}</span>
|
||||
{% endfor %}
|
||||
{% if playbook.roles|length > 3 %}
|
||||
<span class="text-muted">+{{ playbook.roles|length - 3 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="small text-muted mb-2">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Статус:
|
||||
<span class="badge {% if playbook.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{{ playbook.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-3">
|
||||
<a
|
||||
href="/playbooks/{{ playbook.id }}"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Детали
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Playbook'ов пока нет. <a href="/playbooks/create">Создайте первый playbook</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user