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,111 @@
{% extends "base.html" %}
{% block title %}Смена пароля - DevOpsLab{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-card">
<div class="card-header">
<i class="fas fa-key fa-3x mb-3"></i>
<h1>Смена пароля</h1>
<p class="text-muted">Измените пароль для пользователя {{ current_user.username }}</p>
</div>
<div class="card-body">
<form
hx-post="/api/v1/auth/change-password"
hx-target="#change-password-result"
hx-swap="innerHTML"
class="login-form"
>
<div class="form-group">
<label class="form-label">Текущий пароль</label>
<input
type="password"
name="current_password"
class="form-control"
placeholder="••••••••"
required
autofocus
>
</div>
<div class="form-group">
<label class="form-label">Новый пароль</label>
<input
type="password"
name="new_password"
class="form-control"
placeholder="••••••••"
required
minlength="6"
>
</div>
<div class="form-group">
<label class="form-label">Подтвердите новый пароль</label>
<input
type="password"
name="new_password_confirm"
class="form-control"
placeholder="••••••••"
required
minlength="6"
>
</div>
<div id="change-password-result" class="mt-3"></div>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-save me-2"></i>
Изменить пароль
</button>
</form>
<div class="mt-4 text-center">
<a href="/" class="btn btn-link">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('.login-form');
const newPassword = form.querySelector('input[name="new_password"]');
const newPasswordConfirm = form.querySelector('input[name="new_password_confirm"]');
// Проверка совпадения паролей
function validatePasswords() {
if (newPassword.value !== newPasswordConfirm.value) {
newPasswordConfirm.setCustomValidity('Пароли не совпадают');
} else {
newPasswordConfirm.setCustomValidity('');
}
}
newPassword.addEventListener('input', validatePasswords);
newPasswordConfirm.addEventListener('input', validatePasswords);
form.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.xhr.status === 200) {
const resultDiv = document.getElementById('change-password-result');
resultDiv.innerHTML = '<div class="alert alert-success mt-3">Пароль успешно изменен</div>';
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
const resultDiv = document.getElementById('change-password-result');
try {
const response = JSON.parse(event.detail.xhr.responseText);
resultDiv.innerHTML = `<div class="alert alert-danger mt-3">${response.detail || 'Ошибка при смене пароля'}</div>`;
} catch (e) {
resultDiv.innerHTML = '<div class="alert alert-danger mt-3">Ошибка при смене пароля</div>';
}
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Вход - DevOpsLab{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-card">
<div class="card-header">
<i class="fas fa-cogs fa-3x mb-3"></i>
</div>
<div class="card-body">
<form
hx-post="/api/v1/auth/login"
hx-target="#login-result"
hx-swap="innerHTML"
class="login-form"
>
<div class="form-group">
<label class="form-label">Имя пользователя</label>
<input
type="text"
name="username"
class="form-control"
placeholder="admin"
required
autofocus
>
</div>
<div class="form-group">
<label class="form-label">Пароль</label>
<input
type="password"
name="password"
class="form-control"
placeholder="••••••••"
required
>
</div>
<div id="login-result" class="mt-3"></div>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-sign-in-alt me-2"></i>
Войти
</button>
</form>
<div class="mt-4 text-center">
<p class="text-muted small">
По умолчанию: <strong>admin</strong> / <strong>admin</strong>
</p>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Проверяем, истекла ли сессия
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('expired') === '1') {
const resultDiv = document.getElementById('login-result');
resultDiv.innerHTML = '<div class="alert alert-warning mt-3"><i class="fas fa-exclamation-triangle me-2"></i>Ваша сессия истекла. Пожалуйста, войдите снова.</div>';
}
const form = document.querySelector('.login-form');
form.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.xhr.status === 200) {
const response = JSON.parse(event.detail.xhr.responseText);
// Токен сохраняется в cookie автоматически сервером
// Перенаправление на главную
window.location.href = '/';
} else {
// Показываем ошибку
const resultDiv = document.getElementById('login-result');
resultDiv.innerHTML = '<div class="alert alert-danger mt-3">Неверное имя пользователя или пароль</div>';
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Dashboard - DevOpsLab{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block content %}
<div class="row g-3 mb-4">
<div class="col-12 col-md-6 col-lg-3">
<div class="card stat-card stat-card-cpu">
<div class="card-body d-flex align-items-center gap-3">
<div class="stat-card-icon">
<i class="fas fa-tasks"></i>
</div>
<div class="stat-card-content">
<div class="stat-card-label">Всего ролей</div>
<div class="stat-card-value"
hx-get="/api/v1/stats/roles"
hx-trigger="load, every 10s">
...
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card stat-card stat-card-ram">
<div class="card-body d-flex align-items-center gap-3">
<div class="stat-card-icon">
<i class="fas fa-vial"></i>
</div>
<div class="stat-card-content">
<div class="stat-card-label">Протестировано</div>
<div class="stat-card-value"
hx-get="/api/v1/stats/tests"
hx-trigger="load, every 10s">
...
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card stat-card stat-card-hdd">
<div class="card-body d-flex align-items-center gap-3">
<div class="stat-card-icon">
<i class="fas fa-check-circle"></i>
</div>
<div class="stat-card-content">
<div class="stat-card-label">Успешных тестов</div>
<div class="stat-card-value text-success"
hx-get="/api/v1/stats/success"
hx-trigger="load, every 10s">
...
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card stat-card stat-card-ssd">
<div class="card-body d-flex align-items-center gap-3">
<div class="stat-card-icon">
<i class="fas fa-cube"></i>
</div>
<div class="stat-card-content">
<div class="stat-card-label">Docker образов</div>
<div class="stat-card-value"
hx-get="/api/v1/stats/docker"
hx-trigger="load, every 10s">
...
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Последние тесты</h5>
</div>
<div class="card-body">
<div id="test-list"
hx-get="/api/v1/tests/recent"
hx-trigger="load, every 10s">
<p class="text-muted">Загрузка...</p>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Быстрые действия</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-4">
<a href="/roles/create" class="btn btn-primary w-100">
<i class="fas fa-plus me-2"></i>
Создать роль
</a>
</div>
<div class="col-12 col-md-4">
<a href="/presets/create" class="btn btn-success w-100">
<i class="fas fa-plus me-2"></i>
Создать preset
</a>
</div>
<div class="col-12 col-md-4">
<a href="/roles/import" class="btn btn-outline-primary w-100">
<i class="fas fa-download me-2"></i>
Импортировать роль
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,273 @@
{% extends "base.html" %}
{% block title %}Деплой - DevOpsLab{% endblock %}
{% block page_title %}Деплой на живые серверы{% endblock %}
{% block header_actions %}
<a href="/deploy/inventory" class="btn btn-secondary btn-sm">
<i class="fas fa-list me-2"></i>
Управление Inventory
</a>
{% endblock %}
{% block content %}
<div x-data="deployManager()">
<!-- Проверка inventory -->
{% if not inventory_exists %}
<div class="alert alert-warning mb-3">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Внимание:</strong> Файл inventory/hosts.ini не найден.
<a href="/deploy/inventory" class="alert-link">Создайте его</a> перед запуском деплоя.
</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">Роль для деплоя</label>
<select
x-model="deployConfig.role"
class="form-select"
>
<option value="all">Все роли</option>
{% for role in roles %}
<option value="{{ role }}">{{ role }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Теги (через запятую, опционально)</label>
<input
type="text"
x-model="deployConfig.tags"
class="form-control"
placeholder="web, database, nginx"
>
</div>
<div class="mb-3">
<label class="form-label">Лимит хостов (опционально)</label>
<input
type="text"
x-model="deployConfig.limit"
class="form-control"
placeholder="webservers или web1,web2"
>
<div class="form-text">
Ограничить выполнение определенными хостами или группами
</div>
</div>
<div class="mb-3">
<label class="form-label">Опции</label>
<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 (проверка без изменений)
</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
x-model="deployConfig.verbose"
id="deploy-verbose"
>
<label class="form-check-label" for="deploy-verbose">
Verbose режим (-vvv)
</label>
</div>
</div>
<button
type="submit"
class="btn btn-success"
:disabled="deployRunning || !inventoryExists"
>
<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>
<!-- Информация о inventory -->
{% if inventory_exists and inventory_data %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Информация о Inventory</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<h6 class="fw-semibold mb-2">Группы</h6>
<ul class="list-unstyled">
{% for group, hosts in inventory_data.groups.items() %}
<li class="mb-1">
<i class="fas fa-server me-2 text-muted"></i>
<strong>{{ group }}</strong>: {{ hosts|length }} хост(ов)
</li>
{% endfor %}
</ul>
</div>
<div class="col-12 col-md-6">
<h6 class="fw-semibold mb-2">Всего хостов</h6>
<p class="display-6 fw-bold">{{ inventory_data.hosts|length }}</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- 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;">
<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 deployManager() {
return {
deployRunning: false,
logs: [],
inventoryExists: {{ 'true' if inventory_exists else 'false' }},
deployConfig: {
role: 'all',
tags: '',
limit: '',
check: false,
verbose: false
},
ws: null,
async startDeploy() {
if (!this.inventoryExists) {
alert('Сначала создайте inventory файл!');
return;
}
this.deployRunning = true;
this.logs = [];
// Формирование deploy_id
const deployId = `deploy-${this.deployConfig.role}-${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.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: 'info',
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}`
});
};
},
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')) 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 = `deploy-${Date.now()}.log`;
a.click();
URL.revokeObjectURL(url);
}
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}Inventory - DevOpsLab{% endblock %}
{% block page_title %}Управление Inventory{% endblock %}
{% block header_actions %}
<a href="/deploy" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад к деплою
</a>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<!-- Информация -->
<div class="alert alert-info mb-3">
<i class="fas fa-info-circle me-2"></i>
<strong>Информация:</strong> Inventory файл определяет серверы для деплоя.
Используйте стандартный формат Ansible inventory (INI или YAML).
</div>
<!-- Редактор inventory -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Редактор inventory/hosts.ini</h5>
</div>
<div class="card-body">
<form
id="inventory-form"
hx-post="/api/v1/deploy/inventory"
hx-target="#result"
hx-swap="innerHTML"
>
<div class="mb-3">
<label class="form-label">Содержимое inventory</label>
<textarea
id="inventory-editor"
name="content"
class="form-control"
rows="20"
placeholder="[webservers]
web1 ansible_host=192.168.1.10 ansible_user=root
web2 ansible_host=192.168.1.11 ansible_user=root
[database]
db1 ansible_host=192.168.1.20 ansible_user=root
[all:vars]
ansible_python_interpreter=/usr/bin/python3"
>{{ inventory_content }}</textarea>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
Поддерживается формат INI и YAML. Редактор автоматически определит формат.
</div>
</div>
<!-- Результат -->
<div id="result" class="mb-3"></div>
<!-- Кнопки -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить Inventory
</button>
<button type="button" class="btn btn-outline-secondary" onclick="validateInventory()">
<i class="fas fa-check me-2"></i>
Проверить синтаксис
</button>
<a href="/deploy" class="btn btn-secondary">
Отмена
</a>
</div>
</form>
</div>
</div>
<!-- Примеры -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Примеры Inventory</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<h6 class="fw-semibold mb-2">Простой пример (INI)</h6>
<pre class="bg-dark text-light p-3 rounded"><code>[webservers]
web1 ansible_host=192.168.1.10
web2 ansible_host=192.168.1.11
[database]
db1 ansible_host=192.168.1.20
[all:vars]
ansible_user=root
ansible_python_interpreter=/usr/bin/python3</code></pre>
</div>
<div class="col-12 col-md-6">
<h6 class="fw-semibold mb-2">С SSH ключами (INI)</h6>
<pre class="bg-dark text-light p-3 rounded"><code>[webservers]
web1 ansible_host=192.168.1.10 ansible_ssh_private_key_file=~/.ssh/id_rsa
web2 ansible_host=192.168.1.11 ansible_ssh_private_key_file=~/.ssh/id_rsa
[database]
db1 ansible_host=192.168.1.20 ansible_ssh_private_key_file=~/.ssh/id_rsa
[all:vars]
ansible_user=ubuntu
ansible_python_interpreter=/usr/bin/python3</code></pre>
</div>
<div class="col-12">
<h6 class="fw-semibold mb-2">YAML формат</h6>
<pre class="bg-dark text-light p-3 rounded"><code>all:
children:
webservers:
hosts:
web1:
ansible_host: 192.168.1.10
web2:
ansible_host: 192.168.1.11
database:
hosts:
db1:
ansible_host: 192.168.1.20
vars:
ansible_user: root
ansible_python_interpreter: /usr/bin/python3</code></pre>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof CodeMirror !== 'undefined') {
const inventoryEditor = CodeMirror.fromTextArea(
document.getElementById('inventory-editor'),
{
mode: 'yaml', // Начинаем с YAML, но можно переключить на INI
theme: 'monokai',
lineNumbers: true,
indentUnit: 2,
lineWrapping: true,
autofocus: true
}
);
// Определяем формат по содержимому
const content = inventoryEditor.getValue();
if (content.trim().startsWith('[') || content.includes('ansible_host=')) {
// Это INI формат
inventoryEditor.setOption('mode', 'ini');
}
// Обновляем textarea перед отправкой формы
document.getElementById('inventory-form').addEventListener('submit', function() {
inventoryEditor.save();
});
// Сохраняем editor в глобальной переменной для доступа из других функций
window.inventoryEditor = inventoryEditor;
}
});
function validateInventory() {
const editor = window.inventoryEditor;
if (!editor) {
alert('Редактор не инициализирован');
return;
}
const content = editor.getValue();
// Простая валидация
if (!content.trim()) {
alert('Inventory пуст');
return;
}
// Проверяем наличие групп
if (content.includes('[') && !content.match(/\[.*\]/)) {
alert('⚠️ Предупреждение: Не найдено групп в формате [group_name]');
return;
}
alert('✅ Синтаксис inventory корректен');
}
</script>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Docker - DevOpsLab{% endblock %}
{% block page_title %}Docker образы{% endblock %}
{% block content %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Docker образы</h5>
</div>
<div class="card-body">
<p class="text-muted">
Управление Docker образами для тестирования Ansible ролей.
</p>
<div id="docker-images-list"
hx-get="/api/v1/docker/images"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-3">
<i class="fas fa-spinner fa-spin me-2"></i>
Загрузка...
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,352 @@
{% extends "base.html" %}
{% block title %}Логи сборок - DevOpsLab{% endblock %}
{% block page_title %}Логи сборок{% endblock %}
{% block header_actions %}
<a href="/dockerfiles" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад к Dockerfile
</a>
{% endblock %}
{% block content %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-history me-2"></i>
Все логи сборок
</h5>
<div>
<span class="badge bg-info">Всего: {{ total }}</span>
</div>
</div>
<div class="card-body p-0">
{% if logs %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Dockerfile</th>
<th>Образ</th>
<th>Тип</th>
<th>Платформы</th>
<th>Статус</th>
<th>Начало</th>
<th>Длительность</th>
<th>Пользователь</th>
<th style="min-width: 120px;">Действия</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td class="align-middle">
{% if log.dockerfile_id in dockerfiles_map %}
<a href="/dockerfiles/{{ log.dockerfile_id }}" class="text-decoration-none">
<i class="fas fa-file-code me-1 text-primary"></i>
{{ dockerfiles_map[log.dockerfile_id].name }}
</a>
{% else %}
<span class="text-muted">ID: {{ log.dockerfile_id }}</span>
{% endif %}
</td>
<td class="align-middle">
<code>{{ log.image_name }}{% if log.tag %}:{{ log.tag }}{% endif %}</code>
</td>
<td class="align-middle">
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
<span class="badge bg-info">
<i class="fas fa-upload me-1"></i>Push
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-hammer me-1"></i>Build
</span>
{% endif %}
</td>
<td class="align-middle">
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
{# Для push показываем registry вместо платформ #}
{% if log.extra_data.get('registry') %}
<span class="badge bg-primary">
<i class="fas fa-server me-1"></i>{{ log.extra_data.get('registry') }}
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
{% elif log.platforms %}
{% for platform in log.platforms %}
<span class="badge bg-secondary me-1">{{ platform }}</span>
{% endfor %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="align-middle">
{% if log.status == "success" %}
<span class="badge bg-success">Успешно</span>
{% elif log.status == "failed" %}
<span class="badge bg-danger">Ошибка</span>
{% else %}
<span class="badge bg-warning">Выполняется</span>
{% endif %}
</td>
<td class="align-middle">
{% if log.started_at %}
<small>{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="align-middle">
{% if log.duration %}
<small>{{ log.duration }} сек</small>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="align-middle">
{% if log.user %}
<small>{{ log.user }}</small>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="align-middle">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-outline-primary"
onclick="showLogDetail({{ log.id }})"
title="Просмотр логов"
>
<i class="fas fa-eye"></i>
</button>
<button
type="button"
class="btn btn-outline-danger"
onclick="deleteLog({{ log.id }})"
title="Удалить лог"
>
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if total_pages > 1 %}
<div class="card-footer">
<nav aria-label="Навигация по страницам">
<ul class="pagination mb-0 justify-content-center">
{% if page > 1 %}
<li class="page-item">
<a class="page-link" href="?page={{ page - 1 }}&per_page={{ per_page }}">Предыдущая</a>
</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 <= 3 or p > total_pages - 3 or (p >= page - 1 and p <= page + 1) %}
<li class="page-item">
<a class="page-link" href="?page={{ p }}&per_page={{ per_page }}">{{ p }}</a>
</li>
{% elif p == 4 or p == total_pages - 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 }}&per_page={{ per_page }}">Следующая</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
{% 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>
</div>
{% endif %}
</div>
</div>
<!-- Модальное окно для просмотра лога -->
<div class="modal fade" id="logDetailModal" tabindex="-1">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Детали лога сборки</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="log-detail-content" class="log-container">
Загрузка...
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function showLogDetail(logId) {
const modalElement = document.getElementById('logDetailModal');
const modal = new bootstrap.Modal(modalElement);
const content = document.getElementById('log-detail-content');
content.textContent = 'Загрузка...';
// Функция для установки высоты
const setHeight = () => {
const modalBody = document.querySelector('#logDetailModal .modal-body');
const modalHeader = document.querySelector('#logDetailModal .modal-header');
if (modalBody && modalHeader) {
const headerHeight = modalHeader.offsetHeight;
const availableHeight = window.innerHeight - headerHeight;
modalBody.style.height = availableHeight + 'px';
modalBody.style.minHeight = availableHeight + 'px';
modalBody.style.maxHeight = availableHeight + 'px';
content.style.height = availableHeight + 'px';
content.style.minHeight = availableHeight + 'px';
content.style.maxHeight = availableHeight + 'px';
}
};
// Устанавливаем высоту при открытии модального окна
modalElement.addEventListener('shown.bs.modal', function onShown() {
setHeight();
window.addEventListener('resize', setHeight);
modalElement.removeEventListener('shown.bs.modal', onShown);
});
// Убираем обработчик resize при закрытии
modalElement.addEventListener('hidden.bs.modal', function onHidden() {
window.removeEventListener('resize', setHeight);
modalElement.removeEventListener('hidden.bs.modal', onHidden);
});
modal.show();
try {
const response = await fetch(`/api/v1/dockerfiles/build-logs/${logId}`);
const data = await response.json();
if (data.logs) {
content.textContent = data.logs;
} else {
content.textContent = 'Логи не найдены';
}
} catch (error) {
content.textContent = `Ошибка загрузки: ${error.message}`;
}
}
async function deleteLog(logId) {
const confirmed = await showConfirmModal('Вы уверены, что хотите удалить этот лог сборки?');
if (!confirmed) {
return;
}
try {
const response = await fetch(`/api/v1/dockerfiles/build-logs/${logId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
// Удаляем элемент из DOM
const logElement = document.querySelector(`[onclick*="deleteLog(${logId})"]`).closest('tr');
if (logElement) {
logElement.remove();
}
} else {
const data = await response.json();
alert(`Ошибка при удалении: ${data.detail || 'Неизвестная ошибка'}`);
}
} catch (error) {
alert(`Ошибка при удалении: ${error.message}`);
}
}
</script>
<style>
/* Стили для модального окна с логами на весь экран */
#logDetailModal .modal-dialog {
margin: 0 !important;
max-width: 100% !important;
width: 100% !important;
height: 100vh !important;
max-height: 100vh !important;
}
#logDetailModal .modal-content {
height: 100vh !important;
max-height: 100vh !important;
margin: 0 !important;
border: none !important;
border-radius: 0 !important;
display: flex !important;
flex-direction: column !important;
}
#logDetailModal .modal-header {
flex-shrink: 0 !important;
padding: 1rem !important;
height: auto !important;
}
#logDetailModal .modal-body {
flex: 1 1 0 !important;
overflow: hidden !important;
padding: 0 !important;
margin: 0 !important;
width: 100% !important;
min-height: 0 !important;
max-height: none !important;
height: auto !important;
display: flex !important;
flex-direction: column !important;
position: relative !important;
}
#log-detail-content {
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100% !important;
height: 100% !important;
min-height: 0 !important;
max-height: none !important;
box-sizing: border-box !important;
overflow: auto !important;
background: #1e1e1e !important;
color: #d4d4d4 !important;
padding: 1rem !important;
font-family: 'Courier New', monospace !important;
font-size: 0.875rem !important;
white-space: pre !important;
word-wrap: normal !important;
overflow-wrap: normal !important;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,243 @@
{% extends "base.html" %}
{% block title %}История сборок - {{ dockerfile.name }} - DevOpsLab{% endblock %}
{% block page_title %}История сборок: {{ dockerfile.name }}{% endblock %}
{% block content %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-history me-2"></i>
История сборок Dockerfile
</h5>
<div>
<button type="button" class="btn btn-danger btn-sm" onclick="clearLogs()">
<i class="fas fa-trash me-2"></i>
Очистить логи
</button>
<a href="/dockerfiles/{{ dockerfile.id }}/edit" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
</div>
</div>
<div class="card-body">
{% if logs %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Образ</th>
<th>Тип</th>
<th>Платформы</th>
<th>Статус</th>
<th>Начало</th>
<th>Длительность</th>
<th>Пользователь</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.id }}</td>
<td>
<code>{{ log.image_name }}{% if log.tag %}:{{ log.tag }}{% endif %}</code>
</td>
<td>
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
<span class="badge bg-info">
<i class="fas fa-upload me-1"></i>Push
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-hammer me-1"></i>Build
</span>
{% endif %}
</td>
<td>
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
{# Для push показываем registry вместо платформ #}
{% if log.extra_data.get('registry') %}
<span class="badge bg-primary">
<i class="fas fa-server me-1"></i>{{ log.extra_data.get('registry') }}
</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
{% elif log.platforms %}
{% for platform in log.platforms %}
<span class="badge bg-info me-1">{{ platform }}</span>
{% endfor %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if log.status == "success" %}
<span class="badge bg-success">Успешно</span>
{% elif log.status == "failed" %}
<span class="badge bg-danger">Ошибка</span>
{% else %}
<span class="badge bg-warning">Выполняется</span>
{% endif %}
</td>
<td>
{% if log.started_at %}
{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if log.duration %}
{{ log.duration }} сек
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ log.user or '-' }}</td>
<td>
<button type="button" class="btn btn-sm btn-primary" onclick="showLogDetail({{ log.id }})">
<i class="fas fa-eye me-1"></i>
Просмотр
</button>
<button type="button" class="btn btn-sm btn-danger" onclick="deleteLog({{ log.id }})">
<i class="fas fa-trash me-1"></i>
Удалить
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if total_pages > 1 %}
<nav aria-label="Навигация по страницам">
<ul class="pagination justify-content-center">
{% if page > 1 %}
<li class="page-item">
<a class="page-link" href="?page={{ page - 1 }}">Предыдущая</a>
</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 <= 3 or p >= total_pages - 2 or (p >= page - 1 and p <= page + 1) %}
<li class="page-item">
<a class="page-link" href="?page={{ p }}">{{ p }}</a>
</li>
{% elif p == 4 or p == total_pages - 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 }}">Следующая</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Логи сборки отсутствуют
</div>
{% endif %}
</div>
</div>
<!-- Модальное окно для просмотра лога -->
<div class="modal fade" id="logDetailModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Детали лога сборки</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="log-detail-content" class="log-container" style="height: 500px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 1rem; font-family: 'Courier New', monospace; font-size: 0.875rem; white-space: pre-wrap;">
Загрузка...
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
<script>
async function showLogDetail(logId) {
const modal = new bootstrap.Modal(document.getElementById('logDetailModal'));
const content = document.getElementById('log-detail-content');
content.textContent = 'Загрузка...';
modal.show();
try {
const response = await fetch(`/api/v1/dockerfiles/build-logs/${logId}`);
const data = await response.json();
if (data.logs) {
content.textContent = data.logs;
} else {
content.textContent = 'Логи не найдены';
}
} catch (error) {
content.textContent = `Ошибка загрузки: ${error.message}`;
}
}
async function deleteLog(logId) {
const confirmed = await showConfirmModal('Вы уверены, что хотите удалить этот лог?');
if (!confirmed) {
return;
}
try {
const response = await fetch(`/api/v1/dockerfiles/build-logs/${logId}`, {
method: 'DELETE'
});
if (response.ok) {
location.reload();
} else {
alert('Ошибка при удалении лога');
}
} catch (error) {
alert(`Ошибка: ${error.message}`);
}
}
async function clearLogs() {
const confirmed = await showConfirmModal('Вы уверены, что хотите очистить все логи сборки для этого Dockerfile?');
if (!confirmed) {
return;
}
try {
const response = await fetch(`/api/v1/dockerfiles/{{ dockerfile.id }}/build-logs`, {
method: 'DELETE'
});
if (response.ok) {
location.reload();
} else {
alert('Ошибка при очистке логов');
}
} catch (error) {
alert(`Ошибка: ${error.message}`);
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,993 @@
{% extends "base.html" %}
{% block title %}Сборка Dockerfile - {{ dockerfile.name }} - DevOpsLab{% endblock %}
{% block page_title %}Сборка Dockerfile: {{ dockerfile.name }}{% endblock %}
{% block content %}
<input type="hidden" id="dockerfile-content-hidden" value="{{ dockerfile.content | e }}">
<div class="row">
<!-- Левая колонка: Форма сборки и текущие логи -->
<div class="col-lg-8">
<!-- Форма сборки -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-hammer me-2"></i>
Параметры сборки
</h5>
</div>
<div class="card-body">
<form id="build-form">
<div class="mb-3">
<label class="form-label">Имя образа (без тега)</label>
<input type="text" id="build-image-name" class="form-control"
value="inecs/ansible-lab:{{ dockerfile.name }}" required>
<div class="form-text">Тег будет добавлен автоматически</div>
</div>
<div class="mb-3">
<label class="form-label">Тег</label>
<input type="text" id="build-tag" class="form-control" value="latest" required>
</div>
<div class="mb-3">
<label class="form-label">Платформы для сборки</label>
<div class="row">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/amd64" id="build-platform-amd64" checked>
<label class="form-check-label" for="build-platform-amd64">
<i class="fab fa-linux me-1"></i>linux/amd64 (x86_64)
</label>
</div>
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/386" id="build-platform-386" checked>
<label class="form-check-label" for="build-platform-386">
<i class="fab fa-linux me-1"></i>linux/386 (x86)
</label>
</div>
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/arm64" id="build-platform-arm64" checked>
<label class="form-check-label" for="build-platform-arm64">
<i class="fab fa-apple me-1"></i>linux/arm64 (macOS M1/M2)
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/arm/v7" id="build-platform-armv7">
<label class="form-check-label" for="build-platform-armv7">
<i class="fas fa-microchip me-1"></i>linux/arm/v7
</label>
</div>
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/riscv64" id="build-platform-riscv64">
<label class="form-check-label" for="build-platform-riscv64">
<i class="fas fa-server me-1"></i>linux/riscv64
</label>
</div>
<div class="form-check">
<input class="form-check-input build-platform-checkbox" type="checkbox" name="build-platforms" value="linux/ppc64le" id="build-platform-ppc64le">
<label class="form-check-label" for="build-platform-ppc64le">
<i class="fas fa-server me-1"></i>linux/ppc64le
</label>
</div>
</div>
</div>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
По умолчанию выбраны: linux/amd64, linux/386, linux/arm64
</div>
<input type="hidden" name="build-platforms-json" id="build-platforms-json-hidden">
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="build-no-cache" name="no_cache">
<label class="form-check-label" for="build-no-cache">
<i class="fas fa-ban me-1"></i>
Сборка без кеша (--no-cache)
</label>
</div>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
При включении сборка будет выполняться без использования кеша Docker
</div>
</div>
<button type="button" class="btn btn-primary" onclick="startBuild()">
<i class="fas fa-hammer me-2"></i>
Начать сборку
</button>
<a href="/dockerfiles/{{ dockerfile.id }}/edit" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
</form>
</div>
</div>
<!-- Логи сборки -->
<div class="card" id="build-logs-card" style="display: none;">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Логи сборки</h5>
<div class="d-flex gap-2">
<button type="button" id="push-image-btn" class="btn btn-sm btn-primary" onclick="startPush()" style="display: none;">
<i class="fas fa-upload me-1"></i>
Отправить образ
</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="clearLogs()">
<i class="fas fa-times me-1"></i>
Очистить
</button>
</div>
</div>
<div class="card-body p-0">
<div class="log-container" id="build-logs" style="height: 500px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 1rem; font-family: 'Courier New', monospace; font-size: 0.875rem;">
<!-- Логи будут добавлены через WebSocket -->
</div>
</div>
</div>
</div>
<!-- Правая колонка: История сборок -->
<div class="col-lg-4">
<div class="card" id="build-history-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-history me-2"></i>
История сборок
</h5>
<a href="/dockerfiles/{{ dockerfile.id }}/build-logs" class="btn btn-sm btn-primary">
<i class="fas fa-list me-1"></i>
Все логи
</a>
</div>
<div class="card-body">
{% if recent_logs %}
<div class="list-group list-group-flush">
{% for log in recent_logs %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1">
<code>{{ log.image_name }}{% if log.tag %}:{{ log.tag }}{% endif %}</code>
</h6>
<small class="text-muted">
{% if log.started_at %}
{{ log.started_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
{% if log.duration %}
• {{ log.duration }} сек
{% endif %}
</small>
<div class="mt-1">
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
<span class="badge bg-info me-1">
<i class="fas fa-upload me-1"></i>Push
</span>
{% else %}
<span class="badge bg-secondary me-1">
<i class="fas fa-hammer me-1"></i>Build
</span>
{% endif %}
{% if log.status == "success" %}
<span class="badge bg-success">Успешно</span>
{% elif log.status == "failed" %}
<span class="badge bg-danger">Ошибка</span>
{% else %}
<span class="badge bg-warning">Выполняется</span>
{% endif %}
</div>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="showLogDetail({{ log.id }})" title="Просмотр логов">
<i class="fas fa-eye"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteLog({{ log.id }})" title="Удалить лог">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info mb-0">
<i class="fas fa-info-circle me-2"></i>
История сборок пуста
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Модальное окно для просмотра лога -->
<div class="modal fade" id="logDetailModal" tabindex="-1">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Детали лога сборки</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="log-detail-content" class="log-container">
Загрузка...
</div>
</div>
</div>
</div>
</div>
<!-- Модальное окно для выбора registry -->
<div class="modal fade" id="pushRegistryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Выбор Registry для отправки образа</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Registry</label>
<select id="push-registry-select" class="form-select">
<option value="docker.io">Docker Hub (hub.docker.com)</option>
<option value="harbor">Harbor</option>
</select>
</div>
<div id="push-registry-info" class="alert alert-info">
<small>Будут использованы настройки из вашего профиля</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" onclick="confirmPush()">
<i class="fas fa-upload me-1"></i>
Отправить
</button>
</div>
</div>
</div>
</div>
<script>
// Сохраненные платформы из БД
const savedPlatforms = {{ dockerfile.platforms | tojson if dockerfile.platforms else '["linux/amd64", "linux/386", "linux/arm64"]' }};
// Загружаем последние логи при загрузке страницы
document.addEventListener('DOMContentLoaded', function() {
loadRecentLogs();
// Устанавливаем высоту после загрузки DOM
});
// Функция для обновления JSON платформ
function updateBuildPlatformsJson() {
const checkboxes = document.querySelectorAll('.build-platform-checkbox:checked');
const platforms = Array.from(checkboxes).map(cb => cb.value);
document.getElementById('build-platforms-json-hidden').value = JSON.stringify(platforms);
return platforms;
}
// Инициализация чекбоксов платформ
document.querySelectorAll('.build-platform-checkbox').forEach(cb => {
cb.addEventListener('change', updateBuildPlatformsJson);
// Устанавливаем checked для сохраненных платформ
if (savedPlatforms.includes(cb.value)) {
cb.checked = true;
}
});
updateBuildPlatformsJson(); // Инициализация
// Функция для запуска сборки
window.startBuild = function() {
const imageName = document.getElementById('build-image-name').value;
const tag = document.getElementById('build-tag').value;
const dockerfileContentHidden = document.getElementById('dockerfile-content-hidden');
const dockerfileContent = dockerfileContentHidden ? dockerfileContentHidden.value : '';
const noCache = document.getElementById('build-no-cache').checked;
// Получаем выбранные платформы из чекбоксов
const platforms = updateBuildPlatformsJson();
if (platforms.length === 0) {
alert('Выберите хотя бы одну платформу для сборки!');
return;
}
// Показываем карточку с логами и прокручиваем к ней
const logsCard = document.getElementById('build-logs-card');
logsCard.style.display = 'block';
logsCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
const logsContainer = document.getElementById('build-logs');
logsContainer.innerHTML = '<div class="text-info">🔗 Подключение к WebSocket...</div>';
// Скрываем кнопку отправки при начале новой сборки
const pushButton = document.getElementById('push-image-btn');
if (pushButton) {
pushButton.style.display = 'none';
}
// Сбрасываем глобальные переменные
currentImageName = null;
currentImageTag = null;
// Подключаемся к WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/dockerfile/build/{{ dockerfile.id }}`);
currentWebSocket = ws;
ws.onopen = () => {
logsContainer.innerHTML = '<div class="text-info">🚀 Запуск сборки...</div>';
ws.send(JSON.stringify({
image_name: imageName,
tag: tag,
platforms: platforms,
dockerfile_content: dockerfileContent,
no_cache: noCache
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
const logLine = document.createElement('div');
logLine.className = 'log-line';
if (data.level === 'error') {
logLine.classList.add('log-error');
} else if (data.level === 'warning') {
logLine.classList.add('log-warning');
} else if (data.level === 'info') {
logLine.classList.add('log-info');
}
logLine.textContent = data.data;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
} else if (data.type === 'info' && data.data) {
// Сохраняем build_log_id и celery_task_id если они есть
if (data.build_log_id) {
currentBuildLogId = data.build_log_id;
}
if (data.celery_task_id) {
currentCeleryTaskId = data.celery_task_id;
}
// Показываем кнопку остановки если сборка запущена
if (data.build_log_id || data.data.includes('запущена в фоне')) {
// Проверяем, не добавлена ли уже кнопка
if (!document.getElementById('stop-build-button-container')) {
const stopButtonContainer = document.createElement('div');
stopButtonContainer.className = 'mt-2';
stopButtonContainer.id = 'stop-build-button-container';
stopButtonContainer.innerHTML = `
<button id="stop-build-btn" class="btn btn-danger btn-sm" onclick="stopBuild()">
<i class="fas fa-stop me-1"></i>
Остановить сборку
</button>
`;
logsContainer.appendChild(stopButtonContainer);
}
}
// Показываем сообщение как обычный лог
const logLine = document.createElement('div');
logLine.className = 'log-line log-info';
logLine.textContent = data.data;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
} else if (data.type === 'complete') {
const logLine = document.createElement('div');
logLine.className = 'log-line log-info';
logLine.textContent = data.data || `✅ Сборка завершена: ${data.status}`;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
ws.close();
currentWebSocket = null;
// Удаляем кнопку остановки
const stopButtonContainer = document.getElementById('stop-build-button-container');
if (stopButtonContainer) {
stopButtonContainer.remove();
}
// Если сборка успешна, показываем кнопку Push в заголовке
if (data.status === 'success' && data.image_name && data.tag) {
currentImageName = data.image_name;
currentImageTag = data.tag;
const pushButton = document.getElementById('push-image-btn');
if (pushButton) {
pushButton.style.display = 'inline-block';
}
}
// Обновляем историю сборок без перезагрузки страницы
loadRecentLogs();
} else if (data.type === 'error') {
const logLine = document.createElement('div');
logLine.className = 'log-line log-error';
logLine.textContent = data.data || '❌ Ошибка при сборке';
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
ws.close();
}
};
ws.onerror = (error) => {
const logLine = document.createElement('div');
logLine.className = 'log-line log-error';
logLine.textContent = `❌ Ошибка подключения: ${error}`;
logsContainer.appendChild(logLine);
};
ws.onclose = () => {
const logLine = document.createElement('div');
logLine.className = 'log-line log-info';
logLine.textContent = '🔌 Соединение закрыто';
logsContainer.appendChild(logLine);
};
};
function clearLogs() {
const logsContainer = document.getElementById('build-logs');
logsContainer.innerHTML = '';
// Скрываем кнопку отправки при очистке логов
const pushButton = document.getElementById('push-image-btn');
if (pushButton) {
pushButton.style.display = 'none';
}
// Сбрасываем глобальные переменные
currentImageName = null;
currentImageTag = null;
}
async function showLogDetail(logId) {
const modalElement = document.getElementById('logDetailModal');
const modal = new bootstrap.Modal(modalElement);
const content = document.getElementById('log-detail-content');
content.textContent = 'Загрузка...';
// Функция для установки высоты
const setHeight = () => {
const modalBody = document.querySelector('#logDetailModal .modal-body');
const modalHeader = document.querySelector('#logDetailModal .modal-header');
if (modalBody && modalHeader) {
const headerHeight = modalHeader.offsetHeight;
const availableHeight = window.innerHeight - headerHeight;
modalBody.style.height = availableHeight + 'px';
modalBody.style.minHeight = availableHeight + 'px';
modalBody.style.maxHeight = availableHeight + 'px';
content.style.height = availableHeight + 'px';
content.style.minHeight = availableHeight + 'px';
content.style.maxHeight = availableHeight + 'px';
}
};
// Устанавливаем высоту при открытии модального окна
modalElement.addEventListener('shown.bs.modal', function onShown() {
setHeight();
// Также устанавливаем при изменении размера окна
window.addEventListener('resize', setHeight);
modalElement.removeEventListener('shown.bs.modal', onShown);
});
// Убираем обработчик resize при закрытии
modalElement.addEventListener('hidden.bs.modal', function onHidden() {
window.removeEventListener('resize', setHeight);
modalElement.removeEventListener('hidden.bs.modal', onHidden);
});
modal.show();
try {
const response = await fetch(`/api/v1/dockerfiles/build-logs/${logId}`);
const data = await response.json();
if (data.logs) {
content.textContent = data.logs;
} else {
content.textContent = 'Логи не найдены';
}
} catch (error) {
content.textContent = `Ошибка загрузки: ${error.message}`;
}
}
// Глобальные переменные для управления сборкой
let currentWebSocket = null;
let currentBuildLogId = null;
let currentCeleryTaskId = null;
let currentImageName = null;
let currentImageTag = null;
// Функция для установки высоты правой колонки равной левой
// Функция для загрузки последних логов сборки
async function loadRecentLogs() {
try {
const response = await fetch(`/api/v1/dockerfiles/{{ dockerfile.id }}/build-logs/recent?limit=5`);
const logs = await response.json();
// Находим контейнер для истории сборок
const cardBody = document.querySelector('#build-history-card .card-body');
if (!cardBody) return;
// Проверяем, есть ли уже список
let logsContainer = cardBody.querySelector('.list-group');
if (logs.length === 0) {
// Если логов нет, показываем сообщение
cardBody.innerHTML = `
<div class="alert alert-info mb-0">
<i class="fas fa-info-circle me-2"></i>
История сборок пуста
</div>
`;
return;
}
// Если списка нет, создаем его
if (!logsContainer) {
cardBody.innerHTML = '<div class="list-group list-group-flush"></div>';
logsContainer = cardBody.querySelector('.list-group');
}
// Очищаем текущий список
logsContainer.innerHTML = '';
// Добавляем логи
logs.forEach(log => {
const logItem = document.createElement('div');
logItem.className = 'list-group-item';
// Определяем тип операции (build или push)
const operationType = log.extra_data && log.extra_data.type === 'push' ? 'push' : 'build';
const operationBadge = operationType === 'push'
? '<span class="badge bg-info me-1"><i class="fas fa-upload me-1"></i>Push</span>'
: '<span class="badge bg-secondary me-1"><i class="fas fa-hammer me-1"></i>Build</span>';
const statusBadge = log.status === 'success'
? '<span class="badge bg-success">Успешно</span>'
: log.status === 'failed'
? '<span class="badge bg-danger">Ошибка</span>'
: '<span class="badge bg-warning">Выполняется</span>';
const startedAt = log.started_at
? new Date(log.started_at).toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: '';
const duration = log.duration ? `${log.duration} сек` : '';
logItem.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1">
<code>${log.image_name}${log.tag ? ':' + log.tag : ''}</code>
</h6>
<small class="text-muted">
${startedAt}${duration}
</small>
<div class="mt-1">
${operationBadge}
${statusBadge}
</div>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="showLogDetail(${log.id})" title="Просмотр логов">
<i class="fas fa-eye"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteLog(${log.id})" title="Удалить лог">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
logsContainer.appendChild(logItem);
});
} catch (error) {
console.error('Ошибка загрузки истории сборок:', error);
}
}
// Функция для остановки сборки
async function stopBuild() {
if (!currentBuildLogId) {
alert('ID сборки не найден');
return;
}
const confirmed = await showConfirmModal('Вы уверены, что хотите остановить сборку?');
if (!confirmed) {
return;
}
try {
const response = await fetch(`/api/v1/dockerfiles/build-logs/${currentBuildLogId}/cancel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
const logsContainer = document.getElementById('build-logs');
const logLine = document.createElement('div');
logLine.className = 'log-line log-warning';
logLine.textContent = '⚠️ Сборка остановлена пользователем';
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
// Удаляем кнопку остановки
const stopButtonContainer = document.getElementById('stop-build-button-container');
if (stopButtonContainer) {
stopButtonContainer.remove();
}
// Закрываем WebSocket
if (currentWebSocket) {
currentWebSocket.close();
currentWebSocket = null;
}
// Обновляем историю
setTimeout(() => {
loadRecentLogs();
}, 1000);
} else {
const errorData = await response.json();
alert(`Ошибка при остановке сборки: ${errorData.detail || 'Неизвестная ошибка'}`);
}
} catch (error) {
alert(`Ошибка при остановке сборки: ${error.message}`);
}
}
// Периодическое обновление истории сборок (каждые 5 секунд)
setInterval(() => {
loadRecentLogs();
}, 5000);
let selectedRegistry = 'docker.io';
function startPush() {
if (!currentImageName || !currentImageTag) {
alert('Информация об образе не найдена. Запустите сборку заново.');
return;
}
// Показываем модальное окно для выбора registry
const modal = new bootstrap.Modal(document.getElementById('pushRegistryModal'));
modal.show();
}
async function confirmPush() {
// Получаем выбранный registry
selectedRegistry = document.getElementById('push-registry-select').value;
// Закрываем модальное окно
const modal = bootstrap.Modal.getInstance(document.getElementById('pushRegistryModal'));
modal.hide();
// Запускаем push
await executePush();
}
async function executePush() {
if (!currentImageName || !currentImageTag) {
alert('Информация об образе не найдена. Запустите сборку заново.');
return;
}
const dockerfileId = {{ dockerfile.id }};
const pushButton = document.getElementById('push-image-btn');
if (pushButton) {
pushButton.disabled = true;
pushButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Отправка...';
}
const logsContainer = document.getElementById('build-logs');
const logLine = document.createElement('div');
logLine.className = 'log-line log-info';
logLine.textContent = `🚀 Запуск отправки образа ${currentImageName}:${currentImageTag} в ${selectedRegistry === 'docker.io' ? 'Docker Hub' : 'Harbor'}...`;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
try {
const response = await fetch(`/api/v1/dockerfiles/${dockerfileId}/push`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
image_name: currentImageName,
tag: currentImageTag,
registry: selectedRegistry
})
});
// Проверяем, успешен ли ответ
if (!response.ok) {
// Пытаемся получить JSON с ошибкой
let errorMessage = 'Неизвестная ошибка';
try {
const errorData = await response.json();
// Обрабатываем разные форматы ошибок
if (Array.isArray(errorData)) {
// Если это массив ошибок (например, от Pydantic)
errorMessage = errorData.map(err => {
if (typeof err === 'string') return err;
if (err.msg) return err.msg;
if (err.message) return err.message;
if (err.detail) return err.detail;
return JSON.stringify(err);
}).join(', ');
} else if (errorData.detail) {
// Стандартный формат FastAPI
if (Array.isArray(errorData.detail)) {
errorMessage = errorData.detail.map(err => {
if (typeof err === 'string') return err;
if (err.msg) return err.msg;
if (err.message) return err.message;
return JSON.stringify(err);
}).join(', ');
} else {
errorMessage = errorData.detail;
}
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
} else {
// Пытаемся извлечь любую строку из объекта
errorMessage = JSON.stringify(errorData);
}
} catch (e) {
// Если не удалось распарсить JSON, используем текст ответа
try {
errorMessage = await response.text() || `HTTP ${response.status}: ${response.statusText}`;
} catch (textError) {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
}
throw new Error(errorMessage);
}
const data = await response.json();
if (data.success) {
const successLine = document.createElement('div');
successLine.className = 'log-line log-success';
successLine.textContent = `${data.message}`;
logsContainer.appendChild(successLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
// Подключаемся к WebSocket для получения логов push
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/api/v1/dockerfiles/${dockerfileId}/push/ws`);
ws.onopen = () => {
// Отправляем данные образа при подключении, включая push_log_id если есть
ws.send(JSON.stringify({
image_name: currentImageName,
tag: currentImageTag,
registry: selectedRegistry,
push_log_id: data.push_log_id // Передаем ID лога из POST ответа
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log') {
const logLine = document.createElement('div');
logLine.className = 'log-line';
logLine.textContent = data.data;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
} else if (data.type === 'status') {
if (data.status === 'success') {
const successLine = document.createElement('div');
successLine.className = 'log-line log-success';
successLine.textContent = '✅ Образ успешно отправлен в registry';
logsContainer.appendChild(successLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
ws.close();
} else if (data.status === 'failed') {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = `❌ Ошибка при отправке: ${data.message || 'Неизвестная ошибка'}`;
logsContainer.appendChild(errorLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
ws.close();
}
}
};
ws.onerror = (error) => {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = `❌ Ошибка подключения WebSocket`;
logsContainer.appendChild(errorLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
};
ws.onclose = () => {
if (pushButton) {
pushButton.disabled = false;
pushButton.innerHTML = '<i class="fas fa-upload me-1"></i>Отправить образ';
}
};
} else {
throw new Error(data.detail || data.message || 'Неизвестная ошибка');
}
} catch (error) {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
// Правильно обрабатываем ошибку
let errorMessage = 'Неизвестная ошибка';
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
} else if (error && typeof error === 'object') {
// Пытаемся извлечь сообщение из объекта
if (error.message) {
errorMessage = error.message;
} else if (error.detail) {
errorMessage = error.detail;
} else if (Array.isArray(error)) {
errorMessage = error.map(err => {
if (typeof err === 'string') return err;
if (err.msg) return err.msg;
if (err.message) return err.message;
return JSON.stringify(err);
}).join(', ');
} else {
// Последняя попытка - преобразовать в строку
try {
errorMessage = JSON.stringify(error);
} catch (e) {
errorMessage = String(error);
}
}
} else {
errorMessage = String(error);
}
errorLine.textContent = `❌ Ошибка при запуске отправки: ${errorMessage}`;
logsContainer.appendChild(errorLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
if (pushButton) {
pushButton.disabled = false;
pushButton.innerHTML = '<i class="fas fa-upload me-1"></i>Отправить образ';
}
}
}
async function deleteLog(logId) {
const confirmed = await showConfirmModal('Вы уверены, что хотите удалить этот лог сборки?');
if (!confirmed) {
return;
}
try {
const response = await fetch(`/api/v1/dockerfiles/build-logs/${logId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
// Обновляем историю вместо удаления из DOM
loadRecentLogs();
} else {
const data = await response.json();
alert(`Ошибка при удалении: ${data.detail || 'Неизвестная ошибка'}`);
}
} catch (error) {
alert(`Ошибка при удалении: ${error.message}`);
}
}
</script>
<style>
.log-line {
margin-bottom: 0.25rem;
word-wrap: break-word;
}
.log-error {
color: #f48771;
}
.log-warning {
color: #dcdcaa;
}
.log-info {
color: #4ec9b0;
}
/* Стили для модального окна с логами на весь экран */
#logDetailModal .modal-dialog {
margin: 0 !important;
max-width: 100% !important;
width: 100% !important;
height: 100vh !important;
max-height: 100vh !important;
}
#logDetailModal .modal-content {
height: 100vh !important;
max-height: 100vh !important;
margin: 0 !important;
border: none !important;
border-radius: 0 !important;
display: flex !important;
flex-direction: column !important;
}
#logDetailModal .modal-header {
flex-shrink: 0 !important;
padding: 1rem !important;
height: auto !important;
}
#logDetailModal .modal-body {
flex: 1 1 0 !important;
overflow: hidden !important;
padding: 0 !important;
margin: 0 !important;
width: 100% !important;
min-height: 0 !important;
max-height: none !important;
height: auto !important;
display: flex !important;
flex-direction: column !important;
position: relative !important;
}
#log-detail-content {
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100% !important;
height: 100% !important;
min-height: 0 !important;
max-height: none !important;
box-sizing: border-box !important;
overflow: auto !important;
background: #1e1e1e !important;
color: #d4d4d4 !important;
padding: 1rem !important;
font-family: 'Courier New', monospace !important;
font-size: 0.875rem !important;
white-space: pre !important;
word-wrap: normal !important;
overflow-wrap: normal !important;
}
/* Дополнительные стили для гарантии полной высоты */
#logDetailModal.show .modal-body,
#logDetailModal.showing .modal-body {
height: calc(100vh - 120px) !important;
min-height: calc(100vh - 120px) !important;
max-height: calc(100vh - 120px) !important;
}
#logDetailModal.show #log-detail-content,
#logDetailModal.showing #log-detail-content {
height: calc(100vh - 120px) !important;
min-height: calc(100vh - 120px) !important;
max-height: calc(100vh - 120px) !important;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block title %}Создать Dockerfile - DevOpsLab{% endblock %}
{% block page_title %}Создать Dockerfile{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
<form
hx-post="/api/v1/dockerfiles"
hx-swap="none"
id="dockerfile-form"
>
<div class="mb-3">
<label class="form-label">Название</label>
<input
type="text"
name="name"
class="form-control"
placeholder="ubuntu22"
required
pattern="[a-z0-9-]+"
title="Только строчные буквы, цифры и дефисы"
>
<div class="form-text">
Только строчные буквы, цифры и дефисы
</div>
</div>
<div class="mb-3">
<label class="form-label">Базовый образ</label>
<input
type="text"
name="base_image"
class="form-control"
placeholder="ubuntu:22.04"
>
<div class="form-text">
Базовый образ (например, ubuntu:22.04)
</div>
</div>
<div class="mb-3">
<label class="form-label">Теги</label>
<input
type="text"
name="tags"
class="form-control"
placeholder="latest, v1.0, stable"
>
<div class="form-text">
Теги образа через запятую (например: latest, v1.0, stable)
</div>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea
name="description"
class="form-control"
rows="3"
placeholder="Описание Dockerfile..."
></textarea>
</div>
<div class="mb-3">
<label class="form-label">Dockerfile</label>
<textarea
id="dockerfile-content-editor"
class="form-control font-monospace"
rows="35"
placeholder="# Dockerfile
FROM ubuntu:22.04
# Установка пакетов
RUN apt-get update && apt-get install -y ..."
></textarea>
<input type="hidden" name="content" id="dockerfile-content-hidden">
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
Содержимое Dockerfile. Подсветка синтаксиса включена.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Создать Dockerfile
</button>
<a href="/dockerfiles" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>
Отмена
</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация редактора Dockerfile
if (typeof CodeEditor !== 'undefined') {
const dockerfileEditor = CodeEditor.init('dockerfile-content-editor', 'dockerfile', {
theme: 'monokai',
lineNumbers: true,
foldGutter: true
});
if (dockerfileEditor) {
dockerfileEditor.on('change', function() {
const content = dockerfileEditor.getValue();
// Сохраняем в hidden поле для отправки формы
document.getElementById('dockerfile-content-hidden').value = content;
});
}
}
// Обработка формы
const form = document.getElementById('dockerfile-form');
form.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.xhr.status === 200) {
const response = JSON.parse(event.detail.xhr.responseText);
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
response.message || 'Dockerfile создан успешно',
'success',
'Успешно',
function() {
// После закрытия модального окна перенаправляем на страницу деталей
window.location.href = `/dockerfiles/${response.id}`;
}
);
} else {
// Если функция недоступна, просто перенаправляем
window.location.href = `/dockerfiles/${response.id}`;
}
} else {
// Ошибка - показываем в модальном окне
try {
const response = JSON.parse(event.detail.xhr.responseText);
const errorMessage = response.detail || response.message || 'Ошибка при создании Dockerfile';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при создании Dockerfile', 'error');
} else {
alert('Ошибка при создании Dockerfile');
}
}
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,186 @@
{% extends "base.html" %}
{% block title %}Dockerfile: {{ dockerfile.name }} - DevOpsLab{% endblock %}
{% block page_title %}Dockerfile: {{ dockerfile.name }}{% endblock %}
{% block header_actions %}
<div class="d-flex flex-wrap gap-2">
<a href="/dockerfiles/{{ dockerfile.id }}/edit" class="btn btn-primary btn-sm">
<i class="fas fa-edit me-2"></i>
Редактировать
</a>
<a href="/dockerfiles" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
</div>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Информация</h5>
</div>
<div class="card-body">
{% if dockerfile.description %}
<p class="mb-3">{{ dockerfile.description }}</p>
{% endif %}
{% if dockerfile.base_image %}
<div class="mb-2">
<strong>Базовый образ:</strong> {{ dockerfile.base_image }}
</div>
{% endif %}
{% if dockerfile.tags %}
<div class="mb-2">
<strong>Теги:</strong>
{% for tag in dockerfile.tags %}
<span class="badge bg-info me-1">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<div class="mb-2">
<strong>Статус:</strong>
<span class="badge {% if dockerfile.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ dockerfile.status }}
</span>
</div>
<div class="mb-2">
<strong>Создан:</strong> {{ dockerfile.created_at.strftime('%d.%m.%Y %H:%M') if dockerfile.created_at else 'N/A' }}
</div>
{% if dockerfile.updated_at %}
<div class="mb-2">
<strong>Обновлен:</strong> {{ dockerfile.updated_at.strftime('%d.%m.%Y %H:%M') }}
</div>
{% endif %}
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Dockerfile</h5>
</div>
<div class="card-body p-0">
<div class="dockerfile-code-wrapper">
<pre class="bg-light p-3 rounded mb-0"><code>{{ dockerfile.content }}</code></pre>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">История сборок</h5>
<a href="/dockerfiles/{{ dockerfile.id }}/build-logs" class="btn btn-sm btn-outline-primary">
<i class="fas fa-list me-1"></i>
Все логи
</a>
</div>
<div class="card-body p-0">
{% if build_logs %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Образ</th>
<th>Тип</th>
<th>Платформы</th>
<th>Статус</th>
<th>Начало</th>
<th>Длительность</th>
<th>Пользователь</th>
<th style="min-width: 100px;">Действия</th>
</tr>
</thead>
<tbody>
{% for log in build_logs %}
<tr>
<td class="align-middle">
<code>{{ log.image_name }}{% if log.tag %}:{{ log.tag }}{% endif %}</code>
</td>
<td class="align-middle">
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
<span class="badge bg-info">
<i class="fas fa-upload me-1"></i>Push
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-hammer me-1"></i>Build
</span>
{% endif %}
</td>
<td class="align-middle">
{% if log.extra_data and log.extra_data.get('type') == 'push' %}
{# Для push показываем registry вместо платформ #}
{% if log.extra_data.get('registry') %}
<span class="badge bg-primary">
<i class="fas fa-server me-1"></i>{{ log.extra_data.get('registry') }}
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
{% elif log.platforms %}
{% for platform in log.platforms %}
<span class="badge bg-secondary me-1">{{ platform }}</span>
{% endfor %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="align-middle">
{% if log.status == "success" %}
<span class="badge bg-success">Успешно</span>
{% elif log.status == "failed" %}
<span class="badge bg-danger">Ошибка</span>
{% else %}
<span class="badge bg-warning">Выполняется</span>
{% endif %}
</td>
<td class="align-middle">
{% if log.started_at %}
{{ log.started_at.strftime('%d.%m.%Y %H:%M') }}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="align-middle">
{% if log.duration %}
{{ log.duration }} сек
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="align-middle">
{% if log.user %}
{{ log.user }}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="align-middle">
<div class="btn-group btn-group-sm">
<a href="/dockerfiles/{{ dockerfile.id }}/build-logs?log_id={{ log.id }}" class="btn btn-outline-primary" title="Просмотр логов">
<i class="fas fa-eye"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info mb-0 m-3">
<i class="fas fa-info-circle me-2"></i>
История сборок пуста
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}Редактировать Dockerfile - DevOpsLab{% endblock %}
{% block page_title %}Редактировать Dockerfile: {{ dockerfile.name }}{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
<form
hx-put="/api/v1/dockerfiles/{{ dockerfile.id }}"
hx-swap="none"
>
<div class="mb-3">
<label class="form-label">Название</label>
<input
type="text"
name="name"
class="form-control"
value="{{ dockerfile.name }}"
required
>
</div>
<div class="mb-3">
<label class="form-label">Базовый образ</label>
<input
type="text"
name="base_image"
class="form-control"
value="{{ dockerfile.base_image or '' }}"
placeholder="ubuntu:22.04"
>
<div class="form-text">
Базовый образ (например, ubuntu:22.04)
</div>
</div>
<div class="mb-3">
<label class="form-label">Теги</label>
<input
type="text"
name="tags"
class="form-control"
value="{% if dockerfile.tags %}{{ dockerfile.tags | join(', ') }}{% endif %}"
placeholder="latest, v1.0, stable"
>
<div class="form-text">
Теги образа через запятую (например: latest, v1.0, stable)
</div>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea
name="description"
class="form-control"
rows="3"
>{{ dockerfile.description or '' }}</textarea>
</div>
<div class="mb-3">
<label class="form-label">Dockerfile</label>
<textarea
id="dockerfile-content-editor"
class="form-control font-monospace"
rows="35"
>{{ dockerfile.content }}</textarea>
<input type="hidden" name="content" id="dockerfile-content-hidden">
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
Редактируйте содержимое Dockerfile. Подсветка синтаксиса включена.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить
</button>
<a href="/dockerfiles/{{ dockerfile.id }}/build" class="btn btn-success">
<i class="fas fa-hammer me-2"></i>
Сборка
</a>
<a href="/dockerfiles/{{ dockerfile.id }}/build-logs" class="btn btn-outline-primary">
<i class="fas fa-history me-2"></i>
История сборок
</a>
<a href="/dockerfiles/{{ dockerfile.id }}" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>
Отмена
</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация редактора Dockerfile
if (typeof CodeEditor !== 'undefined') {
const dockerfileEditor = CodeEditor.init('dockerfile-content-editor', 'dockerfile', {
theme: 'monokai',
lineNumbers: true,
foldGutter: true
});
if (dockerfileEditor) {
dockerfileEditor.on('change', function() {
const content = dockerfileEditor.getValue();
// Сохраняем в hidden поле для отправки формы
document.getElementById('dockerfile-content-hidden').value = content;
});
}
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}Dockerfile - DevOpsLab{% endblock %}
{% block page_title %}Dockerfile{% endblock %}
{% block header_actions %}
<div class="d-flex gap-2">
<a href="/dockerfiles/create" class="btn btn-primary btn-sm">
<i class="fas fa-plus me-2"></i>
Создать Dockerfile
</a>
<a href="/dockerfiles/build-logs" class="btn btn-outline-primary btn-sm">
<i class="fas fa-history me-2"></i>
Логи сборок
</a>
</div>
{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">Список Dockerfile</h5>
</div>
<div class="card-body p-0">
{% if dockerfiles %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Описание</th>
<th>Базовый образ</th>
<th>Теги</th>
<th>Статус</th>
<th style="min-width: 140px;">Действия</th>
</tr>
</thead>
<tbody>
{% for dockerfile in dockerfiles %}
<tr>
<td class="align-middle">
<i class="fas fa-file-code me-2 text-primary"></i>
<a href="/dockerfiles/{{ dockerfile.id }}" class="text-decoration-none fw-semibold">
{{ dockerfile.name }}
</a>
</td>
<td class="align-middle">
{% if dockerfile.description %}
<span class="text-truncate d-inline-block" style="max-width: 300px;" title="{{ dockerfile.description }}">
{{ dockerfile.description }}
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="align-middle">
{% if dockerfile.base_image %}
<span class="badge bg-info">{{ dockerfile.base_image }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="align-middle">
{% if dockerfile.tags %}
{% for tag in dockerfile.tags %}
<span class="badge bg-secondary me-1">{{ tag }}</span>
{% endfor %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="align-middle">
<span class="badge {% if dockerfile.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ dockerfile.status }}
</span>
</td>
<td class="align-middle">
<div class="btn-group btn-group-sm">
<a
href="/dockerfiles/{{ dockerfile.id }}/edit"
class="btn btn-outline-primary"
title="Редактировать"
>
<i class="fas fa-edit"></i>
</a>
<a
href="/dockerfiles/{{ dockerfile.id }}"
class="btn btn-outline-info"
title="Детали"
>
<i class="fas fa-info-circle"></i>
</a>
<button
hx-delete="/api/v1/dockerfiles/{{ dockerfile.id }}"
hx-confirm="Удалить Dockerfile '{{ dockerfile.name }}'?"
hx-target="closest tr"
hx-swap="outerHTML"
class="btn btn-outline-danger"
title="Удалить"
>
<i class="fas fa-trash"></i>
</button>
</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">Dockerfile'ов пока нет</p>
<a href="/dockerfiles/create" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>
Создать первый Dockerfile
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% set hide_sidebar = True %}
{% block title %}401 - Требуется авторизация - DevOpsLab{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="text-center">
<div class="error-code mb-4">
<i class="fas fa-user-lock fa-5x text-info mb-3"></i>
<h1 class="display-1 fw-bold text-info">401</h1>
</div>
<h2 class="h3 mb-3">Требуется авторизация</h2>
<p class="text-muted mb-4">
Для доступа к этой странице необходимо войти в систему.
</p>
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle me-2"></i>
Пожалуйста, войдите в систему, чтобы продолжить.
</div>
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="/login" class="btn btn-primary">
<i class="fas fa-sign-in-alt me-2"></i>
Войти
</a>
<a href="/" class="btn btn-outline-secondary">
<i class="fas fa-home me-2"></i>
На главную
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% set hide_sidebar = True %}
{% block title %}403 - Доступ запрещен - DevOpsLab{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="text-center">
<div class="error-code mb-4">
<i class="fas fa-lock fa-5x text-warning mb-3"></i>
<h1 class="display-1 fw-bold text-warning">403</h1>
</div>
<h2 class="h3 mb-3">Доступ запрещен</h2>
<p class="text-muted mb-4">
У вас нет прав для доступа к этому ресурсу.
</p>
<div class="alert alert-warning mb-4">
<i class="fas fa-exclamation-triangle me-2"></i>
Для доступа к этой странице требуются дополнительные права. Обратитесь к администратору.
</div>
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>
На главную
</a>
<button onclick="history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>
Назад
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% set hide_sidebar = True %}
{% block title %}404 - Страница не найдена - DevOpsLab{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="text-center">
<div class="error-code mb-4">
<i class="fas fa-search fa-5x text-muted mb-3"></i>
<h1 class="display-1 fw-bold text-primary">404</h1>
</div>
<h2 class="h3 mb-3">Страница не найдена</h2>
<p class="text-muted mb-4">
Запрашиваемая страница не существует или была перемещена.
</p>
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle me-2"></i>
Проверьте правильность URL или вернитесь на главную страницу.
</div>
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>
На главную
</a>
<button onclick="history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>
Назад
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% set hide_sidebar = True %}
{% block title %}500 - Внутренняя ошибка сервера - DevOpsLab{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="text-center">
<div class="error-code mb-4">
<i class="fas fa-exclamation-triangle fa-5x text-danger mb-3"></i>
<h1 class="display-1 fw-bold text-danger">500</h1>
</div>
<h2 class="h3 mb-3">Внутренняя ошибка сервера</h2>
<p class="text-muted mb-4">
Произошла непредвиденная ошибка при обработке вашего запроса.
</p>
<div class="alert alert-danger mb-4">
<i class="fas fa-bug me-2"></i>
Мы уже работаем над устранением проблемы. Пожалуйста, попробуйте позже.
</div>
{% if error_detail %}
<div class="alert alert-secondary mb-4 text-start">
<small><strong>Детали ошибки:</strong><br>{{ error_detail }}</small>
</div>
{% endif %}
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>
На главную
</a>
<button onclick="location.reload()" class="btn btn-outline-primary">
<i class="fas fa-redo me-2"></i>
Обновить страницу
</button>
<button onclick="history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>
Назад
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% set hide_sidebar = True %}
{% block title %}502 - Ошибка шлюза - DevOpsLab{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="text-center">
<div class="error-code mb-4">
<i class="fas fa-server fa-5x text-danger mb-3"></i>
<h1 class="display-1 fw-bold text-danger">502</h1>
</div>
<h2 class="h3 mb-3">Ошибка шлюза</h2>
<p class="text-muted mb-4">
Сервер получил неверный ответ от вышестоящего сервера.
</p>
<div class="alert alert-warning mb-4">
<i class="fas fa-exclamation-triangle me-2"></i>
Возможно, один из сервисов временно недоступен. Попробуйте обновить страницу через несколько секунд.
</div>
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>
На главную
</a>
<button onclick="location.reload()" class="btn btn-outline-primary">
<i class="fas fa-redo me-2"></i>
Обновить страницу
</button>
<button onclick="history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>
Назад
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% set hide_sidebar = True %}
{% block title %}503 - Сервис недоступен - DevOpsLab{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="text-center">
<div class="error-code mb-4">
<i class="fas fa-tools fa-5x text-warning mb-3"></i>
<h1 class="display-1 fw-bold text-warning">503</h1>
</div>
<h2 class="h3 mb-3">Сервис недоступен</h2>
<p class="text-muted mb-4">
Сервис временно недоступен из-за технического обслуживания или перегрузки.
</p>
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle me-2"></i>
Мы проводим техническое обслуживание. Пожалуйста, попробуйте позже.
</div>
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>
На главную
</a>
<button onclick="location.reload()" class="btn btn-outline-primary">
<i class="fas fa-redo me-2"></i>
Обновить страницу
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}{{ error_code }} - {{ error_title }} - DevOpsLab{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="text-center">
<div class="error-code mb-4">
<h1 class="display-1 fw-bold text-primary">{{ error_code }}</h1>
</div>
<h2 class="h3 mb-3">{{ error_title }}</h2>
{% if error_message %}
<p class="text-muted mb-4">{{ error_message }}</p>
{% endif %}
{% if error_description %}
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle me-2"></i>
{{ error_description }}
</div>
{% endif %}
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>
На главную
</a>
<button onclick="history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>
Назад
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Kubernetes - DevOpsLab{% endblock %}
{% block page_title %}Kubernetes кластеры{% endblock %}
{% block content %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Kubernetes кластеры</h5>
</div>
<div class="card-body">
<p class="text-muted">
Управление локальными Kubernetes кластерами через Kind.
</p>
<div id="k8s-clusters-list"
hx-get="/api/v1/k8s/clusters"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="text-center py-3">
<i class="fas fa-spinner fa-spin me-2"></i>
Загрузка...
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}Линтинг - DevOpsLab{% endblock %}
{% block page_title %}Проверка синтаксиса ролей{% endblock %}
{% block header_actions %}
<button type="button" class="btn btn-primary btn-sm" onclick="startLint('all')">
<i class="fas fa-check-circle me-2"></i>
Проверить все роли
</button>
{% endblock %}
{% block content %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Выбор роли для проверки</h5>
</div>
<div class="card-body">
{% if roles %}
<div class="row g-3">
{% for role in roles %}
<div class="col-12 col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-body">
<h6 class="card-title">
<i class="fas fa-cube me-2"></i>
{{ role.name }}
</h6>
<a href="/roles/{{ role.name }}/lint" class="btn btn-primary btn-sm w-100">
<i class="fas fa-check-circle me-2"></i>
Проверить
</a>
</div>
</div>
</div>
{% endfor %}
</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>
</div>
<!-- Логи линтинга -->
<div class="card" id="lint-logs-card" style="display: none;">
<div class="card-header">
<h5 class="mb-0">Логи проверки</h5>
</div>
<div class="card-body p-0">
<div class="log-container" id="lint-logs" style="height: 500px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 1rem; font-family: 'Courier New', monospace; font-size: 0.875rem;">
<!-- Логи будут добавлены через WebSocket -->
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function startLint(roleName) {
const logsCard = document.getElementById('lint-logs-card');
const logsContainer = document.getElementById('lint-logs');
// Показываем контейнер логов
logsCard.style.display = 'block';
logsContainer.innerHTML = '';
// Создаем WebSocket подключение
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/lint/${roleName}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
const logLine = document.createElement('div');
logLine.className = `log-line log-${data.level || 'info'}`;
logLine.textContent = data.data;
logsContainer.appendChild(logLine);
// Автоскролл
logsContainer.scrollTop = logsContainer.scrollHeight;
} else if (data.type === 'error') {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = data.data;
logsContainer.appendChild(errorLine);
} else if (data.type === 'complete') {
const completeLine = document.createElement('div');
completeLine.className = 'log-line log-info';
completeLine.textContent = data.data || '✅ Проверка завершена';
logsContainer.appendChild(completeLine);
ws.close();
}
};
ws.onerror = (error) => {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = `❌ Ошибка подключения: ${error}`;
logsContainer.appendChild(errorLine);
};
ws.onclose = () => {
console.log('WebSocket закрыт');
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Линтинг {{ role_name }} - DevOpsLab{% endblock %}
{% block page_title %}Проверка синтаксиса: {{ role_name }}{% endblock %}
{% block header_actions %}
<a href="/lint" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
<button type="button" class="btn btn-primary btn-sm" onclick="startLint()">
<i class="fas fa-check-circle me-2"></i>
Запустить проверку
</button>
{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">Логи проверки</h5>
</div>
<div class="card-body p-0">
<div class="log-container" id="lint-logs" style="height: 600px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 1rem; font-family: 'Courier New', monospace; font-size: 0.875rem;">
<div class="text-muted text-center py-5">
Нажмите "Запустить проверку" для начала
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function startLint() {
const logsContainer = document.getElementById('lint-logs');
logsContainer.innerHTML = '';
// Создаем WebSocket подключение
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/lint/{{ role_name }}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
const logLine = document.createElement('div');
logLine.className = `log-line log-${data.level || 'info'}`;
logLine.textContent = data.data;
logsContainer.appendChild(logLine);
// Автоскролл
logsContainer.scrollTop = logsContainer.scrollHeight;
} else if (data.type === 'error') {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = data.data;
logsContainer.appendChild(errorLine);
} else if (data.type === 'complete') {
const completeLine = document.createElement('div');
completeLine.className = 'log-line log-info';
completeLine.textContent = data.data || '✅ Проверка завершена';
logsContainer.appendChild(completeLine);
ws.close();
}
};
ws.onerror = (error) => {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = `❌ Ошибка подключения: ${error}`;
logsContainer.appendChild(errorLine);
};
ws.onclose = () => {
console.log('WebSocket закрыт');
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,176 @@
{% extends "base.html" %}
{% block title %}Создать Playbook - DevOpsLab{% endblock %}
{% block page_title %}Создать Playbook{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
<form
hx-post="/api/v1/playbooks"
hx-target="#playbook-result"
hx-swap="innerHTML"
id="playbook-form"
>
<div class="mb-3">
<label class="form-label">Название playbook</label>
<input
type="text"
name="name"
class="form-control"
placeholder="my-playbook"
required
pattern="[a-z0-9-]+"
title="Только строчные буквы, цифры и дефисы"
>
<div class="form-text">
Только строчные буквы, цифры и дефисы
</div>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea
name="description"
class="form-control"
rows="3"
placeholder="Описание playbook..."
></textarea>
</div>
<div class="mb-3">
<label class="form-label">Роли</label>
<div class="form-text mb-2">
Выберите роли для включения в playbook
</div>
<div class="row g-2">
{% for role in roles %}
<div class="col-12 col-md-6 col-lg-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="roles"
value="{{ role }}"
id="role-{{ role }}"
>
<label class="form-check-label" for="role-{{ role }}">
{{ role }}
</label>
</div>
</div>
{% endfor %}
</div>
{% if not roles %}
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
Роли не найдены. Создайте роли перед созданием playbook.
</div>
{% endif %}
</div>
<div class="mb-3">
<label class="form-label">Переменные (YAML)</label>
<textarea
name="variables"
id="variables-editor"
class="form-control font-monospace"
rows="10"
placeholder="vars:
key1: value1
key2: value2"
></textarea>
<div class="form-text">
Переменные в формате YAML (опционально)
</div>
</div>
<div class="mb-3">
<label class="form-label">Инвентарь (YAML)</label>
<textarea
name="inventory"
id="inventory-editor"
class="form-control font-monospace"
rows="10"
placeholder="all:
hosts:
host1:
ansible_host: 192.168.1.10
host2:
ansible_host: 192.168.1.11"
></textarea>
<div class="form-text">
Инвентарь в формате YAML (опционально)
</div>
</div>
<div id="playbook-result"></div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Создать playbook
</button>
<a href="/playbooks" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>
Отмена
</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация редакторов
if (typeof CodeEditor !== 'undefined') {
const varsEditor = CodeEditor.init('variables-editor', 'yaml');
const invEditor = CodeEditor.init('inventory-editor', 'yaml');
// Валидация при изменении
if (varsEditor) {
varsEditor.on('change', function() {
const content = varsEditor.getValue();
if (content.trim()) {
const validation = CodeEditor.validateYAML(content);
if (!validation.valid) {
CodeEditor.showErrors(varsEditor, validation.errors);
}
}
});
}
if (invEditor) {
invEditor.on('change', function() {
const content = invEditor.getValue();
if (content.trim()) {
const validation = CodeEditor.validateYAML(content);
if (!validation.valid) {
CodeEditor.showErrors(invEditor, validation.errors);
}
}
});
}
}
// Обработка формы
const form = document.getElementById('playbook-form');
form.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.xhr.status === 200) {
const response = JSON.parse(event.detail.xhr.responseText);
window.location.href = `/playbooks/${response.id}`;
} else {
const resultDiv = document.getElementById('playbook-result');
try {
const response = JSON.parse(event.detail.xhr.responseText);
resultDiv.innerHTML = `<div class="alert alert-danger mt-3">${response.detail || 'Ошибка при создании playbook'}</div>`;
} catch (e) {
resultDiv.innerHTML = '<div class="alert alert-danger mt-3">Ошибка при создании playbook</div>';
}
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,431 @@
{% extends "base.html" %}
{% block title %}Playbook: {{ playbook.name }} - DevOpsLab{% endblock %}
{% block page_title %}Playbook: {{ playbook.name }}{% endblock %}
{% block header_actions %}
<a href="/playbooks/{{ playbook.id }}/edit" class="btn btn-primary btn-sm me-2">
<i class="fas fa-edit me-2"></i>
Редактировать
</a>
<a href="/playbooks" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
{% endblock %}
{% block content %}
<div x-data="playbookManager({{ playbook.id }})">
<div class="row">
<div class="col-12 col-lg-8">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Информация</h5>
</div>
<div class="card-body">
{% if playbook.description %}
<p class="mb-3">{{ playbook.description }}</p>
{% endif %}
<div class="mb-2">
<strong>Роли:</strong>
{% for role in playbook.roles %}
<span class="badge bg-info me-1">{{ role }}</span>
{% endfor %}
</div>
<div class="mb-2">
<strong>Статус:</strong>
<span class="badge {% if playbook.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ playbook.status }}
</span>
</div>
<div class="mb-2">
<strong>Создан:</strong> {{ playbook.created_at.strftime('%d.%m.%Y %H:%M') if playbook.created_at else 'N/A' }}
</div>
{% if playbook.updated_at %}
<div class="mb-2">
<strong>Обновлен:</strong> {{ playbook.updated_at.strftime('%d.%m.%Y %H:%M') }}
</div>
{% endif %}
</div>
</div>
<!-- Действия -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Действия</h5>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-12 col-md-6">
<div class="mb-3">
<label class="form-label">Preset для тестирования</label>
<select x-model="testPreset" class="form-select">
<option value="default">default</option>
<option value="minimal">minimal</option>
<option value="all-images">all-images</option>
</select>
</div>
<button
@click="startTest"
:disabled="testRunning || deployRunning"
class="btn btn-success w-100"
>
<i class="fas fa-vial me-2" x-show="!testRunning"></i>
<i class="fas fa-spinner fa-spin me-2" x-show="testRunning"></i>
<span x-show="!testRunning">Запустить тест</span>
<span x-show="testRunning">Тест выполняется...</span>
</button>
</div>
<div class="col-12 col-md-6">
<div class="mb-3">
<label class="form-label">Inventory для деплоя</label>
<input
type="text"
x-model="deployInventory"
class="form-control"
placeholder="inventory/hosts.ini или путь к файлу"
:value="'{{ playbook.inventory or '' }}'"
>
</div>
<button
@click="startDeploy"
:disabled="testRunning || deployRunning"
class="btn btn-warning w-100"
>
<i class="fas fa-rocket me-2" x-show="!deployRunning"></i>
<i class="fas fa-spinner fa-spin me-2" x-show="deployRunning"></i>
<span x-show="!deployRunning">Запустить деплой</span>
<span x-show="deployRunning">Деплой выполняется...</span>
</button>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Playbook (YAML)</h5>
</div>
<div class="card-body">
<textarea id="playbook-content" class="form-control" rows="15" readonly>{{ playbook.content }}</textarea>
</div>
</div>
{% if playbook.inventory %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Инвентарь</h5>
<button
@click="editInventory = !editInventory"
class="btn btn-sm btn-outline-primary"
>
<i class="fas fa-edit me-1"></i>
<span x-show="!editInventory">Редактировать</span>
<span x-show="editInventory">Отменить</span>
</button>
</div>
<div class="card-body">
<textarea
id="inventory-content"
class="form-control"
rows="10"
:readonly="!editInventory"
x-text="inventoryContent"
>{{ playbook.inventory }}</textarea>
<div class="mt-2" x-show="editInventory">
<button
@click="saveInventory"
class="btn btn-primary btn-sm"
>
<i class="fas fa-save me-1"></i>
Сохранить
</button>
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-12 col-lg-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Последние тесты</h5>
</div>
<div class="card-body">
{% if test_runs %}
<div class="list-group">
{% for test_run in test_runs %}
<div class="list-group-item">
<div class="d-flex justify-content-between">
<span>
<i class="fas fa-vial me-2"></i>
{{ test_run.preset_name or 'default' }}
</span>
<span class="badge {% if test_run.status == 'success' %}bg-success{% elif test_run.status == 'failed' %}bg-danger{% else %}bg-warning{% endif %}">
{{ test_run.status }}
</span>
</div>
<small class="text-muted">
{{ test_run.started_at.strftime('%d.%m.%Y %H:%M') if test_run.started_at else 'N/A' }}
</small>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0">Тесты еще не запускались</p>
{% endif %}
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Последние деплои</h5>
</div>
<div class="card-body">
{% if deployments %}
<div class="list-group">
{% for deployment in deployments %}
<div class="list-group-item">
<div class="d-flex justify-content-between">
<span>
<i class="fas fa-rocket me-2"></i>
Деплой #{{ deployment.id }}
</span>
<span class="badge {% if deployment.status == 'success' %}bg-success{% elif deployment.status == 'failed' %}bg-danger{% else %}bg-warning{% endif %}">
{{ deployment.status }}
</span>
</div>
<small class="text-muted">
{{ deployment.started_at.strftime('%d.%m.%Y %H:%M') if deployment.started_at else 'N/A' }}
</small>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0">Деплои еще не выполнялись</p>
{% endif %}
</div>
</div>
</div>
<!-- Live логи -->
<div class="card mt-3" x-show="testRunning || deployRunning || logs.length > 0">
<div class="card-header">
<h5 class="mb-0">Логи выполнения</h5>
</div>
<div class="card-body">
<div class="log-container" style="max-height: 500px; overflow-y: auto; font-family: monospace; font-size: 0.875rem; background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 0.25rem;" x-ref="logContainer">
<template x-for="log in logs" :key="log.id">
<div
class="log-line mb-1"
:class="{
'text-danger': log.type === 'error',
'text-warning': log.type === 'warning',
'text-info': log.type === 'info',
'text-success': log.type === 'success'
}"
x-html="log.data"
></div>
</template>
</div>
<div class="mt-3 d-flex gap-2">
<button
@click="clearLogs"
class="btn btn-outline-secondary btn-sm"
>
<i class="fas fa-trash me-2"></i>
Очистить
</button>
<button
@click="downloadLogs"
class="btn btn-outline-secondary btn-sm"
>
<i class="fas fa-download me-2"></i>
Скачать логи
</button>
</div>
</div>
</div>
</div>
<script>
function playbookManager(playbookId) {
return {
playbookId: playbookId,
testRunning: false,
deployRunning: false,
testPreset: 'default',
deployInventory: '{{ playbook.inventory or "" }}',
editInventory: false,
inventoryContent: '{{ playbook.inventory or "" }}',
logs: [],
ws: null,
async startTest() {
this.testRunning = true;
this.logs = [];
try {
const formData = new FormData();
formData.append('preset', this.testPreset);
const response = await fetch(`/api/v1/playbooks/${this.playbookId}/test`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.websocket_url) {
this.connectWebSocket(data.websocket_url, 'test');
} else {
this.addLog('error', 'Не удалось получить WebSocket URL');
this.testRunning = false;
}
} catch (error) {
this.addLog('error', `Ошибка запуска теста: ${error.message}`);
this.testRunning = false;
}
},
async startDeploy() {
if (!this.deployInventory) {
alert('Укажите inventory для деплоя');
return;
}
this.deployRunning = true;
this.logs = [];
try {
const formData = new FormData();
formData.append('inventory', this.deployInventory);
const response = await fetch(`/api/v1/playbooks/${this.playbookId}/deploy`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.websocket_url) {
this.connectWebSocket(data.websocket_url, 'deploy');
} else {
this.addLog('error', 'Не удалось получить WebSocket URL');
this.deployRunning = false;
}
} catch (error) {
this.addLog('error', `Ошибка запуска деплоя: ${error.message}`);
this.deployRunning = false;
}
},
connectWebSocket(url, type) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.ws = new WebSocket(`${protocol}//${window.location.host}${url}`);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
this.addLog(data.level || 'info', data.data);
} else if (data.type === 'complete') {
this.addLog('success', data.data || '✅ Выполнение завершено');
if (type === 'test') {
this.testRunning = false;
} else {
this.deployRunning = false;
}
this.ws.close();
} else if (data.type === 'error') {
this.addLog('error', data.data || '❌ Ошибка');
if (type === 'test') {
this.testRunning = false;
} else {
this.deployRunning = false;
}
this.ws.close();
}
};
this.ws.onerror = (error) => {
this.addLog('error', `Ошибка WebSocket: ${error}`);
if (type === 'test') {
this.testRunning = false;
} else {
this.deployRunning = false;
}
};
this.ws.onclose = () => {
if (type === 'test') {
this.testRunning = false;
} else {
this.deployRunning = false;
}
};
},
addLog(type, message) {
this.logs.push({
id: Date.now() + Math.random(),
type: type,
data: message.replace(/\n/g, '<br>')
});
this.$nextTick(() => {
const container = this.$refs.logContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
},
clearLogs() {
this.logs = [];
},
downloadLogs() {
const content = this.logs.map(l => l.data.replace(/<br>/g, '\n')).join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `playbook-${this.playbookId}-${Date.now()}.log`;
a.click();
URL.revokeObjectURL(url);
},
async saveInventory() {
const content = document.getElementById('inventory-content').value;
// TODO: Реализовать сохранение через API
this.inventoryContent = content;
this.editInventory = false;
alert('Inventory сохранен (TODO: реализовать API)');
}
}
}
// Инициализация CodeMirror для playbook content
document.addEventListener('DOMContentLoaded', function() {
if (typeof CodeMirror !== 'undefined') {
const playbookEditor = CodeMirror.fromTextArea(
document.getElementById('playbook-content'),
{
mode: 'yaml',
theme: 'monokai',
readOnly: true,
lineNumbers: true
}
);
const inventoryEditor = document.getElementById('inventory-content');
if (inventoryEditor) {
let inventoryCodeMirror = CodeMirror.fromTextArea(inventoryEditor, {
mode: 'yaml',
theme: 'monokai',
readOnly: true,
lineNumbers: true
});
}
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,217 @@
{% extends "base.html" %}
{% block title %}Редактировать Playbook - DevOpsLab{% endblock %}
{% block page_title %}Редактировать Playbook: {{ playbook.name }}{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
<ul class="nav nav-tabs mb-3" id="playbook-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="info-tab" data-bs-toggle="tab" data-bs-target="#info" type="button" role="tab">
<i class="fas fa-info-circle me-2"></i>
Информация
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="content-tab" data-bs-toggle="tab" data-bs-target="#content" type="button" role="tab">
<i class="fas fa-code me-2"></i>
Playbook (YAML)
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="inventory-tab" data-bs-toggle="tab" data-bs-target="#inventory" type="button" role="tab">
<i class="fas fa-server me-2"></i>
Инвентарь
</button>
</li>
</ul>
<div class="tab-content" id="playbook-tab-content">
<!-- Информация -->
<div class="tab-pane fade show active" id="info" role="tabpanel">
<form
hx-put="/api/v1/playbooks/{{ playbook.id }}"
hx-target="#info-result"
hx-swap="innerHTML"
>
<div class="mb-3">
<label class="form-label">Название</label>
<input
type="text"
name="name"
class="form-control"
value="{{ playbook.name }}"
required
>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea
name="description"
class="form-control"
rows="3"
>{{ playbook.description or '' }}</textarea>
</div>
<div class="mb-3">
<label class="form-label">Роли</label>
<div class="row g-2">
{% for role in all_roles %}
<div class="col-12 col-md-6 col-lg-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="roles"
value="{{ role }}"
id="role-{{ role }}"
{% if role in playbook.roles %}checked{% endif %}
>
<label class="form-check-label" for="role-{{ role }}">
{{ role }}
</label>
</div>
</div>
{% endfor %}
</div>
</div>
<div id="info-result"></div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить
</button>
</form>
</div>
<!-- Playbook YAML -->
<div class="tab-pane fade" id="content" role="tabpanel">
<form
hx-put="/api/v1/playbooks/{{ playbook.id }}"
hx-target="#content-result"
hx-swap="innerHTML"
>
<input type="hidden" name="content" id="playbook-content-hidden">
<div class="mb-3">
<label class="form-label">Playbook (YAML)</label>
<textarea
id="playbook-content-editor"
class="form-control font-monospace"
rows="20"
>{{ playbook.content }}</textarea>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
Редактируйте YAML содержимое playbook. Валидация выполняется автоматически.
</div>
</div>
<div id="content-result"></div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить
</button>
</form>
</div>
<!-- Инвентарь -->
<div class="tab-pane fade" id="inventory" role="tabpanel">
<form
hx-put="/api/v1/playbooks/{{ playbook.id }}"
hx-target="#inventory-result"
hx-swap="innerHTML"
>
<input type="hidden" name="inventory" id="inventory-hidden">
<div class="mb-3">
<label class="form-label">Инвентарь (YAML или INI)</label>
<textarea
id="inventory-editor"
class="form-control font-monospace"
rows="20"
>{{ playbook.inventory or '' }}</textarea>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
Инвентарь в формате YAML или INI. Редактор автоматически определит формат.
</div>
</div>
<div id="inventory-result"></div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация редакторов
if (typeof CodeEditor !== 'undefined') {
const playbookEditor = CodeEditor.init('playbook-content-editor', 'yaml', {
theme: 'monokai',
lineNumbers: true,
foldGutter: true
});
const inventoryEditor = CodeEditor.init('inventory-editor', 'yaml', {
theme: 'monokai',
lineNumbers: true,
foldGutter: true
});
// Валидация playbook при изменении
if (playbookEditor) {
playbookEditor.on('change', function() {
const content = playbookEditor.getValue();
const validation = CodeEditor.validateAnsible(content);
if (!validation.valid) {
CodeEditor.showErrors(playbookEditor, validation.errors);
} else {
// Очищаем ошибки если валидация прошла
if (playbookEditor._validationMarkers) {
playbookEditor._validationMarkers.forEach(m => m.clear());
playbookEditor._validationMarkers = [];
}
}
// Сохраняем в hidden поле для отправки формы
document.getElementById('playbook-content-hidden').value = content;
});
}
// Валидация инвентаря
if (inventoryEditor) {
inventoryEditor.on('change', function() {
const content = inventoryEditor.getValue();
// Сохраняем в hidden поле для отправки формы
document.getElementById('inventory-hidden').value = content;
if (content.trim()) {
// Определяем формат (INI или YAML)
const isINI = content.trim().startsWith('[') || content.includes('ansible_host=');
let validation;
if (isINI) {
// Простая валидация INI
validation = { valid: content.includes('[') && content.includes(']'), errors: [] };
} else {
validation = CodeEditor.validateYAML(content);
}
if (!validation.valid) {
CodeEditor.showErrors(inventoryEditor, validation.errors);
} else {
if (inventoryEditor._validationMarkers) {
inventoryEditor._validationMarkers.forEach(m => m.clear());
inventoryEditor._validationMarkers = [];
}
}
}
});
}
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,96 @@
{% extends "base.html" %}
{% block title %}Playbook - DevOpsLab{% endblock %}
{% block page_title %}Playbook{% endblock %}
{% block header_actions %}
<a href="/playbooks/create" class="btn btn-primary btn-sm">
<i class="fas fa-plus me-2"></i>
Создать playbook
</a>
{% endblock %}
{% block content %}
<div class="row g-3" id="playbooks-list">
{% for playbook in playbooks %}
<div class="col-12 col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">
<a href="/playbooks/{{ playbook.id }}" class="text-decoration-none">
{{ playbook.name }}
</a>
</h5>
<div class="btn-group">
<a
href="/playbooks/{{ playbook.id }}/edit"
class="btn btn-sm btn-outline-primary"
title="Редактировать"
>
<i class="fas fa-edit"></i>
</a>
<button
hx-delete="/api/v1/playbooks/{{ playbook.id }}"
hx-confirm="Удалить playbook '{{ playbook.name }}'?"
hx-target="closest .col-12"
hx-swap="outerHTML"
class="btn btn-sm btn-outline-danger"
title="Удалить"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
{% if playbook.description %}
<p class="card-text text-muted small mb-3">{{ playbook.description }}</p>
{% endif %}
<div class="small text-muted mb-2">
<i class="fas fa-tasks me-1"></i>
Ролей: <span class="fw-semibold">{{ playbook.roles|length }}</span>
</div>
{% if playbook.roles %}
<div class="small text-muted mb-2">
<i class="fas fa-list me-1"></i>
Роли:
{% for role in playbook.roles[:3] %}
<span class="badge bg-info me-1">{{ role }}</span>
{% endfor %}
{% if playbook.roles|length > 3 %}
<span class="text-muted">+{{ playbook.roles|length - 3 }}</span>
{% endif %}
</div>
{% endif %}
<div class="small text-muted mb-2">
<i class="fas fa-info-circle me-1"></i>
Статус:
<span class="badge {% if playbook.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ playbook.status }}
</span>
</div>
<div class="d-grid mt-3">
<a
href="/playbooks/{{ playbook.id }}"
class="btn btn-secondary btn-sm"
>
<i class="fas fa-info-circle me-1"></i>
Детали
</a>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Playbook'ов пока нет. <a href="/playbooks/create">Создайте первый playbook</a>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% block title %}Создать preset - DevOpsLab{% endblock %}
{% block page_title %}Создание нового preset'а{% endblock %}
{% block header_actions %}
<a href="/presets" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад к списку
</a>
{% endblock %}
{% block content %}
<div x-data="presetCreator()">
<form
hx-post="/api/v1/presets/create"
hx-target="#result"
hx-swap="innerHTML"
@submit.prevent="submitForm"
class="card"
>
<div class="card-header">
<h5 class="mb-0">Базовая информация</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Имя preset'а *</label>
<input
type="text"
name="preset_name"
x-model="formData.preset_name"
required
pattern="[a-z0-9_-]+"
class="form-control"
placeholder="my-preset"
>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea
name="description"
x-model="formData.description"
rows="2"
class="form-control"
placeholder="Описание preset'а..."
></textarea>
</div>
<div class="mb-3">
<label class="form-label">Категория</label>
<select
name="category"
x-model="formData.category"
class="form-select"
>
<option value="main">Основные</option>
<option value="k8s">Kubernetes</option>
</select>
</div>
</div>
<div class="card-header">
<h5 class="mb-0">Хосты</h5>
</div>
<div class="card-body">
<div class="space-y-2 mb-3" x-ref="hostsContainer">
<template x-for="(host, index) in formData.hosts" :key="index">
<div class="card mb-2">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-md-3">
<label class="form-label small">Имя хоста</label>
<input
type="text"
x-model="host.name"
placeholder="u1"
class="form-control"
>
</div>
<div class="col-12 col-md-4">
<label class="form-label small">Семейство образа</label>
<select
x-model="host.family"
class="form-select"
>
<option value="ubuntu20">Ubuntu 20</option>
<option value="ubuntu22">Ubuntu 22</option>
<option value="ubuntu24">Ubuntu 24</option>
<option value="debian11">Debian 11</option>
<option value="debian12">Debian 12</option>
<option value="centos7">CentOS 7</option>
<option value="centos8">CentOS 8</option>
<option value="centos9">CentOS 9</option>
<option value="alma">AlmaLinux</option>
<option value="rocky">Rocky Linux</option>
<option value="rhel">RHEL</option>
<option value="astra">Astra Linux</option>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label small">Группы (через запятую)</label>
<input
type="text"
x-model="host.groups_str"
placeholder="test, web"
class="form-control"
@input="updateHostGroups(index)"
>
</div>
<div class="col-12 col-md-1">
<button
type="button"
@click="removeHost(index)"
class="btn btn-danger btn-sm w-100"
title="Удалить"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
<button
type="button"
@click="addHost"
class="btn btn-outline-secondary"
>
<i class="fas fa-plus me-2"></i>
Добавить хост
</button>
</div>
<!-- Скрытые поля -->
<input
type="hidden"
name="hosts"
:value="JSON.stringify(formData.hosts.map(h => ({name: h.name, family: h.family, groups: h.groups})))"
>
<!-- Результат -->
<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>
Создать preset
</button>
<a href="/presets" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>
Отмена
</a>
</div>
</div>
</form>
</div>
<script>
function presetCreator() {
return {
formData: {
preset_name: '',
description: '',
category: 'main',
hosts: [{
name: 'u1',
family: 'ubuntu22',
groups_str: 'test, web',
groups: ['test', 'web']
}]
},
addHost() {
this.formData.hosts.push({
name: `u${this.formData.hosts.length + 1}`,
family: 'ubuntu22',
groups_str: 'test',
groups: ['test']
});
},
removeHost(index) {
this.formData.hosts.splice(index, 1);
},
updateHostGroups(index) {
const host = this.formData.hosts[index];
host.groups = host.groups_str.split(',').map(g => g.trim()).filter(g => g);
},
submitForm(event) {
// HTMX обработает отправку
}
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,268 @@
{% extends "base.html" %}
{% block title %}{{ preset.name }} - DevOpsLab{% endblock %}
{% block page_title %}Preset: {{ preset.name }}{% endblock %}
{% block header_actions %}
<div class="btn-group">
<button
type="button"
class="btn btn-success btn-sm"
onclick="startPresetTest()"
title="Запустить тест preset'а"
>
<i class="fas fa-play me-2"></i>
Запустить
</button>
<button
type="button"
class="btn btn-warning btn-sm"
onclick="stopPresetTest()"
title="Остановить тест"
id="stop-btn"
style="display: none;"
>
<i class="fas fa-stop me-2"></i>
Остановить
</button>
<button
type="button"
class="btn btn-info btn-sm"
onclick="restartPresetTest()"
title="Перезапустить тест"
id="restart-btn"
style="display: none;"
>
<i class="fas fa-redo me-2"></i>
Перезапустить
</button>
</div>
<a href="/presets/{{ preset.name }}/edit?category={{ preset.category }}" class="btn btn-primary btn-sm">
<i class="fas fa-edit me-2"></i>
Редактировать
</a>
<a href="/presets" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12 col-lg-8">
<!-- Информация о preset'е -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Информация о preset'е</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<strong>Имя:</strong> {{ preset.name }}<br>
<strong>Категория:</strong>
<span class="badge bg-{% if preset.category == 'k8s' %}info{% else %}primary{% endif %}">
{{ preset.category }}
</span>
</div>
<div class="col-md-6">
{% if preset.data %}
<strong>Docker сеть:</strong> {{ preset.data.get('docker_network', 'labnet') }}<br>
<strong>Хостов:</strong> {{ preset.data.get('hosts', [])|length }}
{% endif %}
</div>
</div>
{% if preset.data and preset.data.get('description') %}
<div class="mb-3">
<strong>Описание:</strong>
<p class="text-muted mb-0">{{ preset.data.description }}</p>
</div>
{% endif %}
{% if preset.data and preset.data.get('hosts') %}
<div class="mb-3">
<strong>Хосты:</strong>
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Имя</th>
<th>Семейство</th>
<th>Группы</th>
<th>Тип</th>
</tr>
</thead>
<tbody>
{% for host in preset.data.hosts %}
<tr>
<td>{{ host.name }}</td>
<td>
<span class="badge bg-secondary">{{ host.family }}</span>
</td>
<td>
{% if host.groups %}
{% for group in host.groups %}
<span class="badge bg-info me-1">{{ group }}</span>
{% endfor %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>{{ host.type or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if preset.data and preset.data.get('images') %}
<div class="mb-3">
<strong>Docker образы:</strong>
<div class="d-flex flex-wrap gap-2 mt-2">
{% for key, value in preset.data.images.items() %}
<span class="badge bg-success">
<i class="fas fa-cube me-1"></i>
{{ key }}: {{ value.split(':')[-1] if ':' in value else value }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- YAML содержимое -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">YAML содержимое</h5>
</div>
<div class="card-body p-0">
<pre class="mb-0" style="background: #1e1e1e; color: #d4d4d4; padding: 1rem; margin: 0; overflow-x: auto;"><code>{{ preset.content }}</code></pre>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<!-- Логи тестирования -->
<div class="card" id="test-logs-card" style="display: none;">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Логи тестирования</h5>
<button
type="button"
class="btn btn-sm btn-outline-light"
onclick="clearTestLogs()"
title="Очистить логи"
>
<i class="fas fa-trash"></i>
</button>
</div>
<div class="card-body p-0">
<div class="log-container" id="test-logs" style="height: 600px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 1rem; font-family: 'Courier New', monospace; font-size: 0.75rem;">
<!-- Логи будут добавлены через WebSocket -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let testWebSocket = null;
let testRunning = false;
function startPresetTest() {
if (testRunning) {
alert('Тест уже запущен');
return;
}
// Показываем логи
const logsCard = document.getElementById('test-logs-card');
const logsContainer = document.getElementById('test-logs');
logsCard.style.display = 'block';
logsContainer.innerHTML = '';
// Показываем кнопки управления
document.getElementById('stop-btn').style.display = 'inline-block';
document.getElementById('restart-btn').style.display = 'inline-block';
testRunning = true;
// Создаем WebSocket подключение
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/preset/test/{{ preset.name }}?category={{ preset.category }}`);
testWebSocket = ws;
ws.onopen = () => {
ws.send(JSON.stringify({
action: 'start',
preset_name: '{{ preset.name }}',
preset_category: '{{ preset.category }}'
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
const logLine = document.createElement('div');
logLine.className = `log-line log-${data.level || 'info'}`;
logLine.textContent = data.data;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
} else if (data.type === 'error') {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = data.data;
logsContainer.appendChild(errorLine);
} else if (data.type === 'complete') {
const completeLine = document.createElement('div');
completeLine.className = 'log-line log-info';
completeLine.textContent = data.data || '✅ Тестирование завершено';
logsContainer.appendChild(completeLine);
testRunning = false;
document.getElementById('stop-btn').style.display = 'none';
document.getElementById('restart-btn').style.display = 'none';
ws.close();
}
};
ws.onerror = (error) => {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = `❌ Ошибка подключения: ${error}`;
logsContainer.appendChild(errorLine);
testRunning = false;
};
ws.onclose = () => {
testRunning = false;
console.log('WebSocket закрыт');
};
}
function stopPresetTest() {
if (testWebSocket && testWebSocket.readyState === WebSocket.OPEN) {
testWebSocket.send(JSON.stringify({ action: 'stop' }));
testWebSocket.close();
}
testRunning = false;
document.getElementById('stop-btn').style.display = 'none';
document.getElementById('restart-btn').style.display = 'none';
}
function restartPresetTest() {
stopPresetTest();
setTimeout(() => {
startPresetTest();
}, 1000);
}
function clearTestLogs() {
document.getElementById('test-logs').innerHTML = '';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,518 @@
{% extends "base.html" %}
{% block title %}Редактировать {{ preset.name }} - DevOpsLab{% endblock %}
{% block page_title %}Редактирование preset'а: {{ preset.name }}{% endblock %}
{% block header_actions %}
<a href="/presets/{{ preset.name }}?category={{ preset.category }}" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
{% endblock %}
{% block content %}
<div x-data="presetEditor()">
<form
hx-post="/api/v1/presets/{{ preset.name }}/update?category={{ preset.category }}"
hx-target="#result"
hx-swap="innerHTML"
@submit.prevent="submitForm"
class="card"
>
<!-- Базовая информация -->
<div class="card-header">
<h5 class="mb-0">Базовая информация</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Имя preset'а</label>
<input
type="text"
value="{{ preset.name }}"
class="form-control"
readonly
disabled
>
<div class="form-text">Имя preset'а нельзя изменить</div>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea
name="description"
x-model="formData.description"
rows="2"
class="form-control"
placeholder="Описание preset'а..."
>{{ preset.data.description if preset.data and preset.data.description else '' }}</textarea>
</div>
<div class="mb-3">
<label class="form-label">Категория</label>
<input
type="text"
value="{{ preset.category }}"
class="form-control"
readonly
disabled
>
<div class="form-text">Категорию нельзя изменить</div>
</div>
<div class="mb-3">
<label class="form-label">Docker Network</label>
<input
type="text"
name="docker_network"
x-model="formData.docker_network"
class="form-control"
placeholder="labnet"
>
</div>
</div>
<!-- Хосты -->
<div class="card-header">
<h5 class="mb-0">Хосты</h5>
</div>
<div class="card-body">
<div class="space-y-2 mb-3" x-ref="hostsContainer">
<template x-for="(host, index) in formData.hosts" :key="index">
<div class="card mb-2">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-md-2">
<label class="form-label small">Имя хоста</label>
<input
type="text"
x-model="host.name"
placeholder="u1"
class="form-control"
required
>
</div>
<div class="col-12 col-md-3">
<label class="form-label small">Семейство образа</label>
<select
x-model="host.family"
class="form-select"
>
<option value="ubuntu20">Ubuntu 20</option>
<option value="ubuntu22">Ubuntu 22</option>
<option value="ubuntu24">Ubuntu 24</option>
<option value="debian9">Debian 9</option>
<option value="debian10">Debian 10</option>
<option value="debian11">Debian 11</option>
<option value="debian12">Debian 12</option>
<option value="centos7">CentOS 7</option>
<option value="centos8">CentOS 8</option>
<option value="centos9">CentOS 9</option>
<option value="alma">AlmaLinux</option>
<option value="rocky">Rocky Linux</option>
<option value="rhel">RHEL</option>
<option value="redos">RedOS</option>
<option value="astra">Astra Linux</option>
<option value="alt9">Alt Linux 9</option>
<option value="alt10">Alt Linux 10</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label small">Группы (через запятую)</label>
<input
type="text"
x-model="host.groups_str"
placeholder="test, web"
class="form-control"
@input="updateHostGroups(index)"
>
</div>
<div class="col-12 col-md-2">
<label class="form-label small">Тип (опционально)</label>
<select
x-model="host.type"
class="form-select"
>
<option value="">Обычный</option>
<option value="dind">DinD</option>
<option value="dood">DOoD</option>
</select>
</div>
<div class="col-12 col-md-2">
<button
type="button"
@click="removeHost(index)"
class="btn btn-danger btn-sm w-100"
title="Удалить"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
<button
type="button"
@click="addHost"
class="btn btn-outline-secondary"
>
<i class="fas fa-plus me-2"></i>
Добавить хост
</button>
</div>
<!-- Образы -->
<div class="card-header">
<h5 class="mb-0">Docker образы</h5>
</div>
<div class="card-body">
<div class="mb-3">
<div class="space-y-2" x-ref="imagesContainer">
<template x-for="(image, key) in formData.images" :key="key">
<div class="row g-2 mb-2 align-items-center">
<div class="col-12 col-md-4">
<input
type="text"
:value="key"
class="form-control"
readonly
disabled
>
</div>
<div class="col-12 col-md-6">
<input
type="text"
x-model="formData.images[key]"
class="form-control"
placeholder="inecs/ansible-lab:ubuntu22-latest"
>
</div>
<div class="col-12 col-md-2">
<button
type="button"
@click="removeImage(key)"
class="btn btn-danger btn-sm w-100"
title="Удалить"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</template>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-12 col-md-4">
<input
type="text"
x-model="newImageKey"
class="form-control"
placeholder="Новый ключ (ubuntu25)"
>
</div>
<div class="col-12 col-md-6">
<input
type="text"
x-model="newImageValue"
class="form-control"
placeholder="inecs/ansible-lab:ubuntu25-latest"
>
</div>
<div class="col-12 col-md-2">
<button
type="button"
@click="addImage"
class="btn btn-outline-primary w-100"
>
<i class="fas fa-plus"></i>
</button>
</div>
</div>
</div>
<!-- Systemd Defaults -->
<div class="card-header">
<h5 class="mb-0">Systemd Defaults</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
x-model="formData.systemd_defaults.privileged"
id="privileged"
>
<label class="form-check-label" for="privileged">
Privileged
</label>
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Command</label>
<input
type="text"
x-model="formData.systemd_defaults.command"
class="form-control"
placeholder="/sbin/init"
>
</div>
<div class="col-12">
<label class="form-label">Volumes (по одному на строку)</label>
<textarea
x-model="formData.systemd_defaults.volumes_str"
@input="updateVolumes"
rows="3"
class="form-control"
placeholder="/sys/fs/cgroup:/sys/fs/cgroup:rw"
></textarea>
</div>
<div class="col-12">
<label class="form-label">Tmpfs (через запятую)</label>
<input
type="text"
x-model="formData.systemd_defaults.tmpfs_str"
@input="updateTmpfs"
class="form-control"
placeholder="/run, /run/lock"
>
</div>
<div class="col-12">
<label class="form-label">Capabilities (через запятую)</label>
<input
type="text"
x-model="formData.systemd_defaults.capabilities_str"
@input="updateCapabilities"
class="form-control"
placeholder="SYS_ADMIN"
>
</div>
</div>
</div>
{% if preset.category == 'k8s' %}
<!-- Kind Clusters (только для k8s) -->
<div class="card-header">
<h5 class="mb-0">Kind Clusters</h5>
</div>
<div class="card-body">
<div class="mb-3">
<div class="space-y-2" x-ref="clustersContainer">
<template x-for="(cluster, index) in formData.kind_clusters" :key="index">
<div class="row g-2 mb-2 align-items-center">
<div class="col-12 col-md-10">
<input
type="text"
x-model="formData.kind_clusters[index]"
class="form-control"
placeholder="cluster-name"
>
</div>
<div class="col-12 col-md-2">
<button
type="button"
@click="removeCluster(index)"
class="btn btn-danger btn-sm w-100"
title="Удалить"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</template>
</div>
</div>
<button
type="button"
@click="addCluster"
class="btn btn-outline-secondary"
>
<i class="fas fa-plus me-2"></i>
Добавить кластер
</button>
</div>
{% endif %}
<!-- Скрытые поля для отправки -->
<input
type="hidden"
name="hosts"
:value="JSON.stringify(formData.hosts.map(h => ({
name: h.name,
family: h.family,
groups: h.groups,
type: h.type || undefined,
supported_platforms: h.supported_platforms || undefined
})))"
>
<input
type="hidden"
name="images"
:value="JSON.stringify(formData.images)"
>
<input
type="hidden"
name="systemd_defaults"
:value="JSON.stringify({
privileged: formData.systemd_defaults.privileged,
command: formData.systemd_defaults.command,
volumes: formData.systemd_defaults.volumes,
tmpfs: formData.systemd_defaults.tmpfs,
capabilities: formData.systemd_defaults.capabilities
})"
>
{% if preset.category == 'k8s' %}
<input
type="hidden"
name="kind_clusters"
:value="JSON.stringify(formData.kind_clusters)"
>
{% endif %}
<!-- Результат -->
<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-save me-2"></i>
Сохранить изменения
</button>
<a href="/presets/{{ preset.name }}?category={{ preset.category }}" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>
Отмена
</a>
</div>
</div>
</form>
</div>
<script>
function presetEditor() {
// Парсинг данных preset'а из шаблона
const presetData = {% if preset.data %}{{ preset.data | tojson }}{% else %}{}{% endif %};
// Инициализация хостов
const hosts = presetData.hosts || [];
const formattedHosts = hosts.map(host => ({
name: host.name || '',
family: host.family || 'ubuntu22',
groups: host.groups || [],
groups_str: Array.isArray(host.groups) ? host.groups.join(', ') : (host.groups || ''),
type: host.type || '',
supported_platforms: host.supported_platforms || []
}));
// Инициализация образов
const images = presetData.images || {};
// Инициализация systemd defaults
const systemdDefaults = presetData.systemd_defaults || {};
const volumes = systemdDefaults.volumes || [];
const tmpfs = systemdDefaults.tmpfs || [];
const capabilities = systemdDefaults.capabilities || [];
// Извлечение описания из комментария в content
let description = '';
{% if preset.content %}
const contentLines = {{ preset.content | tojson }}.split('\n');
for (const line of contentLines) {
if (line.trim().startsWith('#description:')) {
description = line.split('#description:')[1].trim();
break;
}
}
{% endif %}
return {
formData: {
description: description,
docker_network: presetData.docker_network || 'labnet',
hosts: formattedHosts.length > 0 ? formattedHosts : [{
name: 'u1',
family: 'ubuntu22',
groups: ['test'],
groups_str: 'test',
type: ''
}],
images: images,
systemd_defaults: {
privileged: systemdDefaults.privileged !== undefined ? systemdDefaults.privileged : true,
command: systemdDefaults.command || '/sbin/init',
volumes: volumes,
volumes_str: volumes.join('\n'),
tmpfs: tmpfs,
tmpfs_str: tmpfs.join(', '),
capabilities: capabilities,
capabilities_str: capabilities.join(', ')
},
kind_clusters: presetData.kind_clusters || []
},
newImageKey: '',
newImageValue: '',
addHost() {
this.formData.hosts.push({
name: `u${this.formData.hosts.length + 1}`,
family: 'ubuntu22',
groups: ['test'],
groups_str: 'test',
type: ''
});
},
removeHost(index) {
this.formData.hosts.splice(index, 1);
},
updateHostGroups(index) {
const host = this.formData.hosts[index];
host.groups = host.groups_str.split(',').map(g => g.trim()).filter(g => g);
},
addImage() {
if (this.newImageKey && this.newImageValue) {
this.formData.images[this.newImageKey] = this.newImageValue;
this.newImageKey = '';
this.newImageValue = '';
}
},
removeImage(key) {
delete this.formData.images[key];
},
updateVolumes() {
this.formData.systemd_defaults.volumes =
this.formData.systemd_defaults.volumes_str
.split('\n')
.map(v => v.trim())
.filter(v => v);
},
updateTmpfs() {
this.formData.systemd_defaults.tmpfs =
this.formData.systemd_defaults.tmpfs_str
.split(',')
.map(t => t.trim())
.filter(t => t);
},
updateCapabilities() {
this.formData.systemd_defaults.capabilities =
this.formData.systemd_defaults.capabilities_str
.split(',')
.map(c => c.trim())
.filter(c => c);
},
addCluster() {
this.formData.kind_clusters.push('');
},
removeCluster(index) {
this.formData.kind_clusters.splice(index, 1);
},
submitForm(event) {
// HTMX обработает отправку
}
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,227 @@
{% extends "base.html" %}
{% block title %}Preset'ы - DevOpsLab{% endblock %}
{% block page_title %}Preset'ы Molecule{% endblock %}
{% block header_actions %}
<a href="/presets/create" class="btn btn-primary btn-sm">
<i class="fas fa-plus me-2"></i>
Создать preset
</a>
{% endblock %}
{% block content %}
<!-- Поиск и фильтры -->
<div class="card mb-3">
<div class="card-body">
<form method="get" action="/presets" class="row g-3">
<div class="col-12 col-md-6">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Поиск по имени или описанию..."
class="form-control"
>
</div>
<div class="col-12 col-md-3">
<select name="category" class="form-select">
<option value="">Все категории</option>
<option value="main" {% if category == 'main' %}selected{% endif %}>Основные</option>
<option value="k8s" {% if category == 'k8s' %}selected{% endif %}>Kubernetes</option>
</select>
</div>
<div class="col-12 col-md-3">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search me-1"></i>
Поиск
</button>
{% if search or category %}
<a href="/presets" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i>
Сброс
</a>
{% endif %}
</div>
</div>
</form>
</div>
</div>
<!-- Таблица preset'ов -->
<div class="card mb-3">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">Список preset'ов</h5>
<span class="text-muted small">
Всего: <strong>{{ total }}</strong>
</span>
</div>
</div>
<div class="card-body p-0">
{% if presets %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width: 25%;">Имя</th>
<th style="width: 15%;">Категория</th>
<th style="width: 30%;">Описание</th>
<th style="width: 15%;">Хосты</th>
<th style="width: 15%;">Действия</th>
</tr>
</thead>
<tbody>
{% for preset in presets %}
<tr>
<td>
<a href="/presets/{{ preset.name }}{% if preset.category == 'k8s' %}?category=k8s{% endif %}" class="text-decoration-none fw-semibold">
{{ preset.name }}
</a>
</td>
<td>
{% if preset.category == 'k8s' %}
<span class="badge bg-primary">Kubernetes</span>
{% else %}
<span class="badge bg-secondary">Основной</span>
{% endif %}
</td>
<td>
<span class="text-muted small">
{{ preset.description[:80] if preset.description else "Нет описания" }}{% if preset.description and preset.description|length > 80 %}...{% endif %}
</span>
</td>
<td>
<span class="badge bg-info">{{ preset.hosts_count|default(0) }}</span>
{% if preset.groups %}
<div class="mt-1">
{% for group in preset.groups[:2] %}
<span class="badge bg-light text-dark small">{{ group }}</span>
{% endfor %}
{% if preset.groups|length > 2 %}
<span class="badge bg-light text-dark small">+{{ preset.groups|length - 2 }}</span>
{% endif %}
</div>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a
href="/presets/{{ preset.name }}/edit{% if preset.category == 'k8s' %}?category=k8s{% endif %}"
class="btn btn-outline-primary"
title="Редактировать"
>
<i class="fas fa-edit"></i>
</a>
<button
hx-delete="/api/v1/presets/{{ preset.name }}?category={{ preset.category or 'main' }}"
hx-confirm="Удалить preset '{{ preset.name }}'?"
hx-target="closest tr"
hx-swap="outerHTML"
class="btn btn-outline-danger"
title="Удалить"
>
<i class="fas fa-trash"></i>
</button>
<a
href="/presets/{{ preset.name }}{% if preset.category == 'k8s' %}?category=k8s{% endif %}"
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">Preset'ы не найдены</p>
<a href="/presets/create" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>
Создать первый preset
</a>
</div>
{% endif %}
</div>
{% if presets 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 %}{% if category %}&category={{ category }}{% 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 %}{% if category %}&category={{ category }}{% 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 %}{% if category %}&category={{ category }}{% 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 %}{% if category %}&category={{ category }}{% 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,153 @@
{% extends "base.html" %}
{% block title %}Настройки Docker - DevOpsLab{% endblock %}
{% block page_title %}Настройки Docker (Harbor и Docker Hub){% endblock %}
{% block header_actions %}
<a href="/profile" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад к профилю
</a>
{% endblock %}
{% block content %}
<div class="row">
<!-- Docker Hub настройки -->
<div class="col-12 col-lg-6 mb-3">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fab fa-docker me-2"></i>
Docker Hub
</h5>
</div>
<div class="card-body">
<form
hx-post="/api/v1/profile/docker-settings"
hx-target="#docker-result"
hx-swap="innerHTML"
>
<div class="mb-3">
<label class="form-label">Имя пользователя</label>
<input
type="text"
name="dockerhub_username"
value="{{ profile.dockerhub_username if profile and profile.dockerhub_username else '' }}"
class="form-control"
placeholder="username"
>
</div>
<div class="mb-3">
<label class="form-label">Пароль / Access Token</label>
<input
type="password"
name="dockerhub_password"
value=""
class="form-control"
placeholder="Оставьте пустым, чтобы не изменять"
>
<div class="form-text">
Используйте Access Token вместо пароля для большей безопасности
</div>
</div>
<div class="mb-3">
<label class="form-label">Репозиторий по умолчанию</label>
<input
type="text"
name="dockerhub_repository"
value="{{ profile.dockerhub_repository if profile and profile.dockerhub_repository else '' }}"
class="form-control"
placeholder="ansible-lab"
>
<div class="form-text">
Имя репозитория (без namespace)
</div>
</div>
<div id="docker-result" class="mb-3"></div>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-save me-2"></i>
Сохранить настройки Docker Hub
</button>
</form>
</div>
</div>
</div>
<!-- Harbor настройки -->
<div class="col-12 col-lg-6 mb-3">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-server me-2"></i>
Harbor
</h5>
</div>
<div class="card-body">
<form
hx-post="/api/v1/profile/docker-settings"
hx-target="#harbor-result"
hx-swap="innerHTML"
>
<div class="mb-3">
<label class="form-label">URL Harbor</label>
<input
type="url"
name="harbor_url"
value="{{ profile.harbor_url if profile and profile.harbor_url else '' }}"
class="form-control"
placeholder="https://harbor.example.com"
>
</div>
<div class="mb-3">
<label class="form-label">Имя пользователя</label>
<input
type="text"
name="harbor_username"
value="{{ profile.harbor_username if profile and profile.harbor_username else '' }}"
class="form-control"
placeholder="admin"
>
</div>
<div class="mb-3">
<label class="form-label">Пароль</label>
<input
type="password"
name="harbor_password"
value=""
class="form-control"
placeholder="Оставьте пустым, чтобы не изменять"
>
</div>
<div class="mb-3">
<label class="form-label">Проект</label>
<input
type="text"
name="harbor_project"
value="{{ profile.harbor_project if profile and profile.harbor_project else '' }}"
class="form-control"
placeholder="library"
>
<div class="form-text">
Имя проекта в Harbor
</div>
</div>
<div id="harbor-result" class="mb-3"></div>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-save me-2"></i>
Сохранить настройки Harbor
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,340 @@
{% extends "base.html" %}
{% block title %}Профиль - DevOpsLab{% endblock %}
{% block page_title %}Профиль пользователя{% endblock %}
{% block header_actions %}
<a href="/" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left me-2"></i>
Назад
</a>
{% endblock %}
{% block content %}
<div class="row">
<!-- Боковая панель с информацией -->
<div class="col-12 col-lg-4 mb-3">
<div class="card">
<div class="card-body text-center">
<div class="mb-3">
<div class="avatar-circle mx-auto mb-3" style="width: 100px; height: 100px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 2.5rem; font-weight: bold;">
{{ user.username[0].upper() }}
</div>
<h4 class="mb-1">{{ profile.full_name if profile and profile.full_name else user.username }}</h4>
<p class="text-muted mb-0">@{{ user.username }}</p>
</div>
<div class="d-grid gap-2 mb-3">
<span class="badge {% if user.is_superuser %}bg-danger{% else %}bg-secondary{% endif %}">
{% if user.is_superuser %}
<i class="fas fa-crown me-1"></i>Администратор
{% else %}
<i class="fas fa-user me-1"></i>Пользователь
{% endif %}
</span>
<span class="badge {% if user.is_active %}bg-success{% else %}bg-danger{% endif %}">
{% if user.is_active %}
<i class="fas fa-check-circle me-1"></i>Активен
{% else %}
<i class="fas fa-times-circle me-1"></i>Неактивен
{% endif %}
</span>
</div>
<hr>
<div class="text-start">
<div class="mb-2">
<small class="text-muted d-block">Дата регистрации</small>
<strong>{{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'N/A' }}</strong>
</div>
{% if user.updated_at %}
<div class="mb-2">
<small class="text-muted d-block">Последнее обновление</small>
<strong>{{ user.updated_at.strftime('%d.%m.%Y %H:%M') }}</strong>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Статистика -->
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0">Статистика</h6>
</div>
<div class="card-body">
<div class="row g-2 text-center">
<div class="col-6">
<div class="p-2 bg-light rounded">
<div class="h4 mb-0 text-primary">{{ stats.presets }}</div>
<small class="text-muted">Preset'ов</small>
</div>
</div>
<div class="col-6">
<div class="p-2 bg-light rounded">
<div class="h4 mb-0 text-info">{{ stats.dockerfiles }}</div>
<small class="text-muted">Dockerfile'ов</small>
</div>
</div>
<div class="col-6">
<div class="p-2 bg-light rounded">
<div class="h4 mb-0 text-success">{{ stats.playbooks }}</div>
<small class="text-muted">Playbook'ов</small>
</div>
</div>
<div class="col-6">
<div class="p-2 bg-light rounded">
<div class="h4 mb-0 text-warning">{{ stats.commands }}</div>
<small class="text-muted">Команд</small>
</div>
</div>
</div>
</div>
</div>
<!-- Быстрые ссылки -->
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0">Быстрые действия</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="/change-password" class="btn btn-outline-primary btn-sm">
<i class="fas fa-key me-2"></i>
Сменить пароль
</a>
<a href="/profile/docker-settings" class="btn btn-outline-info btn-sm">
<i class="fab fa-docker me-2"></i>
Настройки Docker
</a>
</div>
</div>
</div>
</div>
<!-- Основной контент с вкладками -->
<div class="col-12 col-lg-8">
<div class="card">
<!-- Навигация по вкладкам -->
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button
class="nav-link active"
id="view-tab"
data-bs-toggle="tab"
data-bs-target="#view"
type="button"
role="tab"
>
<i class="fas fa-eye me-2"></i>
Просмотр
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
id="edit-tab"
data-bs-toggle="tab"
data-bs-target="#edit"
type="button"
role="tab"
>
<i class="fas fa-edit me-2"></i>
Редактирование
</button>
</li>
</ul>
<div class="tab-content">
<!-- Вкладка просмотра -->
<div class="tab-pane fade show active" id="view" role="tabpanel">
<div class="card-body">
<h5 class="mb-4">Информация о профиле</h5>
<div class="row mb-3">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-muted small">Имя пользователя</label>
<div class="fw-semibold">{{ user.username }}</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-muted small">Полное имя</label>
<div class="fw-semibold">
{{ profile.full_name if profile and profile.full_name else 'Не указано' }}
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-muted small">Email</label>
<div class="fw-semibold">
{% if profile and profile.email %}
<a href="mailto:{{ profile.email }}">{{ profile.email }}</a>
{% else %}
Не указан
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-muted small">Роль</label>
<div>
{% if user.is_superuser %}
<span class="badge bg-danger">Администратор</span>
{% else %}
<span class="badge bg-secondary">Пользователь</span>
{% endif %}
</div>
</div>
</div>
</div>
<hr>
<h6 class="mb-3">Настройки Docker</h6>
<div class="row mb-3">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-muted small">
<i class="fab fa-docker me-1"></i>
Docker Hub
</label>
<div>
{% if profile and profile.dockerhub_username %}
<span class="badge bg-info">{{ profile.dockerhub_username }}</span>
{% if profile.dockerhub_repository %}
<span class="text-muted">/ {{ profile.dockerhub_repository }}</span>
{% endif %}
{% else %}
<span class="text-muted">Не настроено</span>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-muted small">
<i class="fas fa-server me-1"></i>
Harbor
</label>
<div>
{% if profile and profile.harbor_url %}
<span class="badge bg-success">{{ profile.harbor_url }}</span>
{% if profile.harbor_project %}
<span class="text-muted">/ {{ profile.harbor_project }}</span>
{% endif %}
{% else %}
<span class="text-muted">Не настроено</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<a href="/profile/docker-settings" class="btn btn-outline-primary btn-sm">
<i class="fab fa-docker me-2"></i>
Настроить Docker
</a>
</div>
</div>
</div>
<!-- Вкладка редактирования -->
<div class="tab-pane fade" id="edit" role="tabpanel">
<div class="card-body">
<h5 class="mb-4">Редактирование профиля</h5>
<form
hx-post="/api/v1/profile"
hx-target="#result"
hx-swap="innerHTML"
>
<div class="mb-3">
<label class="form-label">Имя пользователя</label>
<input
type="text"
value="{{ user.username }}"
class="form-control"
readonly
disabled
>
<div class="form-text">Имя пользователя нельзя изменить</div>
</div>
<div class="mb-3">
<label class="form-label">Полное имя</label>
<input
type="text"
name="full_name"
value="{{ profile.full_name if profile and profile.full_name else '' }}"
class="form-control"
placeholder="Иван Иванов"
>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input
type="email"
name="email"
value="{{ profile.email if profile and profile.email else '' }}"
class="form-control"
placeholder="user@example.com"
>
</div>
<div id="result" class="mb-3"></div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
Сохранить изменения
</button>
<button type="button" class="btn btn-secondary" onclick="location.reload()">
<i class="fas fa-times me-2"></i>
Отмена
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Обработка успешного сохранения профиля
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.target.id === 'result' && event.detail.xhr.status === 200) {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
// Показываем сообщение об успехе
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '<div class="alert alert-success alert-dismissible fade show" role="alert">' +
'<i class="fas fa-check-circle me-2"></i>' + response.message +
'<button type="button" class="btn-close" data-bs-dismiss="alert"></button>' +
'</div>';
// Обновляем страницу через 1.5 секунды
setTimeout(() => {
location.reload();
}, 1500);
}
}
});
});
</script>
{% endblock %}

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

View File

@@ -0,0 +1,213 @@
{% 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-body">
<form method="get" action="/tests" class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label">Роль</label>
<select name="role_name" class="form-select">
<option value="">Все роли</option>
{% for role in roles %}
<option value="{{ role }}" {% if role_name == role %}selected{% endif %}>
{{ role }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label">На странице</label>
<select name="per_page" class="form-select" onchange="this.form.submit()">
<option value="10" {% if per_page == 10 %}selected{% endif %}>10</option>
<option value="20" {% if per_page == 20 %}selected{% endif %}>20</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="col-12 col-md-4 d-flex align-items-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-filter me-2"></i>
Применить фильтры
</button>
</div>
</form>
</div>
</div>
<!-- Список тестов -->
<div class="card">
<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 error %}
<div class="alert alert-warning m-3">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ error }}
</div>
{% elif tests %}
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 15%;">Дата/Время</th>
<th style="width: 20%;">Роль</th>
<th style="width: 20%;">Preset</th>
<th style="width: 10%;">Статус</th>
<th style="width: 10%;">Длительность</th>
<th style="width: 25%;">Команда</th>
</tr>
</thead>
<tbody>
{% for test in tests %}
<tr>
<td>
<small class="text-muted">
{{ test.created_at.strftime('%Y-%m-%d %H:%M:%S') if test.created_at else 'N/A' }}
</small>
</td>
<td>
{% if test.command %}
{% set role_from_command = test.command.split()[2] if test.command.split()|length > 2 else 'N/A' %}
<a href="/roles/{{ role_from_command }}" class="text-decoration-none">
{{ role_from_command }}
</a>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
<span class="badge bg-secondary">
{% if test.command %}
{% set preset_from_command = test.command.split()[3] if test.command.split()|length > 3 else 'default' %}
{{ preset_from_command }}
{% else %}
default
{% endif %}
</span>
</td>
<td>
{% if test.status == 'success' %}
<span class="badge bg-success">
<i class="fas fa-check-circle me-1"></i>
Успешно
</span>
{% elif test.status == 'failed' %}
<span class="badge bg-danger">
<i class="fas fa-times-circle me-1"></i>
Ошибка
</span>
{% elif test.status == 'running' %}
<span class="badge bg-info">
<i class="fas fa-spinner fa-spin me-1"></i>
Выполняется
</span>
{% else %}
<span class="badge bg-secondary">
{{ test.status or 'N/A' }}
</span>
{% endif %}
</td>
<td>
{% if test.duration %}
<small class="text-muted">{{ test.duration }}s</small>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<code class="small">{{ test.command[:50] }}{% if test.command|length > 50 %}...{% endif %}</code>
</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" class="btn btn-primary">
<i class="fas fa-vial me-2"></i>
Запустить тест
</a>
</div>
{% endif %}
</div>
{% if tests 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 }}&per_page={{ per_page }}{% if role_name %}&role_name={{ role_name }}{% 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 }}&per_page={{ per_page }}{% if role_name %}&role_name={{ role_name }}{% 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 }}&per_page={{ per_page }}{% if role_name %}&role_name={{ role_name }}{% 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="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,54 @@
{% extends "base.html" %}
{% block title %}Vault - DevOpsLab{% endblock %}
{% block page_title %}Ansible Vault{% endblock %}
{% block content %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Vault файлы</h5>
</div>
<div class="card-body">
{% if vault_files %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Имя файла</th>
<th>Размер</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for file in vault_files %}
<tr>
<td>{{ file.name }}</td>
<td>{{ (file.size / 1024)|round(2) }} KB</td>
<td>
<button class="btn btn-sm btn-outline-primary" title="Просмотр">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" title="Редактировать">
<i class="fas fa-edit"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-lock fa-3x text-muted mb-3"></i>
<p class="text-muted mb-2">Vault файлы не найдены</p>
{% if vault_dir %}
<p class="text-muted small">Директория: <code>{{ vault_dir }}</code></p>
{% endif %}
<p class="text-muted small mt-3">
Создайте файлы в директории <code>vault/</code> для управления секретами через Ansible Vault.
</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}