feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile

- Добавлена колонка 'Тип' во все таблицы истории сборок
- Для push операций отображается registry вместо платформ
- Сохранение пользователя при создании push лога
- Исправлена ошибка с logger в push_docker_image endpoint
- Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
Сергей Антропов
2026-02-15 22:59:02 +03:00
parent 23e1a6037b
commit 1fbf9185a2
232 changed files with 38075 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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