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