feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
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 %}
|
||||
Reference in New Issue
Block a user