feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
111
app/templates/pages/auth/change-password.html
Normal file
111
app/templates/pages/auth/change-password.html
Normal 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 %}
|
||||
83
app/templates/pages/auth/login.html
Normal file
83
app/templates/pages/auth/login.html
Normal 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 %}
|
||||
121
app/templates/pages/dashboard.html
Normal file
121
app/templates/pages/dashboard.html
Normal 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 %}
|
||||
273
app/templates/pages/deploy/index.html
Normal file
273
app/templates/pages/deploy/index.html
Normal 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 %}
|
||||
196
app/templates/pages/deploy/inventory.html
Normal file
196
app/templates/pages/deploy/inventory.html
Normal 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 %}
|
||||
26
app/templates/pages/docker/index.html
Normal file
26
app/templates/pages/docker/index.html
Normal 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 %}
|
||||
352
app/templates/pages/dockerfiles/all-build-logs.html
Normal file
352
app/templates/pages/dockerfiles/all-build-logs.html
Normal 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 %}
|
||||
243
app/templates/pages/dockerfiles/build-logs.html
Normal file
243
app/templates/pages/dockerfiles/build-logs.html
Normal 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 %}
|
||||
993
app/templates/pages/dockerfiles/build.html
Normal file
993
app/templates/pages/dockerfiles/build.html
Normal 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 %}
|
||||
163
app/templates/pages/dockerfiles/create.html
Normal file
163
app/templates/pages/dockerfiles/create.html
Normal 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 %}
|
||||
186
app/templates/pages/dockerfiles/detail.html
Normal file
186
app/templates/pages/dockerfiles/detail.html
Normal 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 %}
|
||||
122
app/templates/pages/dockerfiles/edit.html
Normal file
122
app/templates/pages/dockerfiles/edit.html
Normal 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 %}
|
||||
122
app/templates/pages/dockerfiles/list.html
Normal file
122
app/templates/pages/dockerfiles/list.html
Normal 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 %}
|
||||
42
app/templates/pages/errors/401.html
Normal file
42
app/templates/pages/errors/401.html
Normal 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 %}
|
||||
42
app/templates/pages/errors/403.html
Normal file
42
app/templates/pages/errors/403.html
Normal 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 %}
|
||||
42
app/templates/pages/errors/404.html
Normal file
42
app/templates/pages/errors/404.html
Normal 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 %}
|
||||
52
app/templates/pages/errors/500.html
Normal file
52
app/templates/pages/errors/500.html
Normal 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 %}
|
||||
46
app/templates/pages/errors/502.html
Normal file
46
app/templates/pages/errors/502.html
Normal 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 %}
|
||||
42
app/templates/pages/errors/503.html
Normal file
42
app/templates/pages/errors/503.html
Normal 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 %}
|
||||
41
app/templates/pages/errors/error.html
Normal file
41
app/templates/pages/errors/error.html
Normal 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 %}
|
||||
27
app/templates/pages/k8s/index.html
Normal file
27
app/templates/pages/k8s/index.html
Normal 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 %}
|
||||
115
app/templates/pages/lint/index.html
Normal file
115
app/templates/pages/lint/index.html
Normal 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 %}
|
||||
79
app/templates/pages/lint/role.html
Normal file
79
app/templates/pages/lint/role.html
Normal 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 %}
|
||||
176
app/templates/pages/playbooks/create.html
Normal file
176
app/templates/pages/playbooks/create.html
Normal file
@@ -0,0 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Создать Playbook - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Создать Playbook{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form
|
||||
hx-post="/api/v1/playbooks"
|
||||
hx-target="#playbook-result"
|
||||
hx-swap="innerHTML"
|
||||
id="playbook-form"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Название playbook</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
class="form-control"
|
||||
placeholder="my-playbook"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
title="Только строчные буквы, цифры и дефисы"
|
||||
>
|
||||
<div class="form-text">
|
||||
Только строчные буквы, цифры и дефисы
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Описание</label>
|
||||
<textarea
|
||||
name="description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="Описание playbook..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Роли</label>
|
||||
<div class="form-text mb-2">
|
||||
Выберите роли для включения в playbook
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
{% for role in roles %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
name="roles"
|
||||
value="{{ role }}"
|
||||
id="role-{{ role }}"
|
||||
>
|
||||
<label class="form-check-label" for="role-{{ role }}">
|
||||
{{ role }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if not roles %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
Роли не найдены. Создайте роли перед созданием playbook.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Переменные (YAML)</label>
|
||||
<textarea
|
||||
name="variables"
|
||||
id="variables-editor"
|
||||
class="form-control font-monospace"
|
||||
rows="10"
|
||||
placeholder="vars:
|
||||
key1: value1
|
||||
key2: value2"
|
||||
></textarea>
|
||||
<div class="form-text">
|
||||
Переменные в формате YAML (опционально)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Инвентарь (YAML)</label>
|
||||
<textarea
|
||||
name="inventory"
|
||||
id="inventory-editor"
|
||||
class="form-control font-monospace"
|
||||
rows="10"
|
||||
placeholder="all:
|
||||
hosts:
|
||||
host1:
|
||||
ansible_host: 192.168.1.10
|
||||
host2:
|
||||
ansible_host: 192.168.1.11"
|
||||
></textarea>
|
||||
<div class="form-text">
|
||||
Инвентарь в формате YAML (опционально)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playbook-result"></div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Создать playbook
|
||||
</button>
|
||||
<a href="/playbooks" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-2"></i>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Инициализация редакторов
|
||||
if (typeof CodeEditor !== 'undefined') {
|
||||
const varsEditor = CodeEditor.init('variables-editor', 'yaml');
|
||||
const invEditor = CodeEditor.init('inventory-editor', 'yaml');
|
||||
|
||||
// Валидация при изменении
|
||||
if (varsEditor) {
|
||||
varsEditor.on('change', function() {
|
||||
const content = varsEditor.getValue();
|
||||
if (content.trim()) {
|
||||
const validation = CodeEditor.validateYAML(content);
|
||||
if (!validation.valid) {
|
||||
CodeEditor.showErrors(varsEditor, validation.errors);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (invEditor) {
|
||||
invEditor.on('change', function() {
|
||||
const content = invEditor.getValue();
|
||||
if (content.trim()) {
|
||||
const validation = CodeEditor.validateYAML(content);
|
||||
if (!validation.valid) {
|
||||
CodeEditor.showErrors(invEditor, validation.errors);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка формы
|
||||
const form = document.getElementById('playbook-form');
|
||||
form.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.xhr.status === 200) {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
window.location.href = `/playbooks/${response.id}`;
|
||||
} else {
|
||||
const resultDiv = document.getElementById('playbook-result');
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger mt-3">${response.detail || 'Ошибка при создании playbook'}</div>`;
|
||||
} catch (e) {
|
||||
resultDiv.innerHTML = '<div class="alert alert-danger mt-3">Ошибка при создании playbook</div>';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
431
app/templates/pages/playbooks/detail.html
Normal file
431
app/templates/pages/playbooks/detail.html
Normal file
@@ -0,0 +1,431 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Playbook: {{ playbook.name }} - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Playbook: {{ playbook.name }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/playbooks/{{ playbook.id }}/edit" class="btn btn-primary btn-sm me-2">
|
||||
<i class="fas fa-edit me-2"></i>
|
||||
Редактировать
|
||||
</a>
|
||||
<a href="/playbooks" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Назад
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="playbookManager({{ playbook.id }})">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Информация</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if playbook.description %}
|
||||
<p class="mb-3">{{ playbook.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-2">
|
||||
<strong>Роли:</strong>
|
||||
{% for role in playbook.roles %}
|
||||
<span class="badge bg-info me-1">{{ role }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<strong>Статус:</strong>
|
||||
<span class="badge {% if playbook.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{{ playbook.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<strong>Создан:</strong> {{ playbook.created_at.strftime('%d.%m.%Y %H:%M') if playbook.created_at else 'N/A' }}
|
||||
</div>
|
||||
{% if playbook.updated_at %}
|
||||
<div class="mb-2">
|
||||
<strong>Обновлен:</strong> {{ playbook.updated_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Действия -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Действия</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Preset для тестирования</label>
|
||||
<select x-model="testPreset" class="form-select">
|
||||
<option value="default">default</option>
|
||||
<option value="minimal">minimal</option>
|
||||
<option value="all-images">all-images</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
@click="startTest"
|
||||
:disabled="testRunning || deployRunning"
|
||||
class="btn btn-success w-100"
|
||||
>
|
||||
<i class="fas fa-vial me-2" x-show="!testRunning"></i>
|
||||
<i class="fas fa-spinner fa-spin me-2" x-show="testRunning"></i>
|
||||
<span x-show="!testRunning">Запустить тест</span>
|
||||
<span x-show="testRunning">Тест выполняется...</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Inventory для деплоя</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="deployInventory"
|
||||
class="form-control"
|
||||
placeholder="inventory/hosts.ini или путь к файлу"
|
||||
:value="'{{ playbook.inventory or '' }}'"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
@click="startDeploy"
|
||||
:disabled="testRunning || deployRunning"
|
||||
class="btn btn-warning w-100"
|
||||
>
|
||||
<i class="fas fa-rocket me-2" x-show="!deployRunning"></i>
|
||||
<i class="fas fa-spinner fa-spin me-2" x-show="deployRunning"></i>
|
||||
<span x-show="!deployRunning">Запустить деплой</span>
|
||||
<span x-show="deployRunning">Деплой выполняется...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Playbook (YAML)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<textarea id="playbook-content" class="form-control" rows="15" readonly>{{ playbook.content }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if playbook.inventory %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Инвентарь</h5>
|
||||
<button
|
||||
@click="editInventory = !editInventory"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
>
|
||||
<i class="fas fa-edit me-1"></i>
|
||||
<span x-show="!editInventory">Редактировать</span>
|
||||
<span x-show="editInventory">Отменить</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<textarea
|
||||
id="inventory-content"
|
||||
class="form-control"
|
||||
rows="10"
|
||||
:readonly="!editInventory"
|
||||
x-text="inventoryContent"
|
||||
>{{ playbook.inventory }}</textarea>
|
||||
<div class="mt-2" x-show="editInventory">
|
||||
<button
|
||||
@click="saveInventory"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
<i class="fas fa-save me-1"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Последние тесты</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if test_runs %}
|
||||
<div class="list-group">
|
||||
{% for test_run in test_runs %}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>
|
||||
<i class="fas fa-vial me-2"></i>
|
||||
{{ test_run.preset_name or 'default' }}
|
||||
</span>
|
||||
<span class="badge {% if test_run.status == 'success' %}bg-success{% elif test_run.status == 'failed' %}bg-danger{% else %}bg-warning{% endif %}">
|
||||
{{ test_run.status }}
|
||||
</span>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{ test_run.started_at.strftime('%d.%m.%Y %H:%M') if test_run.started_at else 'N/A' }}
|
||||
</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Тесты еще не запускались</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Последние деплои</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if deployments %}
|
||||
<div class="list-group">
|
||||
{% for deployment in deployments %}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>
|
||||
<i class="fas fa-rocket me-2"></i>
|
||||
Деплой #{{ deployment.id }}
|
||||
</span>
|
||||
<span class="badge {% if deployment.status == 'success' %}bg-success{% elif deployment.status == 'failed' %}bg-danger{% else %}bg-warning{% endif %}">
|
||||
{{ deployment.status }}
|
||||
</span>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{ deployment.started_at.strftime('%d.%m.%Y %H:%M') if deployment.started_at else 'N/A' }}
|
||||
</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Деплои еще не выполнялись</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live логи -->
|
||||
<div class="card mt-3" x-show="testRunning || deployRunning || logs.length > 0">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Логи выполнения</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="log-container" style="max-height: 500px; overflow-y: auto; font-family: monospace; font-size: 0.875rem; background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 0.25rem;" x-ref="logContainer">
|
||||
<template x-for="log in logs" :key="log.id">
|
||||
<div
|
||||
class="log-line mb-1"
|
||||
:class="{
|
||||
'text-danger': log.type === 'error',
|
||||
'text-warning': log.type === 'warning',
|
||||
'text-info': log.type === 'info',
|
||||
'text-success': log.type === 'success'
|
||||
}"
|
||||
x-html="log.data"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button
|
||||
@click="clearLogs"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
>
|
||||
<i class="fas fa-trash me-2"></i>
|
||||
Очистить
|
||||
</button>
|
||||
<button
|
||||
@click="downloadLogs"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
>
|
||||
<i class="fas fa-download me-2"></i>
|
||||
Скачать логи
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function playbookManager(playbookId) {
|
||||
return {
|
||||
playbookId: playbookId,
|
||||
testRunning: false,
|
||||
deployRunning: false,
|
||||
testPreset: 'default',
|
||||
deployInventory: '{{ playbook.inventory or "" }}',
|
||||
editInventory: false,
|
||||
inventoryContent: '{{ playbook.inventory or "" }}',
|
||||
logs: [],
|
||||
ws: null,
|
||||
async startTest() {
|
||||
this.testRunning = true;
|
||||
this.logs = [];
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('preset', this.testPreset);
|
||||
|
||||
const response = await fetch(`/api/v1/playbooks/${this.playbookId}/test`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.websocket_url) {
|
||||
this.connectWebSocket(data.websocket_url, 'test');
|
||||
} else {
|
||||
this.addLog('error', 'Не удалось получить WebSocket URL');
|
||||
this.testRunning = false;
|
||||
}
|
||||
} catch (error) {
|
||||
this.addLog('error', `Ошибка запуска теста: ${error.message}`);
|
||||
this.testRunning = false;
|
||||
}
|
||||
},
|
||||
async startDeploy() {
|
||||
if (!this.deployInventory) {
|
||||
alert('Укажите inventory для деплоя');
|
||||
return;
|
||||
}
|
||||
|
||||
this.deployRunning = true;
|
||||
this.logs = [];
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('inventory', this.deployInventory);
|
||||
|
||||
const response = await fetch(`/api/v1/playbooks/${this.playbookId}/deploy`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.websocket_url) {
|
||||
this.connectWebSocket(data.websocket_url, 'deploy');
|
||||
} else {
|
||||
this.addLog('error', 'Не удалось получить WebSocket URL');
|
||||
this.deployRunning = false;
|
||||
}
|
||||
} catch (error) {
|
||||
this.addLog('error', `Ошибка запуска деплоя: ${error.message}`);
|
||||
this.deployRunning = false;
|
||||
}
|
||||
},
|
||||
connectWebSocket(url, type) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
this.ws = new WebSocket(`${protocol}//${window.location.host}${url}`);
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'log' || data.type === 'info') {
|
||||
this.addLog(data.level || 'info', data.data);
|
||||
} else if (data.type === 'complete') {
|
||||
this.addLog('success', data.data || '✅ Выполнение завершено');
|
||||
if (type === 'test') {
|
||||
this.testRunning = false;
|
||||
} else {
|
||||
this.deployRunning = false;
|
||||
}
|
||||
this.ws.close();
|
||||
} else if (data.type === 'error') {
|
||||
this.addLog('error', data.data || '❌ Ошибка');
|
||||
if (type === 'test') {
|
||||
this.testRunning = false;
|
||||
} else {
|
||||
this.deployRunning = false;
|
||||
}
|
||||
this.ws.close();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
this.addLog('error', `Ошибка WebSocket: ${error}`);
|
||||
if (type === 'test') {
|
||||
this.testRunning = false;
|
||||
} else {
|
||||
this.deployRunning = false;
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
if (type === 'test') {
|
||||
this.testRunning = false;
|
||||
} else {
|
||||
this.deployRunning = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
addLog(type, message) {
|
||||
this.logs.push({
|
||||
id: Date.now() + Math.random(),
|
||||
type: type,
|
||||
data: message.replace(/\n/g, '<br>')
|
||||
});
|
||||
|
||||
this.$nextTick(() => {
|
||||
const container = this.$refs.logContainer;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
},
|
||||
clearLogs() {
|
||||
this.logs = [];
|
||||
},
|
||||
downloadLogs() {
|
||||
const content = this.logs.map(l => l.data.replace(/<br>/g, '\n')).join('\n');
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `playbook-${this.playbookId}-${Date.now()}.log`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
async saveInventory() {
|
||||
const content = document.getElementById('inventory-content').value;
|
||||
// TODO: Реализовать сохранение через API
|
||||
this.inventoryContent = content;
|
||||
this.editInventory = false;
|
||||
alert('Inventory сохранен (TODO: реализовать API)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация CodeMirror для playbook content
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (typeof CodeMirror !== 'undefined') {
|
||||
const playbookEditor = CodeMirror.fromTextArea(
|
||||
document.getElementById('playbook-content'),
|
||||
{
|
||||
mode: 'yaml',
|
||||
theme: 'monokai',
|
||||
readOnly: true,
|
||||
lineNumbers: true
|
||||
}
|
||||
);
|
||||
|
||||
const inventoryEditor = document.getElementById('inventory-content');
|
||||
if (inventoryEditor) {
|
||||
let inventoryCodeMirror = CodeMirror.fromTextArea(inventoryEditor, {
|
||||
mode: 'yaml',
|
||||
theme: 'monokai',
|
||||
readOnly: true,
|
||||
lineNumbers: true
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
217
app/templates/pages/playbooks/edit.html
Normal file
217
app/templates/pages/playbooks/edit.html
Normal file
@@ -0,0 +1,217 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Редактировать Playbook - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Редактировать Playbook: {{ playbook.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<ul class="nav nav-tabs mb-3" id="playbook-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="info-tab" data-bs-toggle="tab" data-bs-target="#info" type="button" role="tab">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Информация
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="content-tab" data-bs-toggle="tab" data-bs-target="#content" type="button" role="tab">
|
||||
<i class="fas fa-code me-2"></i>
|
||||
Playbook (YAML)
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="inventory-tab" data-bs-toggle="tab" data-bs-target="#inventory" type="button" role="tab">
|
||||
<i class="fas fa-server me-2"></i>
|
||||
Инвентарь
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="playbook-tab-content">
|
||||
<!-- Информация -->
|
||||
<div class="tab-pane fade show active" id="info" role="tabpanel">
|
||||
<form
|
||||
hx-put="/api/v1/playbooks/{{ playbook.id }}"
|
||||
hx-target="#info-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
class="form-control"
|
||||
value="{{ playbook.name }}"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Описание</label>
|
||||
<textarea
|
||||
name="description"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
>{{ playbook.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Роли</label>
|
||||
<div class="row g-2">
|
||||
{% for role in all_roles %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
name="roles"
|
||||
value="{{ role }}"
|
||||
id="role-{{ role }}"
|
||||
{% if role in playbook.roles %}checked{% endif %}
|
||||
>
|
||||
<label class="form-check-label" for="role-{{ role }}">
|
||||
{{ role }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="info-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Playbook YAML -->
|
||||
<div class="tab-pane fade" id="content" role="tabpanel">
|
||||
<form
|
||||
hx-put="/api/v1/playbooks/{{ playbook.id }}"
|
||||
hx-target="#content-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<input type="hidden" name="content" id="playbook-content-hidden">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Playbook (YAML)</label>
|
||||
<textarea
|
||||
id="playbook-content-editor"
|
||||
class="form-control font-monospace"
|
||||
rows="20"
|
||||
>{{ playbook.content }}</textarea>
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Редактируйте YAML содержимое playbook. Валидация выполняется автоматически.
|
||||
</div>
|
||||
</div>
|
||||
<div id="content-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Инвентарь -->
|
||||
<div class="tab-pane fade" id="inventory" role="tabpanel">
|
||||
<form
|
||||
hx-put="/api/v1/playbooks/{{ playbook.id }}"
|
||||
hx-target="#inventory-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<input type="hidden" name="inventory" id="inventory-hidden">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Инвентарь (YAML или INI)</label>
|
||||
<textarea
|
||||
id="inventory-editor"
|
||||
class="form-control font-monospace"
|
||||
rows="20"
|
||||
>{{ playbook.inventory or '' }}</textarea>
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Инвентарь в формате YAML или INI. Редактор автоматически определит формат.
|
||||
</div>
|
||||
</div>
|
||||
<div id="inventory-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Инициализация редакторов
|
||||
if (typeof CodeEditor !== 'undefined') {
|
||||
const playbookEditor = CodeEditor.init('playbook-content-editor', 'yaml', {
|
||||
theme: 'monokai',
|
||||
lineNumbers: true,
|
||||
foldGutter: true
|
||||
});
|
||||
|
||||
const inventoryEditor = CodeEditor.init('inventory-editor', 'yaml', {
|
||||
theme: 'monokai',
|
||||
lineNumbers: true,
|
||||
foldGutter: true
|
||||
});
|
||||
|
||||
// Валидация playbook при изменении
|
||||
if (playbookEditor) {
|
||||
playbookEditor.on('change', function() {
|
||||
const content = playbookEditor.getValue();
|
||||
const validation = CodeEditor.validateAnsible(content);
|
||||
if (!validation.valid) {
|
||||
CodeEditor.showErrors(playbookEditor, validation.errors);
|
||||
} else {
|
||||
// Очищаем ошибки если валидация прошла
|
||||
if (playbookEditor._validationMarkers) {
|
||||
playbookEditor._validationMarkers.forEach(m => m.clear());
|
||||
playbookEditor._validationMarkers = [];
|
||||
}
|
||||
}
|
||||
// Сохраняем в hidden поле для отправки формы
|
||||
document.getElementById('playbook-content-hidden').value = content;
|
||||
});
|
||||
}
|
||||
|
||||
// Валидация инвентаря
|
||||
if (inventoryEditor) {
|
||||
inventoryEditor.on('change', function() {
|
||||
const content = inventoryEditor.getValue();
|
||||
// Сохраняем в hidden поле для отправки формы
|
||||
document.getElementById('inventory-hidden').value = content;
|
||||
|
||||
if (content.trim()) {
|
||||
// Определяем формат (INI или YAML)
|
||||
const isINI = content.trim().startsWith('[') || content.includes('ansible_host=');
|
||||
let validation;
|
||||
if (isINI) {
|
||||
// Простая валидация INI
|
||||
validation = { valid: content.includes('[') && content.includes(']'), errors: [] };
|
||||
} else {
|
||||
validation = CodeEditor.validateYAML(content);
|
||||
}
|
||||
|
||||
if (!validation.valid) {
|
||||
CodeEditor.showErrors(inventoryEditor, validation.errors);
|
||||
} else {
|
||||
if (inventoryEditor._validationMarkers) {
|
||||
inventoryEditor._validationMarkers.forEach(m => m.clear());
|
||||
inventoryEditor._validationMarkers = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
96
app/templates/pages/playbooks/list.html
Normal file
96
app/templates/pages/playbooks/list.html
Normal file
@@ -0,0 +1,96 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Playbook - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Playbook{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/playbooks/create" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-plus me-2"></i>
|
||||
Создать playbook
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row g-3" id="playbooks-list">
|
||||
{% for playbook in playbooks %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-0">
|
||||
<a href="/playbooks/{{ playbook.id }}" class="text-decoration-none">
|
||||
{{ playbook.name }}
|
||||
</a>
|
||||
</h5>
|
||||
<div class="btn-group">
|
||||
<a
|
||||
href="/playbooks/{{ playbook.id }}/edit"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
title="Редактировать"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button
|
||||
hx-delete="/api/v1/playbooks/{{ playbook.id }}"
|
||||
hx-confirm="Удалить playbook '{{ playbook.name }}'?"
|
||||
hx-target="closest .col-12"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
title="Удалить"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if playbook.description %}
|
||||
<p class="card-text text-muted small mb-3">{{ playbook.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="small text-muted mb-2">
|
||||
<i class="fas fa-tasks me-1"></i>
|
||||
Ролей: <span class="fw-semibold">{{ playbook.roles|length }}</span>
|
||||
</div>
|
||||
{% if playbook.roles %}
|
||||
<div class="small text-muted mb-2">
|
||||
<i class="fas fa-list me-1"></i>
|
||||
Роли:
|
||||
{% for role in playbook.roles[:3] %}
|
||||
<span class="badge bg-info me-1">{{ role }}</span>
|
||||
{% endfor %}
|
||||
{% if playbook.roles|length > 3 %}
|
||||
<span class="text-muted">+{{ playbook.roles|length - 3 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="small text-muted mb-2">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Статус:
|
||||
<span class="badge {% if playbook.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{{ playbook.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-3">
|
||||
<a
|
||||
href="/playbooks/{{ playbook.id }}"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Детали
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Playbook'ов пока нет. <a href="/playbooks/create">Создайте первый playbook</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
198
app/templates/pages/presets/create.html
Normal file
198
app/templates/pages/presets/create.html
Normal 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 %}
|
||||
268
app/templates/pages/presets/detail.html
Normal file
268
app/templates/pages/presets/detail.html
Normal 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 %}
|
||||
518
app/templates/pages/presets/edit.html
Normal file
518
app/templates/pages/presets/edit.html
Normal 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 %}
|
||||
227
app/templates/pages/presets/list.html
Normal file
227
app/templates/pages/presets/list.html
Normal 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 %}
|
||||
153
app/templates/pages/profile/docker-settings.html
Normal file
153
app/templates/pages/profile/docker-settings.html
Normal 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 %}
|
||||
340
app/templates/pages/profile/index.html
Normal file
340
app/templates/pages/profile/index.html
Normal 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 %}
|
||||
307
app/templates/pages/roles/create.html
Normal file
307
app/templates/pages/roles/create.html
Normal file
@@ -0,0 +1,307 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Создать роль - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Создание новой роли{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/roles" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Назад к списку
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="roleCreator()">
|
||||
<form
|
||||
hx-post="/api/v1/roles/create"
|
||||
hx-target="#result"
|
||||
hx-swap="innerHTML"
|
||||
@submit.prevent="submitForm"
|
||||
class="card"
|
||||
>
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Шаг 1: Базовая информация</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Имя роли *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="role_name"
|
||||
x-model="formData.role_name"
|
||||
required
|
||||
pattern="[a-z0-9_-]+"
|
||||
class="form-control"
|
||||
placeholder="nginx, docker, python"
|
||||
>
|
||||
<div class="form-text">
|
||||
Только строчные буквы, цифры, дефисы и подчеркивания
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Описание</label>
|
||||
<textarea
|
||||
name="description"
|
||||
x-model="formData.description"
|
||||
rows="3"
|
||||
class="form-control"
|
||||
placeholder="Краткое описание роли..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Тип роли (шаблон)</label>
|
||||
<select
|
||||
name="template"
|
||||
x-model="formData.template"
|
||||
class="form-select"
|
||||
>
|
||||
<option value="default">По умолчанию</option>
|
||||
<option value="service">Сервис (service)</option>
|
||||
<option value="package">Пакеты (package)</option>
|
||||
<option value="config">Конфигурация (config)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
hx-post="/api/v1/roles/create"
|
||||
hx-target="#result"
|
||||
hx-swap="innerHTML"
|
||||
@submit.prevent="submitForm"
|
||||
class="card mt-3"
|
||||
>
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Шаг 2: Поддерживаемые ОС</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value="ubuntu"
|
||||
x-model="formData.platforms"
|
||||
id="platform-ubuntu"
|
||||
>
|
||||
<label class="form-check-label" for="platform-ubuntu">
|
||||
Ubuntu
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value="debian"
|
||||
x-model="formData.platforms"
|
||||
id="platform-debian"
|
||||
>
|
||||
<label class="form-check-label" for="platform-debian">
|
||||
Debian
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value="centos"
|
||||
x-model="formData.platforms"
|
||||
id="platform-centos"
|
||||
>
|
||||
<label class="form-check-label" for="platform-centos">
|
||||
CentOS
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value="rhel"
|
||||
x-model="formData.platforms"
|
||||
id="platform-rhel"
|
||||
>
|
||||
<label class="form-check-label" for="platform-rhel">
|
||||
RHEL
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value="almalinux"
|
||||
x-model="formData.platforms"
|
||||
id="platform-almalinux"
|
||||
>
|
||||
<label class="form-check-label" for="platform-almalinux">
|
||||
AlmaLinux
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value="rocky"
|
||||
x-model="formData.platforms"
|
||||
id="platform-rocky"
|
||||
>
|
||||
<label class="form-check-label" for="platform-rocky">
|
||||
Rocky Linux
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
hx-post="/api/v1/roles/create"
|
||||
hx-target="#result"
|
||||
hx-swap="innerHTML"
|
||||
@submit.prevent="submitForm"
|
||||
class="card mt-3"
|
||||
>
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Шаг 3: Переменные (опционально)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="space-y-2 mb-3" x-ref="variablesContainer">
|
||||
<template x-for="(variable, index) in formData.variables" :key="index">
|
||||
<div class="row g-2 mb-2 align-items-end">
|
||||
<div class="col-12 col-md-4">
|
||||
<input
|
||||
type="text"
|
||||
x-model="variable.name"
|
||||
placeholder="Имя переменной"
|
||||
class="form-control"
|
||||
>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<input
|
||||
type="text"
|
||||
x-model="variable.value"
|
||||
placeholder="Значение по умолчанию"
|
||||
class="form-control"
|
||||
>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<select
|
||||
x-model="variable.type"
|
||||
class="form-select"
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="int">Integer</option>
|
||||
<option value="bool">Boolean</option>
|
||||
<option value="list">List</option>
|
||||
<option value="dict">Dict</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-1">
|
||||
<button
|
||||
type="button"
|
||||
@click="removeVariable(index)"
|
||||
class="btn btn-danger btn-sm w-100"
|
||||
title="Удалить"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addVariable"
|
||||
class="btn btn-outline-secondary"
|
||||
>
|
||||
<i class="fas fa-plus me-2"></i>
|
||||
Добавить переменную
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Скрытые поля для отправки -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="role_name"
|
||||
:value="formData.role_name"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="description"
|
||||
:value="formData.description"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="template"
|
||||
:value="formData.template"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="platforms"
|
||||
:value="JSON.stringify(formData.platforms)"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="variables"
|
||||
:value="JSON.stringify(formData.variables)"
|
||||
>
|
||||
|
||||
<!-- Результат -->
|
||||
<div id="result" class="card-body border-top"></div>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div class="card-footer">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-check me-2"></i>
|
||||
Создать роль
|
||||
</button>
|
||||
<a href="/roles" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-2"></i>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function roleCreator() {
|
||||
return {
|
||||
formData: {
|
||||
role_name: '',
|
||||
description: '',
|
||||
template: 'default',
|
||||
platforms: [],
|
||||
variables: []
|
||||
},
|
||||
addVariable() {
|
||||
this.formData.variables.push({
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'string'
|
||||
});
|
||||
},
|
||||
removeVariable(index) {
|
||||
this.formData.variables.splice(index, 1);
|
||||
},
|
||||
submitForm(event) {
|
||||
// HTMX обработает отправку
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
309
app/templates/pages/roles/deploy.html
Normal file
309
app/templates/pages/roles/deploy.html
Normal file
@@ -0,0 +1,309 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Деплой {{ role_name }} - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Деплой роли: {{ role_name }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/roles/{{ role_name }}" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Назад
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="deployRunner()">
|
||||
<!-- Предупреждение -->
|
||||
<div class="alert alert-warning mb-3" role="alert">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>⚠️ ВНИМАНИЕ:</strong> Вы собираетесь изменить реальные серверы! Убедитесь, что вы понимаете последствия.
|
||||
</div>
|
||||
|
||||
<!-- Проверка наличия необходимых файлов -->
|
||||
{% if not inventory_exists %}
|
||||
<div class="alert alert-danger mb-3" role="alert">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
<strong>❌ Inventory файл не найден!</strong>
|
||||
<p class="mb-0 mt-2">Создайте файл <code>inventory/hosts.ini</code> с вашими серверами или используйте <a href="/deploy/inventory" class="alert-link">редактор inventory</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not deploy_playbook_exists %}
|
||||
<div class="alert alert-danger mb-3" role="alert">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
<strong>❌ Playbook deploy.yml не найден!</strong>
|
||||
<p class="mb-0 mt-2">Создайте файл <code>roles/deploy.yml</code> для развертывания ролей.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Настройки деплоя -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Настройки деплоя</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="startDeploy">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Inventory файл</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="deployConfig.inventory"
|
||||
value="inventory/hosts.ini"
|
||||
class="form-control"
|
||||
placeholder="inventory/hosts.ini"
|
||||
>
|
||||
<div class="form-text">
|
||||
Путь к inventory файлу относительно корня проекта
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Limit (опционально)</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="deployConfig.limit"
|
||||
class="form-control"
|
||||
placeholder="webservers или host1,host2"
|
||||
>
|
||||
<div class="form-text">
|
||||
Ограничение на хосты для деплоя
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tags (опционально)</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="deployConfig.tags"
|
||||
class="form-control"
|
||||
placeholder="web,database или оставьте пустым для всех тегов"
|
||||
>
|
||||
<div class="form-text">
|
||||
Теги для фильтрации задач (через запятую)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Дополнительные переменные (JSON, опционально)</label>
|
||||
<textarea
|
||||
x-model="deployConfig.extra_vars"
|
||||
rows="3"
|
||||
class="form-control font-monospace"
|
||||
placeholder='{"app_version": "1.0.0", "nginx_enabled": true}'
|
||||
></textarea>
|
||||
<div class="form-text">
|
||||
Укажите переменные в формате JSON
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
x-model="deployConfig.check"
|
||||
id="deploy-check"
|
||||
>
|
||||
<label class="form-check-label" for="deploy-check">
|
||||
Dry-run режим (--check) - изменения не будут применены
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="deployRunning || !inventoryExists || !deployPlaybookExists"
|
||||
>
|
||||
<i class="fas fa-rocket me-2" x-show="!deployRunning"></i>
|
||||
<i class="fas fa-spinner fa-spin me-2" x-show="deployRunning"></i>
|
||||
<span x-show="!deployRunning">Запустить деплой</span>
|
||||
<span x-show="deployRunning">Деплой выполняется...</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live логи -->
|
||||
<div class="card" x-show="deployRunning || logs.length > 0">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Логи деплоя</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="log-container" id="deploy-logs" x-ref="logContainer" style="max-height: 500px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 0.25rem; font-family: 'Courier New', monospace; font-size: 0.875rem;">
|
||||
<template x-for="log in logs" :key="log.id">
|
||||
<div
|
||||
class="log-line"
|
||||
:class="{
|
||||
'log-error': log.type === 'error',
|
||||
'log-warning': log.type === 'warning',
|
||||
'log-info': log.type === 'info',
|
||||
'log-success': log.type === 'success'
|
||||
}"
|
||||
x-text="log.data"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button
|
||||
@click="clearLogs"
|
||||
class="btn btn-outline-secondary"
|
||||
>
|
||||
<i class="fas fa-trash me-2"></i>
|
||||
Очистить
|
||||
</button>
|
||||
<button
|
||||
@click="downloadLogs"
|
||||
class="btn btn-outline-secondary"
|
||||
>
|
||||
<i class="fas fa-download me-2"></i>
|
||||
Скачать логи
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function deployRunner() {
|
||||
return {
|
||||
deployRunning: false,
|
||||
logs: [],
|
||||
deployConfig: {
|
||||
inventory: 'inventory/hosts.ini',
|
||||
limit: '',
|
||||
tags: '{{ role_name }}',
|
||||
extra_vars: '',
|
||||
check: false
|
||||
},
|
||||
inventoryExists: {{ 'true' if inventory_exists else 'false' }},
|
||||
deployPlaybookExists: {{ 'true' if deploy_playbook_exists else 'false' }},
|
||||
ws: null,
|
||||
async startDeploy() {
|
||||
if (!this.inventoryExists || !this.deployPlaybookExists) {
|
||||
alert('❌ Необходимые файлы не найдены!');
|
||||
return;
|
||||
}
|
||||
|
||||
this.deployRunning = true;
|
||||
this.logs = [];
|
||||
|
||||
// Формируем deploy_id
|
||||
const deployId = `deploy-{{ role_name }}-${this.deployConfig.tags || 'none'}`;
|
||||
|
||||
// Создание WebSocket подключения
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws/deploy/${deployId}`);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
// Отправляем параметры деплоя
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'start',
|
||||
role_name: '{{ role_name }}',
|
||||
inventory: this.deployConfig.inventory,
|
||||
limit: this.deployConfig.limit || null,
|
||||
tags: this.deployConfig.tags ? this.deployConfig.tags.split(',').map(t => t.trim()) : null,
|
||||
check: this.deployConfig.check,
|
||||
extra_vars: this.deployConfig.extra_vars ? JSON.parse(this.deployConfig.extra_vars) : null
|
||||
}));
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'log' || data.type === 'info') {
|
||||
this.logs.push({
|
||||
id: Date.now() + Math.random(),
|
||||
type: data.level || this.detectLogLevel(data.data),
|
||||
data: data.data
|
||||
});
|
||||
|
||||
// Автоскролл
|
||||
this.$nextTick(() => {
|
||||
const container = this.$refs.logContainer;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
} else if (data.type === 'complete') {
|
||||
this.deployRunning = false;
|
||||
this.logs.push({
|
||||
id: Date.now(),
|
||||
type: data.status === 'success' ? 'success' : 'error',
|
||||
data: data.data || `✅ Деплой завершен: ${data.status}`
|
||||
});
|
||||
this.ws.close();
|
||||
} else if (data.type === 'error') {
|
||||
this.deployRunning = false;
|
||||
this.logs.push({
|
||||
id: Date.now(),
|
||||
type: 'error',
|
||||
data: data.data || `❌ Ошибка`
|
||||
});
|
||||
this.ws.close();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
this.deployRunning = false;
|
||||
this.logs.push({
|
||||
id: Date.now(),
|
||||
type: 'error',
|
||||
data: `❌ Ошибка подключения: ${error}`
|
||||
});
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.deployRunning = false;
|
||||
};
|
||||
},
|
||||
detectLogLevel(line) {
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.includes('error') || lower.includes('failed') || lower.includes('fatal')) return 'error';
|
||||
if (lower.includes('warning') || lower.includes('warn')) return 'warning';
|
||||
if (lower.includes('changed') || lower.includes('ok') || lower.includes('success')) return 'success';
|
||||
if (lower.includes('skipping') || lower.includes('ok')) return 'info';
|
||||
return 'info';
|
||||
},
|
||||
clearLogs() {
|
||||
this.logs = [];
|
||||
},
|
||||
downloadLogs() {
|
||||
const content = this.logs.map(l => l.data).join('\n');
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `deploy-{{ role_name }}-${Date.now()}.log`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.log-line {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.log-error {
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.log-warning {
|
||||
color: #dcdcaa;
|
||||
}
|
||||
|
||||
.log-info {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.log-success {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
234
app/templates/pages/roles/detail.html
Normal file
234
app/templates/pages/roles/detail.html
Normal file
@@ -0,0 +1,234 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ role.name }} - DevOpsLab{% endblock %}
|
||||
{% block page_title %}{{ role.name }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/roles/{{ role.name }}/test" class="btn btn-success btn-sm">
|
||||
<i class="fas fa-vial me-2"></i>
|
||||
Тестировать
|
||||
</a>
|
||||
<a href="/roles/{{ role.name }}/edit" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-edit me-2"></i>
|
||||
Редактировать
|
||||
</a>
|
||||
<a href="/roles" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Назад
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if role.description %}
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
{{ role.description }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Вкладки -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link active"
|
||||
id="overview-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#overview"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Обзор
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link"
|
||||
id="tasks-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#tasks"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-tasks me-2"></i>
|
||||
Задачи
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link"
|
||||
id="defaults-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#defaults"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-sliders-h me-2"></i>
|
||||
Переменные
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link"
|
||||
id="readme-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#readme"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-book me-2"></i>
|
||||
Документация
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Содержимое вкладок -->
|
||||
<div class="card-body">
|
||||
<div class="tab-content" id="role-tabs-content">
|
||||
<!-- Вкладка: Обзор -->
|
||||
<div class="tab-pane fade show active" id="overview" role="tabpanel">
|
||||
<h5 class="mb-4">Информация о роли</h5>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<h6 class="fw-semibold mb-3">Структура</h6>
|
||||
<ul class="list-unstyled">
|
||||
{% if role.has_tasks %}
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check-circle text-success me-2"></i>
|
||||
tasks/main.yml
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="mb-2 text-muted">
|
||||
<i class="fas fa-times-circle me-2"></i>
|
||||
tasks/main.yml
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if role.has_defaults %}
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check-circle text-success me-2"></i>
|
||||
defaults/main.yml
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="mb-2 text-muted">
|
||||
<i class="fas fa-times-circle me-2"></i>
|
||||
defaults/main.yml
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if role.has_handlers %}
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check-circle text-success me-2"></i>
|
||||
handlers/main.yml
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="mb-2 text-muted">
|
||||
<i class="fas fa-times-circle me-2"></i>
|
||||
handlers/main.yml
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if role.has_meta %}
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check-circle text-success me-2"></i>
|
||||
meta/main.yml
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="mb-2 text-muted">
|
||||
<i class="fas fa-times-circle me-2"></i>
|
||||
meta/main.yml
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<h6 class="fw-semibold mb-3">Метаданные</h6>
|
||||
{% if role.author %}
|
||||
<p class="mb-2">
|
||||
<strong>Автор:</strong> {{ role.author }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if role.platforms %}
|
||||
<p class="mb-2"><strong>Платформы:</strong></p>
|
||||
<ul class="list-unstyled">
|
||||
{% for platform in role.platforms %}
|
||||
<li class="mb-1">
|
||||
<i class="fas fa-server me-2 text-muted"></i>
|
||||
{{ platform.name }}
|
||||
{% if platform.versions %}
|
||||
<span class="text-muted">({{ platform.versions|join(", ") }})</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h6 class="fw-semibold mb-3">Быстрые действия</h6>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a href="/roles/{{ role.name }}/test" class="btn btn-success">
|
||||
<i class="fas fa-vial me-2"></i>
|
||||
Запустить тест
|
||||
</a>
|
||||
<a href="/roles/{{ role.name }}/deploy" class="btn btn-primary">
|
||||
<i class="fas fa-rocket me-2"></i>
|
||||
Деплой
|
||||
</a>
|
||||
<a href="/roles/{{ role.name }}/export" class="btn btn-outline-primary">
|
||||
<i class="fas fa-upload me-2"></i>
|
||||
Экспорт
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Вкладка: Задачи -->
|
||||
<div class="tab-pane fade" id="tasks" role="tabpanel">
|
||||
<h5 class="mb-4">Задачи (tasks/main.yml)</h5>
|
||||
{% if tasks_content %}
|
||||
<pre class="bg-dark text-light p-3 rounded"><code>{{ tasks_content }}</code></pre>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
Файл tasks/main.yml не найден
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Вкладка: Переменные -->
|
||||
<div class="tab-pane fade" id="defaults" role="tabpanel">
|
||||
<h5 class="mb-4">Переменные (defaults/main.yml)</h5>
|
||||
{% if defaults_content %}
|
||||
<pre class="bg-dark text-light p-3 rounded"><code>{{ defaults_content }}</code></pre>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
Файл defaults/main.yml не найден
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Вкладка: Документация -->
|
||||
<div class="tab-pane fade" id="readme" role="tabpanel">
|
||||
<h5 class="mb-4">Документация (README.md)</h5>
|
||||
{% if readme_content %}
|
||||
<div class="border rounded p-3 bg-light">
|
||||
<pre class="mb-0" style="white-space: pre-wrap;">{{ readme_content }}</pre>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
Файл README.md не найден
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
456
app/templates/pages/roles/edit.html
Normal file
456
app/templates/pages/roles/edit.html
Normal file
@@ -0,0 +1,456 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Редактировать {{ role.name }} - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Редактирование роли: {{ role.name }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/roles/{{ role.name }}" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Назад
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link active"
|
||||
id="tasks-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#tasks"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-tasks me-2"></i>
|
||||
Tasks
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link"
|
||||
id="handlers-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#handlers"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-cogs me-2"></i>
|
||||
Handlers
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link"
|
||||
id="defaults-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#defaults"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-sliders-h me-2"></i>
|
||||
Defaults
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link"
|
||||
id="vars-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#vars"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-key me-2"></i>
|
||||
Vars
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link"
|
||||
id="meta-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#meta"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Meta
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link"
|
||||
id="readme-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#readme"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
<i class="fas fa-book me-2"></i>
|
||||
README
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="tab-content" id="role-edit-tabs">
|
||||
<!-- Tasks -->
|
||||
<div class="tab-pane fade show active" id="tasks" role="tabpanel">
|
||||
<form
|
||||
hx-post="/api/v1/roles/{{ role.name }}/update"
|
||||
hx-target="#tasks-result"
|
||||
hx-swap="innerHTML"
|
||||
class="mb-3"
|
||||
>
|
||||
<input type="hidden" name="file_type" value="tasks">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">tasks/main.yml</label>
|
||||
<textarea
|
||||
name="content"
|
||||
rows="20"
|
||||
class="form-control font-monospace"
|
||||
required
|
||||
>{{ files_content.get('tasks', '') }}</textarea>
|
||||
</div>
|
||||
<div id="tasks-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Handlers -->
|
||||
<div class="tab-pane fade" id="handlers" role="tabpanel">
|
||||
<form
|
||||
hx-post="/api/v1/roles/{{ role.name }}/update"
|
||||
hx-target="#handlers-result"
|
||||
hx-swap="innerHTML"
|
||||
class="mb-3"
|
||||
>
|
||||
<input type="hidden" name="file_type" value="handlers">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">handlers/main.yml</label>
|
||||
<textarea
|
||||
name="content"
|
||||
id="handlers-content"
|
||||
rows="20"
|
||||
class="form-control font-monospace"
|
||||
required
|
||||
>{{ files_content.get('handlers', '') }}</textarea>
|
||||
</div>
|
||||
<div id="handlers-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Defaults -->
|
||||
<div class="tab-pane fade" id="defaults" role="tabpanel">
|
||||
<form
|
||||
hx-post="/api/v1/roles/{{ role.name }}/update"
|
||||
hx-target="#defaults-result"
|
||||
hx-swap="innerHTML"
|
||||
class="mb-3"
|
||||
>
|
||||
<input type="hidden" name="file_type" value="defaults">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">defaults/main.yml</label>
|
||||
<textarea
|
||||
name="content"
|
||||
id="defaults-content"
|
||||
rows="20"
|
||||
class="form-control font-monospace"
|
||||
required
|
||||
>{{ files_content.get('defaults', '') }}</textarea>
|
||||
</div>
|
||||
<div id="defaults-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Vars -->
|
||||
<div class="tab-pane fade" id="vars" role="tabpanel">
|
||||
<div class="alert alert-warning mb-3">
|
||||
<i class="fas fa-lock me-2"></i>
|
||||
<strong>Внимание:</strong> Если файл зашифрован через Ansible Vault, используйте кнопку "Расшифровать" для редактирования.
|
||||
</div>
|
||||
<form
|
||||
hx-post="/api/v1/roles/{{ role.name }}/update"
|
||||
hx-target="#vars-result"
|
||||
hx-swap="innerHTML"
|
||||
class="mb-3"
|
||||
>
|
||||
<input type="hidden" name="file_type" value="vars">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">vars/main.yml</label>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
onclick="checkVaultEncryption('vars')"
|
||||
>
|
||||
<i class="fas fa-search me-2"></i>
|
||||
Проверить Vault
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
onclick="decryptVault('vars')"
|
||||
id="decrypt-vars-btn"
|
||||
style="display: none;"
|
||||
>
|
||||
<i class="fas fa-unlock me-2"></i>
|
||||
Расшифровать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-success"
|
||||
onclick="encryptVault('vars')"
|
||||
id="encrypt-vars-btn"
|
||||
style="display: none;"
|
||||
>
|
||||
<i class="fas fa-lock me-2"></i>
|
||||
Зашифровать
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
name="content"
|
||||
id="vars-content"
|
||||
rows="20"
|
||||
class="form-control font-monospace"
|
||||
required
|
||||
>{{ files_content.get('vars', '') }}</textarea>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (typeof CodeEditor !== 'undefined') {
|
||||
const editor = CodeEditor.init('vars-content', 'yaml', {
|
||||
theme: 'monokai',
|
||||
lineNumbers: true
|
||||
});
|
||||
if (editor) {
|
||||
editor.on('change', function() {
|
||||
const validation = CodeEditor.validateYAML(editor.getValue());
|
||||
if (!validation.valid) {
|
||||
CodeEditor.showErrors(editor, validation.errors);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Для работы с Vault файлами используйте кнопки выше
|
||||
</div>
|
||||
</div>
|
||||
<div id="vars-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="tab-pane fade" id="meta" role="tabpanel">
|
||||
<form
|
||||
hx-post="/api/v1/roles/{{ role.name }}/update"
|
||||
hx-target="#meta-result"
|
||||
hx-swap="innerHTML"
|
||||
class="mb-3"
|
||||
>
|
||||
<input type="hidden" name="file_type" value="meta">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">meta/main.yml</label>
|
||||
<textarea
|
||||
name="content"
|
||||
id="meta-content"
|
||||
rows="20"
|
||||
class="form-control font-monospace"
|
||||
required
|
||||
>{{ files_content.get('meta', '') }}</textarea>
|
||||
</div>
|
||||
<div id="meta-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- README -->
|
||||
<div class="tab-pane fade" id="readme" role="tabpanel">
|
||||
<form
|
||||
hx-post="/api/v1/roles/{{ role.name }}/update"
|
||||
hx-target="#readme-result"
|
||||
hx-swap="innerHTML"
|
||||
class="mb-3"
|
||||
>
|
||||
<input type="hidden" name="file_type" value="readme">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">README.md</label>
|
||||
<textarea
|
||||
name="content"
|
||||
rows="20"
|
||||
class="form-control font-monospace"
|
||||
required
|
||||
>{{ readme_content }}</textarea>
|
||||
</div>
|
||||
<div id="readme-result"></div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function checkVaultEncryption(fileType) {
|
||||
const textarea = document.getElementById(fileType + '-content');
|
||||
if (!textarea) return;
|
||||
|
||||
const content = textarea.value;
|
||||
|
||||
if (content.includes('$ANSIBLE_VAULT') || content.includes('!vault |')) {
|
||||
document.getElementById('decrypt-' + fileType + '-btn').style.display = 'inline-block';
|
||||
document.getElementById('encrypt-' + fileType + '-btn').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('encrypt-' + fileType + '-btn').style.display = 'inline-block';
|
||||
document.getElementById('decrypt-' + fileType + '-btn').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptVault(fileType) {
|
||||
const textarea = document.getElementById(fileType + '-content');
|
||||
if (!textarea) return;
|
||||
|
||||
const content = textarea.value;
|
||||
|
||||
if (!content.trim()) {
|
||||
alert('Нет содержимого для расшифровки');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/vault/decrypt`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content: content })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
textarea.value = result.decrypted;
|
||||
document.getElementById('decrypt-' + fileType + '-btn').style.display = 'none';
|
||||
document.getElementById('encrypt-' + fileType + '-btn').style.display = 'inline-block';
|
||||
alert('Файл успешно расшифрован');
|
||||
} else {
|
||||
alert('Ошибка расшифровки: ' + (result.error || 'Неизвестная ошибка'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function encryptVault(fileType) {
|
||||
const textarea = document.getElementById(fileType + '-content');
|
||||
if (!textarea) return;
|
||||
|
||||
const content = textarea.value;
|
||||
|
||||
if (!content.trim()) {
|
||||
alert('Нет содержимого для шифрования');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmModal('Зашифровать содержимое через Ansible Vault?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/vault/encrypt`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content: content })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
textarea.value = result.encrypted;
|
||||
document.getElementById('encrypt-' + fileType + '-btn').style.display = 'none';
|
||||
document.getElementById('decrypt-' + fileType + '-btn').style.display = 'inline-block';
|
||||
alert('Файл успешно зашифрован');
|
||||
} else {
|
||||
alert('Ошибка шифрования: ' + (result.error || 'Неизвестная ошибка'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация редакторов для всех textarea
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Инициализация CodeMirror для всех YAML редакторов
|
||||
if (typeof CodeEditor !== 'undefined') {
|
||||
const editors = ['tasks', 'handlers', 'defaults', 'vars', 'meta'];
|
||||
editors.forEach(function(fileType) {
|
||||
const textareaId = fileType + '-content';
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (textarea) {
|
||||
const editor = CodeEditor.init(textareaId, 'yaml', {
|
||||
theme: 'monokai',
|
||||
lineNumbers: true,
|
||||
foldGutter: true
|
||||
});
|
||||
if (editor) {
|
||||
editor.on('change', function() {
|
||||
const content = editor.getValue();
|
||||
const validation = CodeEditor.validateYAML(content);
|
||||
if (!validation.valid && content.trim()) {
|
||||
CodeEditor.showErrors(editor, validation.errors);
|
||||
} else {
|
||||
// Очищаем ошибки если валидация прошла
|
||||
if (editor._validationMarkers) {
|
||||
editor._validationMarkers.forEach(m => m.clear());
|
||||
editor._validationMarkers = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkVaultEncryption('vars');
|
||||
|
||||
// Проверка при переключении на вкладку vars
|
||||
const varsTab = document.getElementById('vars-tab');
|
||||
if (varsTab) {
|
||||
varsTab.addEventListener('shown.bs.tab', function() {
|
||||
checkVaultEncryption('vars');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
163
app/templates/pages/roles/export.html
Normal file
163
app/templates/pages/roles/export.html
Normal file
@@ -0,0 +1,163 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Экспорт {{ role_name }} - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Экспорт роли: {{ role_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Настройки экспорта</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form
|
||||
hx-post="/api/v1/roles/{{ role_name }}/export"
|
||||
hx-target="#export-result"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- URL репозитория -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">URL Git репозитория *</label>
|
||||
<input
|
||||
type="url"
|
||||
name="repo_url"
|
||||
class="form-control"
|
||||
placeholder="https://github.com/username/ansible-role-{{ role_name }}.git"
|
||||
required
|
||||
>
|
||||
<div class="form-text">
|
||||
Поддерживаются HTTPS и SSH URL (git@github.com:user/repo.git)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ветка -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Ветка</label>
|
||||
<input
|
||||
type="text"
|
||||
name="branch"
|
||||
value="main"
|
||||
class="form-control"
|
||||
placeholder="main"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Версия -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Версия (для создания тега)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="version"
|
||||
class="form-control"
|
||||
placeholder="1.0.0"
|
||||
pattern="^[0-9]+\.[0-9]+\.[0-9]+$"
|
||||
>
|
||||
<div class="form-text">
|
||||
Формат: X.Y.Z (например, 1.0.0). Будет создан тег v{version}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Компоненты -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Компоненты для экспорта</label>
|
||||
<div class="row g-2">
|
||||
{% for component in components %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value="{{ component }}"
|
||||
id="component-{{ component }}"
|
||||
name="components"
|
||||
checked
|
||||
>
|
||||
<label class="form-check-label" for="component-{{ component }}">
|
||||
{{ component }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Секреты -->
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="include-secrets"
|
||||
name="include_secrets"
|
||||
>
|
||||
<label class="form-check-label" for="include-secrets">
|
||||
Включать секреты из vars/ (не рекомендуется)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text text-warning">
|
||||
⚠️ Если включено, секреты будут экспортированы. Используйте с осторожностью!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сообщение коммита -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Сообщение коммита</label>
|
||||
<textarea
|
||||
name="commit_message"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
placeholder="Автоматически сгенерируется, если не указано"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Скрытое поле для компонентов -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="components"
|
||||
id="components-json"
|
||||
value="[]"
|
||||
>
|
||||
|
||||
<!-- Результат -->
|
||||
<div id="export-result" class="mt-4"></div>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-upload me-2"></i>
|
||||
Экспортировать роль
|
||||
</button>
|
||||
<a href="/roles/{{ role_name }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.querySelector('form');
|
||||
const componentsInput = document.getElementById('components-json');
|
||||
const checkboxes = document.querySelectorAll('input[name="components"]');
|
||||
|
||||
function updateComponents() {
|
||||
const selected = Array.from(checkboxes)
|
||||
.filter(cb => cb.checked)
|
||||
.map(cb => cb.value);
|
||||
componentsInput.value = JSON.stringify(selected);
|
||||
}
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
cb.addEventListener('change', updateComponents);
|
||||
});
|
||||
|
||||
updateComponents();
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
updateComponents();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
186
app/templates/pages/roles/import.html
Normal file
186
app/templates/pages/roles/import.html
Normal file
@@ -0,0 +1,186 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Импорт роли - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Импорт роли{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/roles" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Назад к списку
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Импорт из Git репозитория</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form
|
||||
hx-post="/api/v1/roles/import/git"
|
||||
hx-target="#import-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">URL Git репозитория *</label>
|
||||
<input
|
||||
type="url"
|
||||
name="repo_url"
|
||||
class="form-control"
|
||||
placeholder="https://github.com/username/ansible-role-nginx.git"
|
||||
required
|
||||
>
|
||||
<div class="form-text">
|
||||
Поддерживаются HTTPS и SSH URL
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Имя роли (опционально)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="role_name"
|
||||
class="form-control"
|
||||
placeholder="Автоматически из URL"
|
||||
pattern="[a-z0-9_-]+"
|
||||
>
|
||||
<div class="form-text">
|
||||
Если не указано, будет извлечено из имени репозитория
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Ветка</label>
|
||||
<input
|
||||
type="text"
|
||||
name="branch"
|
||||
value="main"
|
||||
class="form-control"
|
||||
placeholder="main"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Поддиректория (опционально)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="subdirectory"
|
||||
class="form-control"
|
||||
placeholder="roles/nginx"
|
||||
>
|
||||
<div class="form-text">
|
||||
Если роль находится не в корне репозитория
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="import-result" class="mt-3"></div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-download me-2"></i>
|
||||
Импортировать из Git
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="validateRepo()"
|
||||
>
|
||||
<i class="fas fa-check me-2"></i>
|
||||
Проверить репозиторий
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Импорт из Ansible Galaxy</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form
|
||||
hx-post="/api/v1/roles/import/galaxy"
|
||||
hx-target="#galaxy-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Имя роли *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="role_name"
|
||||
class="form-control"
|
||||
placeholder="username.role_name или role_name"
|
||||
required
|
||||
>
|
||||
<div class="form-text">
|
||||
Формат: namespace.role_name или просто role_name
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Namespace (опционально)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="namespace"
|
||||
class="form-control"
|
||||
placeholder="username"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Версия (опционально)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="version"
|
||||
class="form-control"
|
||||
placeholder="1.0.0"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="galaxy-result" class="mt-3"></div>
|
||||
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-cloud-download-alt me-2"></i>
|
||||
Импортировать из Galaxy
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function validateRepo() {
|
||||
const repoUrl = document.querySelector('input[name="repo_url"]').value;
|
||||
const branch = document.querySelector('input[name="branch"]').value || 'main';
|
||||
|
||||
if (!repoUrl) {
|
||||
alert('Введите URL репозитория');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('repo_url', repoUrl);
|
||||
formData.append('branch', branch);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/roles/import/validate', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.valid) {
|
||||
alert(`✅ Репозиторий доступен!\n\nСтруктура роли:\n- tasks: ${result.has_tasks ? '✅' : '❌'}\n- meta: ${result.has_meta ? '✅' : '❌'}`);
|
||||
} else {
|
||||
alert(`❌ Ошибка: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`❌ Ошибка проверки: ${error.message}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
248
app/templates/pages/roles/list.html
Normal file
248
app/templates/pages/roles/list.html
Normal file
@@ -0,0 +1,248 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Роли - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Роли Ansible{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/roles/create" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-plus me-2"></i>
|
||||
Создать роль
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Поиск и фильтры -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" action="/roles" class="row g-3">
|
||||
<div class="col-12 col-md-8">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value="{{ search }}"
|
||||
placeholder="Поиск по имени или описанию..."
|
||||
class="form-control"
|
||||
>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search me-1"></i>
|
||||
Поиск
|
||||
</button>
|
||||
{% if search %}
|
||||
<a href="/roles" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-1"></i>
|
||||
Сброс
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Таблица ролей -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Список ролей</h5>
|
||||
<span class="text-muted small">
|
||||
Всего: <strong>{{ total }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if roles %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 20%;">Имя роли</th>
|
||||
<th style="width: 30%;">Описание</th>
|
||||
<th style="width: 20%;">Компоненты</th>
|
||||
<th style="width: 20%;">Платформы</th>
|
||||
<th style="width: auto; min-width: 140px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for role in roles %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/roles/{{ role.name }}" class="text-decoration-none fw-semibold text-primary">
|
||||
<i class="fas fa-cube me-1"></i>
|
||||
{{ role.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">
|
||||
{% if role.description %}
|
||||
{{ role.description[:80] }}{% if role.description|length > 80 %}...{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted fst-italic">Нет описания</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% if role.has_tasks %}
|
||||
<span class="badge bg-success" title="Tasks - задачи роли">
|
||||
<i class="fas fa-tasks me-1"></i>Tasks
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if role.has_defaults %}
|
||||
<span class="badge bg-info" title="Defaults - значения по умолчанию">
|
||||
<i class="fas fa-sliders-h me-1"></i>Defaults
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if role.has_handlers %}
|
||||
<span class="badge bg-warning text-dark" title="Handlers - обработчики">
|
||||
<i class="fas fa-bell me-1"></i>Handlers
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if role.has_meta %}
|
||||
<span class="badge bg-primary" title="Meta - метаданные роли">
|
||||
<i class="fas fa-info-circle me-1"></i>Meta
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if not role.has_tasks and not role.has_defaults and not role.has_handlers and not role.has_meta %}
|
||||
<span class="badge bg-secondary">Пустая роль</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if role.platforms and role.platforms|length > 0 %}
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for platform in role.platforms[:4] %}
|
||||
<span class="badge bg-dark" title="{{ platform.name }}{% if platform.versions %} ({{ platform.versions|join(', ') }}){% endif %}">
|
||||
<i class="fab fa-linux me-1"></i>
|
||||
{{ platform.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% if role.platforms|length > 4 %}
|
||||
<span class="badge bg-secondary" title="Ещё {{ role.platforms|length - 4 }} платформ">
|
||||
+{{ role.platforms|length - 4 }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted small fst-italic">Не указаны</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a
|
||||
href="/roles/{{ role.name }}/test"
|
||||
class="btn btn-outline-success"
|
||||
title="Запустить тест"
|
||||
>
|
||||
<i class="fas fa-vial"></i>
|
||||
</a>
|
||||
<a
|
||||
href="/roles/{{ role.name }}/edit"
|
||||
class="btn btn-outline-primary"
|
||||
title="Редактировать"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<a
|
||||
href="/roles/{{ role.name }}"
|
||||
class="btn btn-outline-secondary"
|
||||
title="Детали"
|
||||
>
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted mb-3">Роли не найдены</p>
|
||||
<a href="/roles/create" class="btn btn-primary">
|
||||
<i class="fas fa-plus me-2"></i>
|
||||
Создать первую роль
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if roles and total_pages > 1 %}
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
|
||||
<!-- Пагинация -->
|
||||
<nav aria-label="Навигация по страницам">
|
||||
<ul class="pagination mb-0">
|
||||
{% if page > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ p }}</span>
|
||||
</li>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ p }}{% if search %}&search={{ search }}{% endif %}">{{ p }}</a>
|
||||
</li>
|
||||
{% elif p == page - 3 or p == page + 3 %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Выбор количества на странице -->
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="text-muted small">На странице:</span>
|
||||
<select
|
||||
class="form-select form-select-sm pagination-per-page-select"
|
||||
style="width: auto;"
|
||||
onchange="window.location.href = '?page=1&per_page=' + this.value + '{% if search %}&search={{ search }}{% endif %}'"
|
||||
>
|
||||
<option value="10" {% if per_page == 10 %}selected{% endif %}>10</option>
|
||||
<option value="25" {% if per_page == 25 %}selected{% endif %}>25</option>
|
||||
<option value="50" {% if per_page == 50 %}selected{% endif %}>50</option>
|
||||
<option value="100" {% if per_page == 100 %}selected{% endif %}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Информация о странице -->
|
||||
<div class="text-muted small">
|
||||
Показано {{ ((page - 1) * per_page) + 1 }} - {{ [page * per_page, total]|min }} из {{ total }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
244
app/templates/pages/roles/test.html
Normal file
244
app/templates/pages/roles/test.html
Normal file
@@ -0,0 +1,244 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Тестирование {{ role_name }} - DevOpsLab{% endblock %}
|
||||
{% block page_title %}Тестирование роли: {{ role_name }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/roles/{{ role_name }}" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Назад
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="testRunner()">
|
||||
<!-- Настройки теста -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Настройки теста</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="startTest">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Preset для тестирования</label>
|
||||
<select
|
||||
x-model="testConfig.preset"
|
||||
class="form-select"
|
||||
>
|
||||
{% for preset in presets %}
|
||||
<option value="{{ preset.name }}" data-category="{{ preset.category }}">
|
||||
{{ preset.name }}
|
||||
{% if preset.category == 'k8s' %}
|
||||
<span class="badge bg-info">k8s</span>
|
||||
{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% if not presets %}
|
||||
<option value="default">default</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Выберите preset для тестирования. Preset'ы загружаются из базы данных.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Переменные роли (JSON, опционально)</label>
|
||||
<textarea
|
||||
x-model="testConfig.variables"
|
||||
rows="4"
|
||||
class="form-control font-monospace"
|
||||
placeholder='{"nginx_version": "1.25.0", "nginx_enabled": true}'
|
||||
></textarea>
|
||||
<div class="form-text">
|
||||
Укажите переменные в формате JSON
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Опции</label>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
x-model="testConfig.lint"
|
||||
id="test-lint"
|
||||
>
|
||||
<label class="form-check-label" for="test-lint">
|
||||
Проверка синтаксиса (lint)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
x-model="testConfig.idempotency"
|
||||
id="test-idempotency"
|
||||
>
|
||||
<label class="form-check-label" for="test-idempotency">
|
||||
Проверка идемпотентности
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
x-model="testConfig.verbose"
|
||||
id="test-verbose"
|
||||
>
|
||||
<label class="form-check-label" for="test-verbose">
|
||||
Verbose режим
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-success"
|
||||
:disabled="testRunning"
|
||||
>
|
||||
<i class="fas fa-play me-2" x-show="!testRunning"></i>
|
||||
<i class="fas fa-spinner fa-spin me-2" x-show="testRunning"></i>
|
||||
<span x-show="!testRunning">Запустить тест</span>
|
||||
<span x-show="testRunning">Тест выполняется...</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live логи -->
|
||||
<div class="card" x-show="testRunning || logs.length > 0">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Логи тестирования</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="log-container" id="test-logs" x-ref="logContainer" style="max-height: 500px; overflow-y: auto;">
|
||||
<template x-for="log in logs" :key="log.id">
|
||||
<div
|
||||
class="log-line"
|
||||
:class="{
|
||||
'log-error': log.type === 'error',
|
||||
'log-warning': log.type === 'warning',
|
||||
'log-info': log.type === 'info'
|
||||
}"
|
||||
x-text="log.data"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button
|
||||
@click="clearLogs"
|
||||
class="btn btn-outline-secondary"
|
||||
>
|
||||
<i class="fas fa-trash me-2"></i>
|
||||
Очистить
|
||||
</button>
|
||||
<button
|
||||
@click="downloadLogs"
|
||||
class="btn btn-outline-secondary"
|
||||
>
|
||||
<i class="fas fa-download me-2"></i>
|
||||
Скачать логи
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function testRunner() {
|
||||
return {
|
||||
testRunning: false,
|
||||
logs: [],
|
||||
testConfig: {
|
||||
preset: '{{ presets[0].name if presets else "default" }}',
|
||||
variables: '',
|
||||
lint: true,
|
||||
idempotency: false,
|
||||
verbose: false
|
||||
},
|
||||
ws: null,
|
||||
async startTest() {
|
||||
this.testRunning = true;
|
||||
this.logs = [];
|
||||
|
||||
// Получаем категорию preset'а из выбранного option
|
||||
const presetSelect = document.querySelector('select[x-model="testConfig.preset"]');
|
||||
const selectedOption = presetSelect.options[presetSelect.selectedIndex];
|
||||
const presetCategory = selectedOption ? (selectedOption.dataset.category || 'main') : 'main';
|
||||
|
||||
// Создание WebSocket подключения
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const testId = '{{ role_name }}-' + this.testConfig.preset + '-' + presetCategory;
|
||||
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws/test/${testId}`);
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'log' || data.type === 'info') {
|
||||
this.logs.push({
|
||||
id: Date.now() + Math.random(),
|
||||
type: data.level || this.detectLogLevel(data.data),
|
||||
data: data.data
|
||||
});
|
||||
|
||||
// Автоскролл
|
||||
this.$nextTick(() => {
|
||||
const container = this.$refs.logContainer;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
} else if (data.type === 'complete') {
|
||||
this.testRunning = false;
|
||||
this.logs.push({
|
||||
id: Date.now(),
|
||||
type: 'info',
|
||||
data: data.data || `✅ Тест завершен: ${data.status}`
|
||||
});
|
||||
this.ws.close();
|
||||
} else if (data.type === 'error') {
|
||||
this.testRunning = false;
|
||||
this.logs.push({
|
||||
id: Date.now(),
|
||||
type: 'error',
|
||||
data: data.data || `❌ Ошибка`
|
||||
});
|
||||
this.ws.close();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
this.testRunning = false;
|
||||
this.logs.push({
|
||||
id: Date.now(),
|
||||
type: 'error',
|
||||
data: `❌ Ошибка подключения: ${error}`
|
||||
});
|
||||
};
|
||||
},
|
||||
detectLogLevel(line) {
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.includes('error') || lower.includes('failed')) return 'error';
|
||||
if (lower.includes('warning') || lower.includes('warn')) return 'warning';
|
||||
if (lower.includes('changed') || lower.includes('ok')) return 'info';
|
||||
return 'debug';
|
||||
},
|
||||
clearLogs() {
|
||||
this.logs = [];
|
||||
},
|
||||
downloadLogs() {
|
||||
const content = this.logs.map(l => l.data).join('\n');
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `test-{{ role_name }}-${Date.now()}.log`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
213
app/templates/pages/tests/index.html
Normal file
213
app/templates/pages/tests/index.html
Normal 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 %}
|
||||
54
app/templates/pages/vault/index.html
Normal file
54
app/templates/pages/vault/index.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user