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,307 @@
{% extends "base.html" %}
{% block title %}Создать роль - DevOpsLab{% endblock %}
{% block page_title %}Создание новой роли{% endblock %}
{% block header_actions %}
<a href="/roles" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад к списку
</a>
{% endblock %}
{% block content %}
<div x-data="roleCreator()">
<form
hx-post="/api/v1/roles/create"
hx-target="#result"
hx-swap="innerHTML"
@submit.prevent="submitForm"
class="card"
>
<div class="card-header">
<h5 class="mb-0">Шаг 1: Базовая информация</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Имя роли *</label>
<input
type="text"
name="role_name"
x-model="formData.role_name"
required
pattern="[a-z0-9_-]+"
class="form-control"
placeholder="nginx, docker, python"
>
<div class="form-text">
Только строчные буквы, цифры, дефисы и подчеркивания
</div>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea
name="description"
x-model="formData.description"
rows="3"
class="form-control"
placeholder="Краткое описание роли..."
></textarea>
</div>
<div class="mb-3">
<label class="form-label">Тип роли (шаблон)</label>
<select
name="template"
x-model="formData.template"
class="form-select"
>
<option value="default">По умолчанию</option>
<option value="service">Сервис (service)</option>
<option value="package">Пакеты (package)</option>
<option value="config">Конфигурация (config)</option>
</select>
</div>
</div>
</form>
<form
hx-post="/api/v1/roles/create"
hx-target="#result"
hx-swap="innerHTML"
@submit.prevent="submitForm"
class="card mt-3"
>
<div class="card-header">
<h5 class="mb-0">Шаг 2: Поддерживаемые ОС</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6 col-lg-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="ubuntu"
x-model="formData.platforms"
id="platform-ubuntu"
>
<label class="form-check-label" for="platform-ubuntu">
Ubuntu
</label>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="debian"
x-model="formData.platforms"
id="platform-debian"
>
<label class="form-check-label" for="platform-debian">
Debian
</label>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="centos"
x-model="formData.platforms"
id="platform-centos"
>
<label class="form-check-label" for="platform-centos">
CentOS
</label>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="rhel"
x-model="formData.platforms"
id="platform-rhel"
>
<label class="form-check-label" for="platform-rhel">
RHEL
</label>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="almalinux"
x-model="formData.platforms"
id="platform-almalinux"
>
<label class="form-check-label" for="platform-almalinux">
AlmaLinux
</label>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="rocky"
x-model="formData.platforms"
id="platform-rocky"
>
<label class="form-check-label" for="platform-rocky">
Rocky Linux
</label>
</div>
</div>
</div>
</div>
</form>
<form
hx-post="/api/v1/roles/create"
hx-target="#result"
hx-swap="innerHTML"
@submit.prevent="submitForm"
class="card mt-3"
>
<div class="card-header">
<h5 class="mb-0">Шаг 3: Переменные (опционально)</h5>
</div>
<div class="card-body">
<div class="space-y-2 mb-3" x-ref="variablesContainer">
<template x-for="(variable, index) in formData.variables" :key="index">
<div class="row g-2 mb-2 align-items-end">
<div class="col-12 col-md-4">
<input
type="text"
x-model="variable.name"
placeholder="Имя переменной"
class="form-control"
>
</div>
<div class="col-12 col-md-4">
<input
type="text"
x-model="variable.value"
placeholder="Значение по умолчанию"
class="form-control"
>
</div>
<div class="col-12 col-md-3">
<select
x-model="variable.type"
class="form-select"
>
<option value="string">String</option>
<option value="int">Integer</option>
<option value="bool">Boolean</option>
<option value="list">List</option>
<option value="dict">Dict</option>
</select>
</div>
<div class="col-12 col-md-1">
<button
type="button"
@click="removeVariable(index)"
class="btn btn-danger btn-sm w-100"
title="Удалить"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</template>
</div>
<button
type="button"
@click="addVariable"
class="btn btn-outline-secondary"
>
<i class="fas fa-plus me-2"></i>
Добавить переменную
</button>
</div>
<!-- Скрытые поля для отправки -->
<input
type="hidden"
name="role_name"
:value="formData.role_name"
>
<input
type="hidden"
name="description"
:value="formData.description"
>
<input
type="hidden"
name="template"
:value="formData.template"
>
<input
type="hidden"
name="platforms"
:value="JSON.stringify(formData.platforms)"
>
<input
type="hidden"
name="variables"
:value="JSON.stringify(formData.variables)"
>
<!-- Результат -->
<div id="result" class="card-body border-top"></div>
<!-- Кнопки -->
<div class="card-footer">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-check me-2"></i>
Создать роль
</button>
<a href="/roles" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>
Отмена
</a>
</div>
</div>
</form>
</div>
<script>
function roleCreator() {
return {
formData: {
role_name: '',
description: '',
template: 'default',
platforms: [],
variables: []
},
addVariable() {
this.formData.variables.push({
name: '',
value: '',
type: 'string'
});
},
removeVariable(index) {
this.formData.variables.splice(index, 1);
},
submitForm(event) {
// HTMX обработает отправку
}
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,309 @@
{% extends "base.html" %}
{% block title %}Деплой {{ role_name }} - DevOpsLab{% endblock %}
{% block page_title %}Деплой роли: {{ role_name }}{% endblock %}
{% block header_actions %}
<a href="/roles/{{ role_name }}" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
{% endblock %}
{% block content %}
<div x-data="deployRunner()">
<!-- Предупреждение -->
<div class="alert alert-warning mb-3" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>⚠️ ВНИМАНИЕ:</strong> Вы собираетесь изменить реальные серверы! Убедитесь, что вы понимаете последствия.
</div>
<!-- Проверка наличия необходимых файлов -->
{% if not inventory_exists %}
<div class="alert alert-danger mb-3" role="alert">
<i class="fas fa-exclamation-circle me-2"></i>
<strong>❌ Inventory файл не найден!</strong>
<p class="mb-0 mt-2">Создайте файл <code>inventory/hosts.ini</code> с вашими серверами или используйте <a href="/deploy/inventory" class="alert-link">редактор inventory</a>.</p>
</div>
{% endif %}
{% if not deploy_playbook_exists %}
<div class="alert alert-danger mb-3" role="alert">
<i class="fas fa-exclamation-circle me-2"></i>
<strong>❌ Playbook deploy.yml не найден!</strong>
<p class="mb-0 mt-2">Создайте файл <code>roles/deploy.yml</code> для развертывания ролей.</p>
</div>
{% endif %}
<!-- Настройки деплоя -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Настройки деплоя</h5>
</div>
<div class="card-body">
<form @submit.prevent="startDeploy">
<div class="mb-3">
<label class="form-label">Inventory файл</label>
<input
type="text"
x-model="deployConfig.inventory"
value="inventory/hosts.ini"
class="form-control"
placeholder="inventory/hosts.ini"
>
<div class="form-text">
Путь к inventory файлу относительно корня проекта
</div>
</div>
<div class="mb-3">
<label class="form-label">Limit (опционально)</label>
<input
type="text"
x-model="deployConfig.limit"
class="form-control"
placeholder="webservers или host1,host2"
>
<div class="form-text">
Ограничение на хосты для деплоя
</div>
</div>
<div class="mb-3">
<label class="form-label">Tags (опционально)</label>
<input
type="text"
x-model="deployConfig.tags"
class="form-control"
placeholder="web,database или оставьте пустым для всех тегов"
>
<div class="form-text">
Теги для фильтрации задач (через запятую)
</div>
</div>
<div class="mb-3">
<label class="form-label">Дополнительные переменные (JSON, опционально)</label>
<textarea
x-model="deployConfig.extra_vars"
rows="3"
class="form-control font-monospace"
placeholder='{"app_version": "1.0.0", "nginx_enabled": true}'
></textarea>
<div class="form-text">
Укажите переменные в формате JSON
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
x-model="deployConfig.check"
id="deploy-check"
>
<label class="form-check-label" for="deploy-check">
Dry-run режим (--check) - изменения не будут применены
</label>
</div>
</div>
<button
type="submit"
class="btn btn-primary"
:disabled="deployRunning || !inventoryExists || !deployPlaybookExists"
>
<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>
</form>
</div>
</div>
<!-- Live логи -->
<div class="card" x-show="deployRunning || logs.length > 0">
<div class="card-header">
<h5 class="mb-0">Логи деплоя</h5>
</div>
<div class="card-body">
<div class="log-container" id="deploy-logs" x-ref="logContainer" style="max-height: 500px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 0.25rem; font-family: 'Courier New', monospace; font-size: 0.875rem;">
<template x-for="log in logs" :key="log.id">
<div
class="log-line"
:class="{
'log-error': log.type === 'error',
'log-warning': log.type === 'warning',
'log-info': log.type === 'info',
'log-success': log.type === 'success'
}"
x-text="log.data"
></div>
</template>
</div>
<div class="mt-3 d-flex gap-2">
<button
@click="clearLogs"
class="btn btn-outline-secondary"
>
<i class="fas fa-trash me-2"></i>
Очистить
</button>
<button
@click="downloadLogs"
class="btn btn-outline-secondary"
>
<i class="fas fa-download me-2"></i>
Скачать логи
</button>
</div>
</div>
</div>
</div>
<script>
function deployRunner() {
return {
deployRunning: false,
logs: [],
deployConfig: {
inventory: 'inventory/hosts.ini',
limit: '',
tags: '{{ role_name }}',
extra_vars: '',
check: false
},
inventoryExists: {{ 'true' if inventory_exists else 'false' }},
deployPlaybookExists: {{ 'true' if deploy_playbook_exists else 'false' }},
ws: null,
async startDeploy() {
if (!this.inventoryExists || !this.deployPlaybookExists) {
alert('❌ Необходимые файлы не найдены!');
return;
}
this.deployRunning = true;
this.logs = [];
// Формируем deploy_id
const deployId = `deploy-{{ role_name }}-${this.deployConfig.tags || 'none'}`;
// Создание WebSocket подключения
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws/deploy/${deployId}`);
this.ws.onopen = () => {
// Отправляем параметры деплоя
this.ws.send(JSON.stringify({
type: 'start',
role_name: '{{ role_name }}',
inventory: this.deployConfig.inventory,
limit: this.deployConfig.limit || null,
tags: this.deployConfig.tags ? this.deployConfig.tags.split(',').map(t => t.trim()) : null,
check: this.deployConfig.check,
extra_vars: this.deployConfig.extra_vars ? JSON.parse(this.deployConfig.extra_vars) : null
}));
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
this.logs.push({
id: Date.now() + Math.random(),
type: data.level || this.detectLogLevel(data.data),
data: data.data
});
// Автоскролл
this.$nextTick(() => {
const container = this.$refs.logContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
} else if (data.type === 'complete') {
this.deployRunning = false;
this.logs.push({
id: Date.now(),
type: data.status === 'success' ? 'success' : 'error',
data: data.data || `✅ Деплой завершен: ${data.status}`
});
this.ws.close();
} else if (data.type === 'error') {
this.deployRunning = false;
this.logs.push({
id: Date.now(),
type: 'error',
data: data.data || `❌ Ошибка`
});
this.ws.close();
}
};
this.ws.onerror = (error) => {
this.deployRunning = false;
this.logs.push({
id: Date.now(),
type: 'error',
data: `❌ Ошибка подключения: ${error}`
});
};
this.ws.onclose = () => {
this.deployRunning = false;
};
},
detectLogLevel(line) {
const lower = line.toLowerCase();
if (lower.includes('error') || lower.includes('failed') || lower.includes('fatal')) return 'error';
if (lower.includes('warning') || lower.includes('warn')) return 'warning';
if (lower.includes('changed') || lower.includes('ok') || lower.includes('success')) return 'success';
if (lower.includes('skipping') || lower.includes('ok')) return 'info';
return 'info';
},
clearLogs() {
this.logs = [];
},
downloadLogs() {
const content = this.logs.map(l => l.data).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 = `deploy-{{ role_name }}-${Date.now()}.log`;
a.click();
URL.revokeObjectURL(url);
}
}
}
</script>
<style>
.log-line {
margin: 0;
padding: 0.25rem 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.log-error {
color: #f48771;
}
.log-warning {
color: #dcdcaa;
}
.log-info {
color: #569cd6;
}
.log-success {
color: #4ec9b0;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,234 @@
{% extends "base.html" %}
{% block title %}{{ role.name }} - DevOpsLab{% endblock %}
{% block page_title %}{{ role.name }}{% endblock %}
{% block header_actions %}
<div class="d-flex gap-2">
<a href="/roles/{{ role.name }}/test" class="btn btn-success btn-sm">
<i class="fas fa-vial me-2"></i>
Тестировать
</a>
<a href="/roles/{{ role.name }}/edit" class="btn btn-primary btn-sm">
<i class="fas fa-edit me-2"></i>
Редактировать
</a>
<a href="/roles" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
</div>
{% endblock %}
{% block content %}
{% if role.description %}
<div class="alert alert-info mb-3">
<i class="fas fa-info-circle me-2"></i>
{{ role.description }}
</div>
{% endif %}
<!-- Вкладки -->
<div class="card">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button
class="nav-link active"
id="overview-tab"
data-bs-toggle="tab"
data-bs-target="#overview"
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="tasks-tab"
data-bs-toggle="tab"
data-bs-target="#tasks"
type="button"
role="tab"
>
<i class="fas fa-tasks me-2"></i>
Задачи
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
id="defaults-tab"
data-bs-toggle="tab"
data-bs-target="#defaults"
type="button"
role="tab"
>
<i class="fas fa-sliders-h me-2"></i>
Переменные
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
id="readme-tab"
data-bs-toggle="tab"
data-bs-target="#readme"
type="button"
role="tab"
>
<i class="fas fa-book me-2"></i>
Документация
</button>
</li>
</ul>
</div>
<!-- Содержимое вкладок -->
<div class="card-body">
<div class="tab-content" id="role-tabs-content">
<!-- Вкладка: Обзор -->
<div class="tab-pane fade show active" id="overview" role="tabpanel">
<h5 class="mb-4">Информация о роли</h5>
<div class="row g-4 mb-4">
<div class="col-12 col-md-6">
<h6 class="fw-semibold mb-3">Структура</h6>
<ul class="list-unstyled">
{% if role.has_tasks %}
<li class="mb-2">
<i class="fas fa-check-circle text-success me-2"></i>
tasks/main.yml
</li>
{% else %}
<li class="mb-2 text-muted">
<i class="fas fa-times-circle me-2"></i>
tasks/main.yml
</li>
{% endif %}
{% if role.has_defaults %}
<li class="mb-2">
<i class="fas fa-check-circle text-success me-2"></i>
defaults/main.yml
</li>
{% else %}
<li class="mb-2 text-muted">
<i class="fas fa-times-circle me-2"></i>
defaults/main.yml
</li>
{% endif %}
{% if role.has_handlers %}
<li class="mb-2">
<i class="fas fa-check-circle text-success me-2"></i>
handlers/main.yml
</li>
{% else %}
<li class="mb-2 text-muted">
<i class="fas fa-times-circle me-2"></i>
handlers/main.yml
</li>
{% endif %}
{% if role.has_meta %}
<li class="mb-2">
<i class="fas fa-check-circle text-success me-2"></i>
meta/main.yml
</li>
{% else %}
<li class="mb-2 text-muted">
<i class="fas fa-times-circle me-2"></i>
meta/main.yml
</li>
{% endif %}
</ul>
</div>
<div class="col-12 col-md-6">
<h6 class="fw-semibold mb-3">Метаданные</h6>
{% if role.author %}
<p class="mb-2">
<strong>Автор:</strong> {{ role.author }}
</p>
{% endif %}
{% if role.platforms %}
<p class="mb-2"><strong>Платформы:</strong></p>
<ul class="list-unstyled">
{% for platform in role.platforms %}
<li class="mb-1">
<i class="fas fa-server me-2 text-muted"></i>
{{ platform.name }}
{% if platform.versions %}
<span class="text-muted">({{ platform.versions|join(", ") }})</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<div class="mt-4">
<h6 class="fw-semibold mb-3">Быстрые действия</h6>
<div class="d-flex gap-2 flex-wrap">
<a href="/roles/{{ role.name }}/test" class="btn btn-success">
<i class="fas fa-vial me-2"></i>
Запустить тест
</a>
<a href="/roles/{{ role.name }}/deploy" class="btn btn-primary">
<i class="fas fa-rocket me-2"></i>
Деплой
</a>
<a href="/roles/{{ role.name }}/export" class="btn btn-outline-primary">
<i class="fas fa-upload me-2"></i>
Экспорт
</a>
</div>
</div>
</div>
<!-- Вкладка: Задачи -->
<div class="tab-pane fade" id="tasks" role="tabpanel">
<h5 class="mb-4">Задачи (tasks/main.yml)</h5>
{% if tasks_content %}
<pre class="bg-dark text-light p-3 rounded"><code>{{ tasks_content }}</code></pre>
{% else %}
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
Файл tasks/main.yml не найден
</div>
{% endif %}
</div>
<!-- Вкладка: Переменные -->
<div class="tab-pane fade" id="defaults" role="tabpanel">
<h5 class="mb-4">Переменные (defaults/main.yml)</h5>
{% if defaults_content %}
<pre class="bg-dark text-light p-3 rounded"><code>{{ defaults_content }}</code></pre>
{% else %}
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
Файл defaults/main.yml не найден
</div>
{% endif %}
</div>
<!-- Вкладка: Документация -->
<div class="tab-pane fade" id="readme" role="tabpanel">
<h5 class="mb-4">Документация (README.md)</h5>
{% if readme_content %}
<div class="border rounded p-3 bg-light">
<pre class="mb-0" style="white-space: pre-wrap;">{{ readme_content }}</pre>
</div>
{% else %}
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
Файл README.md не найден
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,456 @@
{% extends "base.html" %}
{% block title %}Редактировать {{ role.name }} - DevOpsLab{% endblock %}
{% block page_title %}Редактирование роли: {{ role.name }}{% endblock %}
{% block header_actions %}
<a href="/roles/{{ role.name }}" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button
class="nav-link active"
id="tasks-tab"
data-bs-toggle="tab"
data-bs-target="#tasks"
type="button"
role="tab"
>
<i class="fas fa-tasks me-2"></i>
Tasks
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
id="handlers-tab"
data-bs-toggle="tab"
data-bs-target="#handlers"
type="button"
role="tab"
>
<i class="fas fa-cogs me-2"></i>
Handlers
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
id="defaults-tab"
data-bs-toggle="tab"
data-bs-target="#defaults"
type="button"
role="tab"
>
<i class="fas fa-sliders-h me-2"></i>
Defaults
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
id="vars-tab"
data-bs-toggle="tab"
data-bs-target="#vars"
type="button"
role="tab"
>
<i class="fas fa-key me-2"></i>
Vars
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
id="meta-tab"
data-bs-toggle="tab"
data-bs-target="#meta"
type="button"
role="tab"
>
<i class="fas fa-info-circle me-2"></i>
Meta
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
id="readme-tab"
data-bs-toggle="tab"
data-bs-target="#readme"
type="button"
role="tab"
>
<i class="fas fa-book me-2"></i>
README
</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content" id="role-edit-tabs">
<!-- Tasks -->
<div class="tab-pane fade show active" id="tasks" role="tabpanel">
<form
hx-post="/api/v1/roles/{{ role.name }}/update"
hx-target="#tasks-result"
hx-swap="innerHTML"
class="mb-3"
>
<input type="hidden" name="file_type" value="tasks">
<div class="mb-3">
<label class="form-label">tasks/main.yml</label>
<textarea
name="content"
rows="20"
class="form-control font-monospace"
required
>{{ files_content.get('tasks', '') }}</textarea>
</div>
<div id="tasks-result"></div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить
</button>
</form>
</div>
<!-- Handlers -->
<div class="tab-pane fade" id="handlers" role="tabpanel">
<form
hx-post="/api/v1/roles/{{ role.name }}/update"
hx-target="#handlers-result"
hx-swap="innerHTML"
class="mb-3"
>
<input type="hidden" name="file_type" value="handlers">
<div class="mb-3">
<label class="form-label">handlers/main.yml</label>
<textarea
name="content"
id="handlers-content"
rows="20"
class="form-control font-monospace"
required
>{{ files_content.get('handlers', '') }}</textarea>
</div>
<div id="handlers-result"></div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить
</button>
</form>
</div>
<!-- Defaults -->
<div class="tab-pane fade" id="defaults" role="tabpanel">
<form
hx-post="/api/v1/roles/{{ role.name }}/update"
hx-target="#defaults-result"
hx-swap="innerHTML"
class="mb-3"
>
<input type="hidden" name="file_type" value="defaults">
<div class="mb-3">
<label class="form-label">defaults/main.yml</label>
<textarea
name="content"
id="defaults-content"
rows="20"
class="form-control font-monospace"
required
>{{ files_content.get('defaults', '') }}</textarea>
</div>
<div id="defaults-result"></div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить
</button>
</form>
</div>
<!-- Vars -->
<div class="tab-pane fade" id="vars" role="tabpanel">
<div class="alert alert-warning mb-3">
<i class="fas fa-lock me-2"></i>
<strong>Внимание:</strong> Если файл зашифрован через Ansible Vault, используйте кнопку "Расшифровать" для редактирования.
</div>
<form
hx-post="/api/v1/roles/{{ role.name }}/update"
hx-target="#vars-result"
hx-swap="innerHTML"
class="mb-3"
>
<input type="hidden" name="file_type" value="vars">
<div class="mb-3">
<label class="form-label">vars/main.yml</label>
<div class="d-flex gap-2 mb-2">
<button
type="button"
class="btn btn-sm btn-outline-secondary"
onclick="checkVaultEncryption('vars')"
>
<i class="fas fa-search me-2"></i>
Проверить Vault
</button>
<button
type="button"
class="btn btn-sm btn-outline-primary"
onclick="decryptVault('vars')"
id="decrypt-vars-btn"
style="display: none;"
>
<i class="fas fa-unlock me-2"></i>
Расшифровать
</button>
<button
type="button"
class="btn btn-sm btn-outline-success"
onclick="encryptVault('vars')"
id="encrypt-vars-btn"
style="display: none;"
>
<i class="fas fa-lock me-2"></i>
Зашифровать
</button>
</div>
<textarea
name="content"
id="vars-content"
rows="20"
class="form-control font-monospace"
required
>{{ files_content.get('vars', '') }}</textarea>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof CodeEditor !== 'undefined') {
const editor = CodeEditor.init('vars-content', 'yaml', {
theme: 'monokai',
lineNumbers: true
});
if (editor) {
editor.on('change', function() {
const validation = CodeEditor.validateYAML(editor.getValue());
if (!validation.valid) {
CodeEditor.showErrors(editor, validation.errors);
}
});
}
}
});
</script>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
Для работы с Vault файлами используйте кнопки выше
</div>
</div>
<div id="vars-result"></div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить
</button>
</form>
</div>
<!-- Meta -->
<div class="tab-pane fade" id="meta" role="tabpanel">
<form
hx-post="/api/v1/roles/{{ role.name }}/update"
hx-target="#meta-result"
hx-swap="innerHTML"
class="mb-3"
>
<input type="hidden" name="file_type" value="meta">
<div class="mb-3">
<label class="form-label">meta/main.yml</label>
<textarea
name="content"
id="meta-content"
rows="20"
class="form-control font-monospace"
required
>{{ files_content.get('meta', '') }}</textarea>
</div>
<div id="meta-result"></div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить
</button>
</form>
</div>
<!-- README -->
<div class="tab-pane fade" id="readme" role="tabpanel">
<form
hx-post="/api/v1/roles/{{ role.name }}/update"
hx-target="#readme-result"
hx-swap="innerHTML"
class="mb-3"
>
<input type="hidden" name="file_type" value="readme">
<div class="mb-3">
<label class="form-label">README.md</label>
<textarea
name="content"
rows="20"
class="form-control font-monospace"
required
>{{ readme_content }}</textarea>
</div>
<div id="readme-result"></div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить
</button>
</form>
</div>
</div>
</div>
</div>
{% block scripts %}
<script>
async function checkVaultEncryption(fileType) {
const textarea = document.getElementById(fileType + '-content');
if (!textarea) return;
const content = textarea.value;
if (content.includes('$ANSIBLE_VAULT') || content.includes('!vault |')) {
document.getElementById('decrypt-' + fileType + '-btn').style.display = 'inline-block';
document.getElementById('encrypt-' + fileType + '-btn').style.display = 'none';
} else {
document.getElementById('encrypt-' + fileType + '-btn').style.display = 'inline-block';
document.getElementById('decrypt-' + fileType + '-btn').style.display = 'none';
}
}
async function decryptVault(fileType) {
const textarea = document.getElementById(fileType + '-content');
if (!textarea) return;
const content = textarea.value;
if (!content.trim()) {
alert('Нет содержимого для расшифровки');
return;
}
try {
const response = await fetch(`/api/v1/vault/decrypt`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: content })
});
const result = await response.json();
if (result.success) {
textarea.value = result.decrypted;
document.getElementById('decrypt-' + fileType + '-btn').style.display = 'none';
document.getElementById('encrypt-' + fileType + '-btn').style.display = 'inline-block';
alert('Файл успешно расшифрован');
} else {
alert('Ошибка расшифровки: ' + (result.error || 'Неизвестная ошибка'));
}
} catch (error) {
alert('Ошибка: ' + error.message);
}
}
async function encryptVault(fileType) {
const textarea = document.getElementById(fileType + '-content');
if (!textarea) return;
const content = textarea.value;
if (!content.trim()) {
alert('Нет содержимого для шифрования');
return;
}
const confirmed = await showConfirmModal('Зашифровать содержимое через Ansible Vault?');
if (!confirmed) {
return;
}
try {
const response = await fetch(`/api/v1/vault/encrypt`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: content })
});
const result = await response.json();
if (result.success) {
textarea.value = result.encrypted;
document.getElementById('encrypt-' + fileType + '-btn').style.display = 'none';
document.getElementById('decrypt-' + fileType + '-btn').style.display = 'inline-block';
alert('Файл успешно зашифрован');
} else {
alert('Ошибка шифрования: ' + (result.error || 'Неизвестная ошибка'));
}
} catch (error) {
alert('Ошибка: ' + error.message);
}
}
// Инициализация редакторов для всех textarea
document.addEventListener('DOMContentLoaded', function() {
// Инициализация CodeMirror для всех YAML редакторов
if (typeof CodeEditor !== 'undefined') {
const editors = ['tasks', 'handlers', 'defaults', 'vars', 'meta'];
editors.forEach(function(fileType) {
const textareaId = fileType + '-content';
const textarea = document.getElementById(textareaId);
if (textarea) {
const editor = CodeEditor.init(textareaId, 'yaml', {
theme: 'monokai',
lineNumbers: true,
foldGutter: true
});
if (editor) {
editor.on('change', function() {
const content = editor.getValue();
const validation = CodeEditor.validateYAML(content);
if (!validation.valid && content.trim()) {
CodeEditor.showErrors(editor, validation.errors);
} else {
// Очищаем ошибки если валидация прошла
if (editor._validationMarkers) {
editor._validationMarkers.forEach(m => m.clear());
editor._validationMarkers = [];
}
}
});
}
}
});
}
checkVaultEncryption('vars');
// Проверка при переключении на вкладку vars
const varsTab = document.getElementById('vars-tab');
if (varsTab) {
varsTab.addEventListener('shown.bs.tab', function() {
checkVaultEncryption('vars');
});
}
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block title %}Экспорт {{ role_name }} - DevOpsLab{% endblock %}
{% block page_title %}Экспорт роли: {{ role_name }}{% endblock %}
{% block content %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Настройки экспорта</h5>
</div>
<div class="card-body">
<form
hx-post="/api/v1/roles/{{ role_name }}/export"
hx-target="#export-result"
hx-swap="innerHTML"
class="space-y-4"
>
<!-- URL репозитория -->
<div class="mb-3">
<label class="form-label">URL Git репозитория *</label>
<input
type="url"
name="repo_url"
class="form-control"
placeholder="https://github.com/username/ansible-role-{{ role_name }}.git"
required
>
<div class="form-text">
Поддерживаются HTTPS и SSH URL (git@github.com:user/repo.git)
</div>
</div>
<!-- Ветка -->
<div class="mb-3">
<label class="form-label">Ветка</label>
<input
type="text"
name="branch"
value="main"
class="form-control"
placeholder="main"
>
</div>
<!-- Версия -->
<div class="mb-3">
<label class="form-label">Версия (для создания тега)</label>
<input
type="text"
name="version"
class="form-control"
placeholder="1.0.0"
pattern="^[0-9]+\.[0-9]+\.[0-9]+$"
>
<div class="form-text">
Формат: X.Y.Z (например, 1.0.0). Будет создан тег v{version}
</div>
</div>
<!-- Компоненты -->
<div class="mb-3">
<label class="form-label">Компоненты для экспорта</label>
<div class="row g-2">
{% for component in components %}
<div class="col-12 col-md-6 col-lg-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="{{ component }}"
id="component-{{ component }}"
name="components"
checked
>
<label class="form-check-label" for="component-{{ component }}">
{{ component }}
</label>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Секреты -->
<div class="mb-3">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="include-secrets"
name="include_secrets"
>
<label class="form-check-label" for="include-secrets">
Включать секреты из vars/ (не рекомендуется)
</label>
</div>
<div class="form-text text-warning">
⚠️ Если включено, секреты будут экспортированы. Используйте с осторожностью!
</div>
</div>
<!-- Сообщение коммита -->
<div class="mb-3">
<label class="form-label">Сообщение коммита</label>
<textarea
name="commit_message"
class="form-control"
rows="2"
placeholder="Автоматически сгенерируется, если не указано"
></textarea>
</div>
<!-- Скрытое поле для компонентов -->
<input
type="hidden"
name="components"
id="components-json"
value="[]"
>
<!-- Результат -->
<div id="export-result" class="mt-4"></div>
<!-- Кнопки -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>
Экспортировать роль
</button>
<a href="/roles/{{ role_name }}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>
Отмена
</a>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form');
const componentsInput = document.getElementById('components-json');
const checkboxes = document.querySelectorAll('input[name="components"]');
function updateComponents() {
const selected = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
componentsInput.value = JSON.stringify(selected);
}
checkboxes.forEach(cb => {
cb.addEventListener('change', updateComponents);
});
updateComponents();
form.addEventListener('submit', function(e) {
updateComponents();
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,186 @@
{% extends "base.html" %}
{% block title %}Импорт роли - DevOpsLab{% endblock %}
{% block page_title %}Импорт роли{% endblock %}
{% block header_actions %}
<a href="/roles" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад к списку
</a>
{% endblock %}
{% block content %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Импорт из Git репозитория</h5>
</div>
<div class="card-body">
<form
hx-post="/api/v1/roles/import/git"
hx-target="#import-result"
hx-swap="innerHTML"
>
<div class="mb-3">
<label class="form-label">URL Git репозитория *</label>
<input
type="url"
name="repo_url"
class="form-control"
placeholder="https://github.com/username/ansible-role-nginx.git"
required
>
<div class="form-text">
Поддерживаются HTTPS и SSH URL
</div>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label">Имя роли (опционально)</label>
<input
type="text"
name="role_name"
class="form-control"
placeholder="Автоматически из URL"
pattern="[a-z0-9_-]+"
>
<div class="form-text">
Если не указано, будет извлечено из имени репозитория
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Ветка</label>
<input
type="text"
name="branch"
value="main"
class="form-control"
placeholder="main"
>
</div>
</div>
<div class="mb-3">
<label class="form-label">Поддиректория (опционально)</label>
<input
type="text"
name="subdirectory"
class="form-control"
placeholder="roles/nginx"
>
<div class="form-text">
Если роль находится не в корне репозитория
</div>
</div>
<div id="import-result" class="mt-3"></div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-download me-2"></i>
Импортировать из Git
</button>
<button
type="button"
class="btn btn-outline-secondary"
onclick="validateRepo()"
>
<i class="fas fa-check me-2"></i>
Проверить репозиторий
</button>
</div>
</form>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Импорт из Ansible Galaxy</h5>
</div>
<div class="card-body">
<form
hx-post="/api/v1/roles/import/galaxy"
hx-target="#galaxy-result"
hx-swap="innerHTML"
>
<div class="mb-3">
<label class="form-label">Имя роли *</label>
<input
type="text"
name="role_name"
class="form-control"
placeholder="username.role_name или role_name"
required
>
<div class="form-text">
Формат: namespace.role_name или просто role_name
</div>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label">Namespace (опционально)</label>
<input
type="text"
name="namespace"
class="form-control"
placeholder="username"
>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Версия (опционально)</label>
<input
type="text"
name="version"
class="form-control"
placeholder="1.0.0"
>
</div>
</div>
<div id="galaxy-result" class="mt-3"></div>
<button type="submit" class="btn btn-success">
<i class="fas fa-cloud-download-alt me-2"></i>
Импортировать из Galaxy
</button>
</form>
</div>
</div>
<script>
async function validateRepo() {
const repoUrl = document.querySelector('input[name="repo_url"]').value;
const branch = document.querySelector('input[name="branch"]').value || 'main';
if (!repoUrl) {
alert('Введите URL репозитория');
return;
}
const formData = new FormData();
formData.append('repo_url', repoUrl);
formData.append('branch', branch);
try {
const response = await fetch('/api/v1/roles/import/validate', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.valid) {
alert(`✅ Репозиторий доступен!\n\nСтруктура роли:\n- tasks: ${result.has_tasks ? '✅' : '❌'}\n- meta: ${result.has_meta ? '✅' : '❌'}`);
} else {
alert(`❌ Ошибка: ${result.error}`);
}
} catch (error) {
alert(`❌ Ошибка проверки: ${error.message}`);
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,248 @@
{% extends "base.html" %}
{% block title %}Роли - DevOpsLab{% endblock %}
{% block page_title %}Роли Ansible{% endblock %}
{% block header_actions %}
<a href="/roles/create" class="btn btn-primary btn-sm">
<i class="fas fa-plus me-2"></i>
Создать роль
</a>
{% endblock %}
{% block content %}
<!-- Поиск и фильтры -->
<div class="card mb-3">
<div class="card-body">
<form method="get" action="/roles" class="row g-3">
<div class="col-12 col-md-8">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Поиск по имени или описанию..."
class="form-control"
>
</div>
<div class="col-12 col-md-4">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search me-1"></i>
Поиск
</button>
{% if search %}
<a href="/roles" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i>
Сброс
</a>
{% endif %}
</div>
</div>
</form>
</div>
</div>
<!-- Таблица ролей -->
<div class="card mb-3">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">Список ролей</h5>
<span class="text-muted small">
Всего: <strong>{{ total }}</strong>
</span>
</div>
</div>
<div class="card-body p-0">
{% if roles %}
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 20%;">Имя роли</th>
<th style="width: 30%;">Описание</th>
<th style="width: 20%;">Компоненты</th>
<th style="width: 20%;">Платформы</th>
<th style="width: auto; min-width: 140px;">Действия</th>
</tr>
</thead>
<tbody>
{% for role in roles %}
<tr>
<td>
<a href="/roles/{{ role.name }}" class="text-decoration-none fw-semibold text-primary">
<i class="fas fa-cube me-1"></i>
{{ role.name }}
</a>
</td>
<td>
<span class="text-muted small">
{% if role.description %}
{{ role.description[:80] }}{% if role.description|length > 80 %}...{% endif %}
{% else %}
<span class="text-muted fst-italic">Нет описания</span>
{% endif %}
</span>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% if role.has_tasks %}
<span class="badge bg-success" title="Tasks - задачи роли">
<i class="fas fa-tasks me-1"></i>Tasks
</span>
{% endif %}
{% if role.has_defaults %}
<span class="badge bg-info" title="Defaults - значения по умолчанию">
<i class="fas fa-sliders-h me-1"></i>Defaults
</span>
{% endif %}
{% if role.has_handlers %}
<span class="badge bg-warning text-dark" title="Handlers - обработчики">
<i class="fas fa-bell me-1"></i>Handlers
</span>
{% endif %}
{% if role.has_meta %}
<span class="badge bg-primary" title="Meta - метаданные роли">
<i class="fas fa-info-circle me-1"></i>Meta
</span>
{% endif %}
{% if not role.has_tasks and not role.has_defaults and not role.has_handlers and not role.has_meta %}
<span class="badge bg-secondary">Пустая роль</span>
{% endif %}
</div>
</td>
<td>
{% if role.platforms and role.platforms|length > 0 %}
<div class="d-flex flex-wrap gap-1">
{% for platform in role.platforms[:4] %}
<span class="badge bg-dark" title="{{ platform.name }}{% if platform.versions %} ({{ platform.versions|join(', ') }}){% endif %}">
<i class="fab fa-linux me-1"></i>
{{ platform.name }}
</span>
{% endfor %}
{% if role.platforms|length > 4 %}
<span class="badge bg-secondary" title="Ещё {{ role.platforms|length - 4 }} платформ">
+{{ role.platforms|length - 4 }}
</span>
{% endif %}
</div>
{% else %}
<span class="text-muted small fst-italic">Не указаны</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a
href="/roles/{{ role.name }}/test"
class="btn btn-outline-success"
title="Запустить тест"
>
<i class="fas fa-vial"></i>
</a>
<a
href="/roles/{{ role.name }}/edit"
class="btn btn-outline-primary"
title="Редактировать"
>
<i class="fas fa-edit"></i>
</a>
<a
href="/roles/{{ role.name }}"
class="btn btn-outline-secondary"
title="Детали"
>
<i class="fas fa-info-circle"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<p class="text-muted mb-3">Роли не найдены</p>
<a href="/roles/create" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>
Создать первую роль
</a>
</div>
{% endif %}
</div>
{% if roles and total_pages > 1 %}
<div class="card-footer">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<!-- Пагинация -->
<nav aria-label="Навигация по страницам">
<ul class="pagination mb-0">
{% if page > 1 %}
<li class="page-item">
<a class="page-link" href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}">
<i class="fas fa-chevron-left"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">
<i class="fas fa-chevron-left"></i>
</span>
</li>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<li class="page-item active">
<span class="page-link">{{ p }}</span>
</li>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<li class="page-item">
<a class="page-link" href="?page={{ p }}{% if search %}&search={{ search }}{% endif %}">{{ p }}</a>
</li>
{% elif p == page - 3 or p == page + 3 %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<li class="page-item">
<a class="page-link" href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}">
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">
<i class="fas fa-chevron-right"></i>
</span>
</li>
{% endif %}
</ul>
</nav>
<!-- Выбор количества на странице -->
<div class="d-flex align-items-center gap-2">
<span class="text-muted small">На странице:</span>
<select
class="form-select form-select-sm pagination-per-page-select"
style="width: auto;"
onchange="window.location.href = '?page=1&per_page=' + this.value + '{% if search %}&search={{ search }}{% endif %}'"
>
<option value="10" {% if per_page == 10 %}selected{% endif %}>10</option>
<option value="25" {% if per_page == 25 %}selected{% endif %}>25</option>
<option value="50" {% if per_page == 50 %}selected{% endif %}>50</option>
<option value="100" {% if per_page == 100 %}selected{% endif %}>100</option>
</select>
</div>
<!-- Информация о странице -->
<div class="text-muted small">
Показано {{ ((page - 1) * per_page) + 1 }} - {{ [page * per_page, total]|min }} из {{ total }}
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,244 @@
{% extends "base.html" %}
{% block title %}Тестирование {{ role_name }} - DevOpsLab{% endblock %}
{% block page_title %}Тестирование роли: {{ role_name }}{% endblock %}
{% block header_actions %}
<a href="/roles/{{ role_name }}" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
{% endblock %}
{% block content %}
<div x-data="testRunner()">
<!-- Настройки теста -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Настройки теста</h5>
</div>
<div class="card-body">
<form @submit.prevent="startTest">
<div class="mb-3">
<label class="form-label">Preset для тестирования</label>
<select
x-model="testConfig.preset"
class="form-select"
>
{% for preset in presets %}
<option value="{{ preset.name }}" data-category="{{ preset.category }}">
{{ preset.name }}
{% if preset.category == 'k8s' %}
<span class="badge bg-info">k8s</span>
{% endif %}
</option>
{% endfor %}
{% if not presets %}
<option value="default">default</option>
{% endif %}
</select>
<div class="form-text">
Выберите preset для тестирования. Preset'ы загружаются из базы данных.
</div>
</div>
<div class="mb-3">
<label class="form-label">Переменные роли (JSON, опционально)</label>
<textarea
x-model="testConfig.variables"
rows="4"
class="form-control font-monospace"
placeholder='{"nginx_version": "1.25.0", "nginx_enabled": true}'
></textarea>
<div class="form-text">
Укажите переменные в формате JSON
</div>
</div>
<div class="mb-3">
<label class="form-label">Опции</label>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
x-model="testConfig.lint"
id="test-lint"
>
<label class="form-check-label" for="test-lint">
Проверка синтаксиса (lint)
</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
x-model="testConfig.idempotency"
id="test-idempotency"
>
<label class="form-check-label" for="test-idempotency">
Проверка идемпотентности
</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
x-model="testConfig.verbose"
id="test-verbose"
>
<label class="form-check-label" for="test-verbose">
Verbose режим
</label>
</div>
</div>
<button
type="submit"
class="btn btn-success"
:disabled="testRunning"
>
<i class="fas fa-play 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>
</form>
</div>
</div>
<!-- Live логи -->
<div class="card" x-show="testRunning || logs.length > 0">
<div class="card-header">
<h5 class="mb-0">Логи тестирования</h5>
</div>
<div class="card-body">
<div class="log-container" id="test-logs" x-ref="logContainer" style="max-height: 500px; overflow-y: auto;">
<template x-for="log in logs" :key="log.id">
<div
class="log-line"
:class="{
'log-error': log.type === 'error',
'log-warning': log.type === 'warning',
'log-info': log.type === 'info'
}"
x-text="log.data"
></div>
</template>
</div>
<div class="mt-3 d-flex gap-2">
<button
@click="clearLogs"
class="btn btn-outline-secondary"
>
<i class="fas fa-trash me-2"></i>
Очистить
</button>
<button
@click="downloadLogs"
class="btn btn-outline-secondary"
>
<i class="fas fa-download me-2"></i>
Скачать логи
</button>
</div>
</div>
</div>
</div>
<script>
function testRunner() {
return {
testRunning: false,
logs: [],
testConfig: {
preset: '{{ presets[0].name if presets else "default" }}',
variables: '',
lint: true,
idempotency: false,
verbose: false
},
ws: null,
async startTest() {
this.testRunning = true;
this.logs = [];
// Получаем категорию preset'а из выбранного option
const presetSelect = document.querySelector('select[x-model="testConfig.preset"]');
const selectedOption = presetSelect.options[presetSelect.selectedIndex];
const presetCategory = selectedOption ? (selectedOption.dataset.category || 'main') : 'main';
// Создание WebSocket подключения
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const testId = '{{ role_name }}-' + this.testConfig.preset + '-' + presetCategory;
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws/test/${testId}`);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
this.logs.push({
id: Date.now() + Math.random(),
type: data.level || this.detectLogLevel(data.data),
data: data.data
});
// Автоскролл
this.$nextTick(() => {
const container = this.$refs.logContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
} else if (data.type === 'complete') {
this.testRunning = false;
this.logs.push({
id: Date.now(),
type: 'info',
data: data.data || `✅ Тест завершен: ${data.status}`
});
this.ws.close();
} else if (data.type === 'error') {
this.testRunning = false;
this.logs.push({
id: Date.now(),
type: 'error',
data: data.data || `❌ Ошибка`
});
this.ws.close();
}
};
this.ws.onerror = (error) => {
this.testRunning = false;
this.logs.push({
id: Date.now(),
type: 'error',
data: `❌ Ошибка подключения: ${error}`
});
};
},
detectLogLevel(line) {
const lower = line.toLowerCase();
if (lower.includes('error') || lower.includes('failed')) return 'error';
if (lower.includes('warning') || lower.includes('warn')) return 'warning';
if (lower.includes('changed') || lower.includes('ok')) return 'info';
return 'debug';
},
clearLogs() {
this.logs = [];
},
downloadLogs() {
const content = this.logs.map(l => l.data).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 = `test-{{ role_name }}-${Date.now()}.log`;
a.click();
URL.revokeObjectURL(url);
}
}
}
</script>
{% endblock %}