feat: ssh auth, protocol management, and cleanup
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
{% extends "layout.twig" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ 'Логи приложения' | trans }}</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">{{ 'Просмотр, поиск и управление файлами логов' | trans }}</p>
|
||||
</div>
|
||||
{% if log_files | length > 0 %}
|
||||
<div class="flex space-x-2">
|
||||
<button class="px-4 py-2 bg-yellow-500 text-white rounded-md hover:bg-yellow-600" id="btnClearAll" title="{{ 'Удалить все логи' | trans }}">
|
||||
<i class="fas fa-trash mr-2"></i>{{ 'Очистить все' | trans }}
|
||||
</button>
|
||||
<a href="/admin/logs/download?file={{ selected_file }}"
|
||||
class="px-4 py-2 rounded-md border {{ selected_file ? 'bg-white text-gray-700 hover:bg-gray-50' : 'bg-gray-100 text-gray-400 cursor-not-allowed' }}">
|
||||
<i class="fas fa-download mr-2"></i>{{ 'Скачать' | trans }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user and user.role == 'admin' %}
|
||||
<div class="mb-6 border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<a href="/settings" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-user mr-2"></i>{{ t('settings.profile') }}
|
||||
</a>
|
||||
<a href="/settings" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-key mr-2"></i>{{ t('settings.api_keys') }}
|
||||
</a>
|
||||
<a href="/settings" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-language mr-2"></i>{{ t('settings.translations') }}
|
||||
</a>
|
||||
<a href="/settings" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-users mr-2"></i>{{ t('settings.users') }}
|
||||
</a>
|
||||
<a href="/settings/ldap" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-network-wired mr-2"></i>LDAP
|
||||
</a>
|
||||
<a href="/settings/protocols" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-cubes mr-2"></i>Protocols
|
||||
</a>
|
||||
<a href="/admin/logs" class="tab-link border-purple-500 text-purple-600 py-4 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-file-alt mr-2"></i>{{ 'Логи' | trans }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<!-- Sidebar with file list -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">{{ 'Файлы логов' | trans }}</h2>
|
||||
</div>
|
||||
<div class="max-h-[600px] overflow-y-auto">
|
||||
{% if log_files | length > 0 %}
|
||||
{% for file in log_files %}
|
||||
<a href="/admin/logs?file={{ file.path }}" class="block px-6 py-4 border-b border-gray-100 hover:bg-gray-50 {{ selected_file == file.path ? 'bg-purple-50' : '' }}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium" style="word-break: break-all;">{{ file.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ file.size_formatted }}</div>
|
||||
</div>
|
||||
<button type="button" class="text-red-600 hover:text-red-800 text-sm delete-log" data-file="{{ file.path }}" title="{{ 'Удалить' | trans }}">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-2">{{ file.modified_formatted }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-6 py-10 text-center text-gray-500">
|
||||
{{ 'Логи не найдены' | trans }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="col-span-12 md:col-span-9">
|
||||
{% if selected_file %}
|
||||
<!-- File info -->
|
||||
<div class="bg-white shadow rounded-lg mb-4">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="mb-2 md:mb-0">
|
||||
<h3 class="text-sm text-gray-700">{{ 'Файл:' | trans }} <code class="text-gray-900">{{ selected_file }}</code></h3>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<strong>{{ 'Размер:' | trans }}</strong> {{ file_size | default(0) | bytes_format }}
|
||||
<span class="mx-2">•</span>
|
||||
<strong>{{ 'Строк:' | trans }}</strong> {{ line_count | number_format(0, '.', ' ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search form -->
|
||||
<div class="bg-white shadow rounded-lg mb-4">
|
||||
<div class="px-6 py-4">
|
||||
<form id="searchForm" class="md:flex md:items-center md:space-x-3">
|
||||
<input type="hidden" name="file" value="{{ selected_file }}">
|
||||
<div class="flex-1 mb-3 md:mb-0">
|
||||
<input type="text" id="searchQuery" name="query" required
|
||||
placeholder="{{ 'Поиск в логе...' | trans }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500">
|
||||
</div>
|
||||
<label class="inline-flex items-center mb-3 md:mb-0">
|
||||
<input type="checkbox" id="caseSensitive" name="case_sensitive" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
|
||||
<span class="ml-2 text-sm text-gray-600">{{ 'Учитывать регистр' | trans }}</span>
|
||||
</label>
|
||||
<button type="submit" class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700">
|
||||
<i class="fas fa-search mr-2"></i>{{ 'Найти' | trans }}
|
||||
</button>
|
||||
<button type="button" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200" id="statsBtn">
|
||||
<i class="fas fa-chart-bar mr-2"></i>{{ 'Статистика' | trans }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search results -->
|
||||
<div id="searchResults" class="hidden bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded mb-4">
|
||||
<strong>{{ 'Результаты поиска:' | trans }}</strong>
|
||||
<div id="resultsContent" class="mt-2 text-sm"></div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div id="statsPanel" class="hidden bg-white shadow rounded-lg mb-4">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-sm font-medium text-gray-900">{{ 'Статистика логов' | trans }}</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="text-center p-3 border-r">
|
||||
<div class="text-2xl text-purple-600" id="totalLines">0</div>
|
||||
<div class="text-xs text-gray-500">{{ 'Всего строк' | trans }}</div>
|
||||
</div>
|
||||
<div class="text-center p-3 border-r">
|
||||
<div class="text-2xl text-red-600" id="errorCount">0</div>
|
||||
<div class="text-xs text-gray-500">{{ 'Ошибок' | trans }}</div>
|
||||
</div>
|
||||
<div class="text-center p-3 border-r">
|
||||
<div class="text-2xl text-yellow-600" id="warningCount">0</div>
|
||||
<div class="text-xs text-gray-500">{{ 'Предупреждений' | trans }}</div>
|
||||
</div>
|
||||
<div class="text-center p-3">
|
||||
<div class="text-2xl text-green-600" id="successCount">0</div>
|
||||
<div class="text-xs text-gray-500">{{ 'Успехов' | trans }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-2" id="lastModified"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log content -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-900">{{ 'Содержание логов' | trans }}</h3>
|
||||
<button type="button" class="px-2 py-1 text-sm border rounded hover:bg-gray-50" id="toggleLineNumbers">
|
||||
<i class="fas fa-list-ol"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<pre class="mb-0 max-h-[600px] overflow-auto"><code id="logContent" class="show-line-numbers">{{ log_content }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-blue-50 border border-blue-200 text-blue-800 px-6 py-10 rounded text-center">
|
||||
<i class="fas fa-info-circle text-2xl mb-3"></i>
|
||||
<div class="text-lg">{{ 'Выберите файл логов для просмотра' | trans }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const selectedFile = '{{ selected_file }}';
|
||||
|
||||
// Delete log file
|
||||
document.querySelectorAll('.delete-log').forEach(btn => {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const file = this.dataset.file;
|
||||
if (confirm('{{ "Удалить этот файл логов?" | trans }}')) {
|
||||
fetch('/admin/logs/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'file=' + encodeURIComponent(file)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
alert('{{ "Ошибка:" | trans }} ' + data.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Clear all logs
|
||||
const clearAllBtn = document.getElementById('btnClearAll');
|
||||
if (clearAllBtn) {
|
||||
clearAllBtn.addEventListener('click', function() {
|
||||
if (confirm('{{ "Удалить ВСЕ файлы логов? Это действие необратимо." | trans }}')) {
|
||||
fetch('/admin/logs/clear-all', {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
alert(data.message);
|
||||
window.location.href = data.redirect;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Search logs
|
||||
document.getElementById('searchForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
fetch('/admin/logs/search', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(formData)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showSearchResults(data);
|
||||
} else {
|
||||
alert('{{ "Ошибка:" | trans }} ' + data.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Statistics
|
||||
document.getElementById('statsBtn').addEventListener('click', function() {
|
||||
if (!selectedFile) return;
|
||||
|
||||
fetch('/admin/logs/stats', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'file=' + encodeURIComponent(selectedFile)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showStatistics(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle line numbers
|
||||
document.getElementById('toggleLineNumbers').addEventListener('click', function() {
|
||||
const content = document.getElementById('logContent');
|
||||
content.classList.toggle('show-line-numbers');
|
||||
});
|
||||
|
||||
function showSearchResults(data) {
|
||||
const resultsDiv = document.getElementById('searchResults');
|
||||
const resultsContent = document.getElementById('resultsContent');
|
||||
|
||||
if (data.results_count === 0) {
|
||||
resultsContent.innerHTML = '<p class="mb-0">{{ "Результатов не найдено" | trans }}</p>';
|
||||
} else {
|
||||
let html = '<p class="mb-2">{{ "Найдено совпадений:" | trans }} <strong>' + data.results_count + '</strong></p>';
|
||||
html += '<div class="results-list" style="max-height: 300px; overflow-y: auto;">';
|
||||
|
||||
data.results.forEach((result, idx) => {
|
||||
html += '<div class="border-bottom pb-2 mb-2">';
|
||||
html += '<small class="text-muted">{{ "Строка" | trans }} ' + result.line + ':</small><br>';
|
||||
html += '<code>' + escapeHtml(result.content.substring(0, 200)) + (result.content.length > 200 ? '...' : '') + '</code>';
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
resultsContent.innerHTML = html;
|
||||
}
|
||||
|
||||
resultsDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function showStatistics(data) {
|
||||
document.getElementById('totalLines').textContent = data.total_lines;
|
||||
document.getElementById('errorCount').textContent = data.errors;
|
||||
document.getElementById('warningCount').textContent = data.warnings;
|
||||
document.getElementById('successCount').textContent = data.success;
|
||||
document.getElementById('lastModified').textContent = '{{ "Последнее обновление:" | trans }} ' + data.last_modified;
|
||||
|
||||
document.getElementById('statsPanel').style.display = 'block';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#logContent {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#logContent.show-line-numbers {
|
||||
counter-reset: line;
|
||||
}
|
||||
|
||||
#logContent.show-line-numbers::before {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.results-list {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.list-group-item.active {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user