Исправление синтаксической ошибки в molecule_executor.py и обновление k8s preset'ов

- Исправлена незакрытая скобка в _build_test_command (строка 745)
- Добавлена поддержка k8s preset'ов: выполнение create_k8s_cluster.py перед create.yml
- Обновлены образы в k8s preset'ах: заменен недоступный ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy на inecs/ansible-lab:ubuntu22-latest
- Обновлены preset'ы в базе данных через SQL
- Обновлены файлы: k8s-single.yml, k8s-multi.yml, k8s-istio-full.yml
This commit is contained in:
Сергей Антропов
2026-02-16 00:31:09 +03:00
parent 1fbf9185a2
commit d4b0d6f848
26 changed files with 1913 additions and 646 deletions

View File

@@ -91,10 +91,7 @@
<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"
onclick="deleteDockerfile({{ dockerfile.id }}, '{{ dockerfile.name }}', this)"
class="btn btn-outline-danger"
title="Удалить"
>
@@ -119,4 +116,89 @@
{% endif %}
</div>
</div>
{% block scripts %}
<script>
async function deleteDockerfile(dockerfileId, dockerfileName, button) {
// Показываем модальное окно подтверждения
const confirmed = await showConfirmModal(
`Вы уверены, что хотите удалить Dockerfile '${dockerfileName}'?`,
'Подтверждение удаления'
);
if (!confirmed) {
return;
}
// Отключаем кнопку во время запроса
button.disabled = true;
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const response = await fetch(`/api/v1/dockerfiles/${dockerfileId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
if (data.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
data.message || `Dockerfile '${dockerfileName}' успешно удален`,
'success',
'Успешно',
function() {
// После закрытия модального окна удаляем строку из таблицы
const row = button.closest('tr');
if (row) {
row.remove();
}
}
);
} else {
// Если функция недоступна, просто удаляем строку
const row = button.closest('tr');
if (row) {
row.remove();
}
}
}
} else {
// Ошибка - показываем в модальном окне
try {
const errorData = await response.json();
const errorMessage = errorData.detail || errorData.message || 'Ошибка при удалении Dockerfile';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении Dockerfile', 'error');
} else {
alert('Ошибка при удалении Dockerfile');
}
}
}
} catch (error) {
console.error('Ошибка при удалении Dockerfile:', error);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении Dockerfile', 'error');
} else {
alert('Ошибка при удалении Dockerfile');
}
} finally {
// Восстанавливаем кнопку
button.disabled = false;
button.innerHTML = originalHTML;
}
}
</script>
{% endblock %}

View File

@@ -31,10 +31,7 @@
<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"
onclick="deletePlaybook({{ playbook.id }}, '{{ playbook.name }}', this)"
class="btn btn-sm btn-outline-danger"
title="Удалить"
>
@@ -93,4 +90,89 @@
</div>
{% endfor %}
</div>
{% block scripts %}
<script>
async function deletePlaybook(playbookId, playbookName, button) {
// Показываем модальное окно подтверждения
const confirmed = await showConfirmModal(
`Вы уверены, что хотите удалить playbook '${playbookName}'?`,
'Подтверждение удаления'
);
if (!confirmed) {
return;
}
// Отключаем кнопку во время запроса
button.disabled = true;
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const response = await fetch(`/api/v1/playbooks/${playbookId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
if (data.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
data.message || `Playbook '${playbookName}' успешно удален`,
'success',
'Успешно',
function() {
// После закрытия модального окна удаляем карточку
const card = button.closest('.col-12');
if (card) {
card.remove();
}
}
);
} else {
// Если функция недоступна, просто удаляем карточку
const card = button.closest('.col-12');
if (card) {
card.remove();
}
}
}
} else {
// Ошибка - показываем в модальном окне
try {
const errorData = await response.json();
const errorMessage = errorData.detail || errorData.message || 'Ошибка при удалении playbook';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении playbook', 'error');
} else {
alert('Ошибка при удалении playbook');
}
}
}
} catch (error) {
console.error('Ошибка при удалении playbook:', error);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении playbook', 'error');
} else {
alert('Ошибка при удалении playbook');
}
} finally {
// Восстанавливаем кнопку
button.disabled = false;
button.innerHTML = originalHTML;
}
}
</script>
{% endblock %}

View File

@@ -11,11 +11,10 @@
{% endblock %}
{% block content %}
<div x-data="presetCreator()">
<div x-data="presetCreator()" x-init="init()">
<form
hx-post="/api/v1/presets/create"
hx-target="#result"
hx-swap="innerHTML"
hx-swap="none"
@submit.prevent="submitForm"
class="card"
>
@@ -60,6 +59,89 @@
</div>
</div>
<!-- Docker образы -->
<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 align-items-end">
<div class="col-12 col-md-4">
<label class="form-label small">Dockerfile</label>
<select
x-model="selectedDockerfile"
@change="onDockerfileSelected"
class="form-select"
:disabled="!dockerfilesLoaded"
>
<option value="">Выберите Dockerfile...</option>
<template x-for="dockerfile in dockerfiles" :key="dockerfile.id">
<option :value="dockerfile.name" x-text="dockerfile.name"></option>
</template>
</select>
<div class="form-text" x-show="!dockerfilesLoaded">
<i class="fas fa-spinner fa-spin me-1"></i>Загрузка Dockerfiles...
</div>
<div class="form-text text-danger" x-show="dockerfilesLoaded && dockerfiles.length === 0">
<i class="fas fa-exclamation-triangle me-1"></i>Dockerfiles не найдены
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label small">Значение образа</label>
<input
type="text"
x-model="newImageValue"
class="form-control"
placeholder="inecs/ansible-lab:ubuntu25"
>
</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>
<div class="card-header">
<h5 class="mb-0">Хосты</h5>
</div>
@@ -84,18 +166,10 @@
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>
<option value="">Выберите образ...</option>
<template x-for="(image, key) in formData.images" :key="key">
<option :value="key" x-text="key"></option>
</template>
</select>
</div>
<div class="col-12 col-md-4">
@@ -134,15 +208,89 @@
</button>
</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"
>/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>
<!-- Скрытые поля -->
<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>
<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
})"
>
<!-- Кнопки -->
<div class="card-footer">
@@ -169,15 +317,43 @@ function presetCreator() {
category: 'main',
hosts: [{
name: 'u1',
family: 'ubuntu22',
family: '',
groups_str: 'test, web',
groups: ['test', 'web']
}]
}],
images: {},
systemd_defaults: {
privileged: true,
command: '/sbin/init',
volumes_str: '/sys/fs/cgroup:/sys/fs/cgroup:rw',
volumes: ['/sys/fs/cgroup:/sys/fs/cgroup:rw'],
tmpfs_str: '/run, /run/lock',
tmpfs: ['/run', '/run/lock'],
capabilities_str: 'SYS_ADMIN',
capabilities: ['SYS_ADMIN']
}
},
dockerfiles: {{ dockerfiles | tojson }},
dockerfilesLoaded: true,
selectedDockerfile: '',
newImageKey: '',
newImageValue: '',
init() {
// Dockerfiles уже загружены из шаблона
console.log('Загружено Dockerfiles из шаблона:', this.dockerfiles.length);
},
onDockerfileSelected() {
if (this.selectedDockerfile) {
// Устанавливаем ключ образа из выбранного Dockerfile
this.newImageKey = this.selectedDockerfile;
// Автоматически подставляем значение образа в формате inecs/ansible-lab:{name}
this.newImageValue = `inecs/ansible-lab:${this.selectedDockerfile}`;
}
},
addHost() {
this.formData.hosts.push({
name: `u${this.formData.hosts.length + 1}`,
family: 'ubuntu22',
family: '',
groups_str: 'test',
groups: ['test']
});
@@ -189,10 +365,99 @@ function presetCreator() {
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 = '';
this.selectedDockerfile = '';
}
},
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);
},
submitForm(event) {
// Обновляем массивы перед отправкой
this.updateVolumes();
this.updateTmpfs();
this.updateCapabilities();
// HTMX обработает отправку
}
}
}
// Обработка ответа от HTMX для создания пресета
document.addEventListener('DOMContentLoaded', function() {
document.body.addEventListener('htmx:afterRequest', function(event) {
// Проверяем, что это запрос на создание пресета
if (event.detail.path === '/api/v1/presets/create') {
if (event.detail.xhr.status === 201 || event.detail.xhr.status === 200) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
response.message || `Preset '${response.preset_name}' успешно создан`,
'success',
'Успешно',
function() {
// После закрытия модального окна перенаправляем на страницу списка пресетов
window.location.href = '/presets';
}
);
} else {
// Если функция недоступна, просто перенаправляем
window.location.href = '/presets';
}
}
} catch (e) {
console.error('Ошибка парсинга ответа:', e);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при создании preset', 'error');
}
}
} else {
// Ошибка - показываем в модальном окне
try {
const response = JSON.parse(event.detail.xhr.responseText);
const errorMessage = response.detail || response.message || 'Ошибка при создании preset';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при создании preset', 'error');
} else {
alert('Ошибка при создании preset');
}
}
}
}
});
});
</script>
{% endblock %}

View File

@@ -4,39 +4,17 @@
{% 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>
<button
type="button"
class="btn btn-success btn-sm"
onclick="openPresetTestModal()"
title="Запустить тест preset'а"
data-bs-toggle="modal"
data-bs-target="#presetTestModal"
>
<i class="fas fa-play me-2"></i>
Запустить
</button>
<a href="/presets/{{ preset.name }}/edit?category={{ preset.category }}" class="btn btn-primary btn-sm">
<i class="fas fa-edit me-2"></i>
Редактировать
@@ -49,7 +27,7 @@
{% block content %}
<div class="row">
<div class="col-12 col-lg-8">
<div class="col-12">
<!-- Информация о preset'е -->
<div class="card mb-3">
<div class="card-header">
@@ -83,7 +61,7 @@
<div class="mb-3">
<strong>Хосты:</strong>
<div class="table-responsive">
<table class="table table-sm table-bordered">
<table class="table table-sm table-bordered w-100">
<thead>
<tr>
<th>Имя</th>
@@ -144,24 +122,65 @@
</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>
</div>
<!-- Модальное окно для тестирования preset'а -->
<div class="modal fade" id="presetTestModal" tabindex="-1" aria-labelledby="presetTestModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="presetTestModalLabel">
<i class="fas fa-vial me-2"></i>
Тестирование preset'а: {{ preset.name }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть" onclick="stopPresetTest()"></button>
</div>
<div class="modal-body">
<div class="log-container" id="preset-test-logs">
<div class="text-center text-muted py-5">
<i class="fas fa-spinner fa-spin fa-2x mb-3"></i>
<p>Подключение к серверу...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm btn-outline-light"
onclick="clearTestLogs()"
class="btn btn-warning"
onclick="stopPresetTest()"
id="modal-stop-btn"
title="Остановить тест"
>
<i class="fas fa-stop me-2"></i>
Остановить
</button>
<button
type="button"
class="btn btn-info"
onclick="restartPresetTest()"
id="modal-restart-btn"
title="Перезапустить тест"
>
<i class="fas fa-redo me-2"></i>
Перезапустить
</button>
<button
type="button"
class="btn btn-secondary"
onclick="clearPresetTestLogs()"
title="Очистить логи"
>
<i class="fas fa-trash"></i>
<i class="fas fa-trash me-2"></i>
Очистить
</button>
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
onclick="stopPresetTest()"
>
Закрыть
</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>
@@ -172,31 +191,59 @@
<script>
let testWebSocket = null;
let testRunning = false;
let presetTestModal = null;
function startPresetTest() {
// Инициализация модального окна
document.addEventListener('DOMContentLoaded', function() {
presetTestModal = new bootstrap.Modal(document.getElementById('presetTestModal'));
// Обработка закрытия модального окна
document.getElementById('presetTestModal').addEventListener('hidden.bs.modal', function() {
stopPresetTest();
});
});
function openPresetTestModal() {
if (testRunning) {
alert('Тест уже запущен');
return;
}
// Показываем логи
const logsCard = document.getElementById('test-logs-card');
const logsContainer = document.getElementById('test-logs');
logsCard.style.display = 'block';
// Очищаем логи
const logsContainer = document.getElementById('preset-test-logs');
logsContainer.innerHTML = '<div class="text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x mb-3"></i><p>Подключение к серверу...</p></div>';
// Показываем модальное окно
presetTestModal.show();
// Запускаем тест
setTimeout(() => {
startPresetTest();
}, 300);
}
function startPresetTest() {
if (testRunning) {
return;
}
const logsContainer = document.getElementById('preset-test-logs');
logsContainer.innerHTML = '';
// Показываем кнопки управления
document.getElementById('stop-btn').style.display = 'inline-block';
document.getElementById('restart-btn').style.display = 'inline-block';
document.getElementById('modal-stop-btn').disabled = false;
document.getElementById('modal-restart-btn').disabled = false;
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 }}`);
const wsUrl = `${protocol}//${window.location.host}/ws/preset/test/{{ preset.name }}?category={{ preset.category }}`;
const ws = new WebSocket(wsUrl);
testWebSocket = ws;
ws.onopen = () => {
addLog('info', '🔌 Подключено к серверу');
ws.send(JSON.stringify({
action: 'start',
preset_name: '{{ preset.name }}',
@@ -205,64 +252,195 @@ function startPresetTest() {
};
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();
try {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
addLog(data.level || 'info', data.data);
} else if (data.type === 'error') {
addLog('error', data.data);
} else if (data.type === 'complete') {
addLog('info', data.data || '✅ Тестирование завершено');
testRunning = false;
document.getElementById('modal-stop-btn').disabled = true;
document.getElementById('modal-restart-btn').disabled = false;
ws.close();
}
} catch (e) {
console.error('Ошибка парсинга сообщения:', e);
}
};
ws.onerror = (error) => {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = `❌ Ошибка подключения: ${error}`;
logsContainer.appendChild(errorLine);
addLog('error', `❌ Ошибка подключения WebSocket`);
testRunning = false;
document.getElementById('modal-stop-btn').disabled = true;
};
ws.onclose = () => {
testRunning = false;
console.log('WebSocket закрыт');
if (testWebSocket === ws) {
testWebSocket = null;
}
};
}
function stopPresetTest() {
if (testWebSocket && testWebSocket.readyState === WebSocket.OPEN) {
testWebSocket.send(JSON.stringify({ action: 'stop' }));
addLog('info', '⏹️ Остановка тестирования...');
}
if (testWebSocket) {
testWebSocket.close();
testWebSocket = null;
}
testRunning = false;
document.getElementById('stop-btn').style.display = 'none';
document.getElementById('restart-btn').style.display = 'none';
document.getElementById('modal-stop-btn').disabled = true;
document.getElementById('modal-restart-btn').disabled = false;
}
function restartPresetTest() {
stopPresetTest();
const logsContainer = document.getElementById('preset-test-logs');
logsContainer.innerHTML = '';
setTimeout(() => {
startPresetTest();
}, 1000);
}, 500);
}
function clearTestLogs() {
document.getElementById('test-logs').innerHTML = '';
function clearPresetTestLogs() {
const logsContainer = document.getElementById('preset-test-logs');
logsContainer.innerHTML = '';
}
function addLog(level, message) {
const logsContainer = document.getElementById('preset-test-logs');
const logLine = document.createElement('div');
logLine.className = `log-line log-${level}`;
// Цвета для разных уровней логов
const colors = {
'error': '#f48771',
'warning': '#d19a66',
'info': '#61afef',
'success': '#98c379',
'debug': '#abb2bf'
};
logLine.style.color = colors[level] || colors['debug'];
logLine.textContent = message;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
}
</script>
<style>
/* Стили для модального окна тестирования preset'а - как у модального окна логов сборки */
#presetTestModal .modal-dialog {
margin: 0 !important;
max-width: 100% !important;
width: 100% !important;
height: 100vh !important;
max-height: 100vh !important;
}
#presetTestModal .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;
}
#presetTestModal .modal-header {
flex-shrink: 0 !important;
padding: 1rem !important;
height: auto !important;
}
#presetTestModal .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;
}
#preset-test-logs {
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;
}
#presetTestModal .modal-footer {
flex-shrink: 0 !important;
padding: 1rem !important;
height: auto !important;
}
/* Дополнительные стили для гарантии полной высоты */
#presetTestModal.show .modal-body,
#presetTestModal.showing .modal-body {
height: calc(100vh - 180px) !important;
min-height: calc(100vh - 180px) !important;
max-height: calc(100vh - 180px) !important;
}
#presetTestModal.show #preset-test-logs,
#presetTestModal.showing #preset-test-logs {
height: calc(100vh - 180px) !important;
min-height: calc(100vh - 180px) !important;
max-height: calc(100vh - 180px) !important;
}
/* Стили для строк логов */
#preset-test-logs .log-line {
margin-bottom: 0.25rem;
line-height: 1.5;
}
#preset-test-logs .log-error {
color: #f48771 !important;
}
#preset-test-logs .log-warning {
color: #d19a66 !important;
}
#preset-test-logs .log-info {
color: #61afef !important;
}
#preset-test-logs .log-success {
color: #98c379 !important;
}
#preset-test-logs .log-debug {
color: #abb2bf !important;
}
</style>
{% endblock %}

View File

@@ -14,8 +14,7 @@
<div x-data="presetEditor()">
<form
hx-post="/api/v1/presets/{{ preset.name }}/update?category={{ preset.category }}"
hx-target="#result"
hx-swap="innerHTML"
hx-swap="none"
@submit.prevent="submitForm"
class="card"
>
@@ -374,9 +373,6 @@
>
{% endif %}
<!-- Результат -->
<div id="result" class="card-body border-top"></div>
<!-- Кнопки -->
<div class="card-footer">
<div class="d-flex gap-2">
@@ -514,5 +510,58 @@ function presetEditor() {
}
}
}
// Обработка ответа от HTMX для обновления пресета
document.addEventListener('DOMContentLoaded', function() {
document.body.addEventListener('htmx:afterRequest', function(event) {
// Проверяем, что это запрос на обновление пресета
if (event.detail.path && event.detail.path.includes('/api/v1/presets/') && event.detail.path.includes('/update')) {
if (event.detail.xhr.status === 200) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
response.message || 'Preset успешно обновлен',
'success',
'Успешно',
function() {
// После закрытия модального окна обновляем страницу
location.reload();
}
);
} else {
// Если функция недоступна, просто обновляем страницу
location.reload();
}
}
} catch (e) {
console.error('Ошибка парсинга ответа:', e);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при обновлении preset', 'error');
}
}
} else {
// Ошибка - показываем в модальном окне
try {
const response = JSON.parse(event.detail.xhr.responseText);
const errorMessage = response.detail || response.message || 'Ошибка при обновлении preset';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при обновлении preset', 'error');
} else {
alert('Ошибка при обновлении preset');
}
}
}
}
});
});
</script>
{% endblock %}

View File

@@ -115,10 +115,7 @@
<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"
onclick="deletePreset('{{ preset.name }}', '{{ preset.category or 'main' }}', this)"
class="btn btn-outline-danger"
title="Удалить"
>
@@ -225,3 +222,95 @@
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
async function deletePreset(presetName, category, button) {
// Показываем модальное окно подтверждения
const confirmed = await showConfirmModal(
`Вы уверены, что хотите удалить preset '${presetName}'?`,
'Подтверждение удаления'
);
if (!confirmed) {
return;
}
// Отключаем кнопку во время запроса
button.disabled = true;
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const response = await fetch(`/api/v1/presets/${presetName}?category=${category}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
if (data.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
data.message || `Preset '${presetName}' успешно удален`,
'success',
'Успешно',
function() {
// После закрытия модального окна удаляем строку из таблицы
const row = button.closest('tr');
if (row) {
row.remove();
// Обновляем счетчик, если нужно
const totalSpan = document.querySelector('.text-muted.small strong');
if (totalSpan) {
const currentTotal = parseInt(totalSpan.textContent) || 0;
totalSpan.textContent = Math.max(0, currentTotal - 1);
}
}
}
);
} else {
// Если функция недоступна, просто удаляем строку
const row = button.closest('tr');
if (row) {
row.remove();
}
}
}
} else {
// Ошибка - показываем в модальном окне
try {
const errorData = await response.json();
const errorMessage = errorData.detail || errorData.message || 'Ошибка при удалении preset';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении preset', 'error');
} else {
alert('Ошибка при удалении preset');
}
}
}
} catch (error) {
console.error('Ошибка при удалении preset:', error);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении preset', 'error');
} else {
alert('Ошибка при удалении preset');
}
} finally {
// Восстанавливаем кнопку
button.disabled = false;
button.innerHTML = originalHTML;
}
}
</script>
{% endblock %}

View File

@@ -24,8 +24,7 @@
<div class="card-body">
<form
hx-post="/api/v1/profile/docker-settings"
hx-target="#docker-result"
hx-swap="innerHTML"
hx-swap="none"
>
<div class="mb-3">
<label class="form-label">Имя пользователя</label>
@@ -66,8 +65,6 @@
</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
@@ -89,8 +86,7 @@
<div class="card-body">
<form
hx-post="/api/v1/profile/docker-settings"
hx-target="#harbor-result"
hx-swap="innerHTML"
hx-swap="none"
>
<div class="mb-3">
<label class="form-label">URL Harbor</label>
@@ -139,8 +135,6 @@
</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
@@ -150,4 +144,57 @@
</div>
</div>
</div>
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Обработка успешного сохранения настроек Docker
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.path === '/api/v1/profile/docker-settings' && event.detail.xhr.status === 200) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
response.message || 'Настройки успешно сохранены',
'success',
'Успешно',
function() {
// После закрытия модального окна обновляем страницу
location.reload();
}
);
} else {
// Если функция недоступна, просто обновляем страницу
location.reload();
}
}
} catch (e) {
console.error('Ошибка парсинга ответа:', e);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при сохранении настроек', 'error');
}
}
} else if (event.detail.path === '/api/v1/profile/docker-settings' && event.detail.xhr.status !== 200) {
// Ошибка - показываем в модальном окне
try {
const response = JSON.parse(event.detail.xhr.responseText);
const errorMessage = response.detail || response.message || 'Ошибка при сохранении настроек';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при сохранении настроек', 'error');
} else {
alert('Ошибка при сохранении настроек');
}
}
}
});
});
</script>
{% endblock %}

View File

@@ -255,8 +255,7 @@
<form
hx-post="/api/v1/profile"
hx-target="#result"
hx-swap="innerHTML"
hx-swap="none"
>
<div class="mb-3">
<label class="form-label">Имя пользователя</label>
@@ -292,8 +291,6 @@
>
</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>
@@ -318,20 +315,48 @@
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);
if (event.detail.path === '/api/v1/profile' && event.detail.xhr.status === 200) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
response.message || 'Профиль успешно обновлен',
'success',
'Успешно',
function() {
// После закрытия модального окна обновляем страницу
location.reload();
}
);
} else {
// Если функция недоступна, просто обновляем страницу
location.reload();
}
}
} catch (e) {
console.error('Ошибка парсинга ответа:', e);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при обновлении профиля', 'error');
}
}
} else if (event.detail.path === '/api/v1/profile' && event.detail.xhr.status !== 200) {
// Ошибка - показываем в модальном окне
try {
const response = JSON.parse(event.detail.xhr.responseText);
const errorMessage = response.detail || response.message || 'Ошибка при обновлении профиля';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при обновлении профиля', 'error');
} else {
alert('Ошибка при обновлении профиля');
}
}
}
});