feat: Добавлено AJAX обновление логов и улучшения интерфейса

Основные изменения:
- Добавлено AJAX обновление логов с чекбоксом 'Auto-update logs'
- Добавлена опция 'All logs' в выпадающий список tail lines
- Исправлено отображение длинных названий контейнеров в multi-view режиме
- Восстановлена загрузка истории логов при включенном AJAX обновлении

Новые функции:
- Чекбокс 'Auto-update logs' в секции Options (включен по умолчанию)
- Настройка интервала обновления через LOGBOARD_AJAX_UPDATE_INTERVAL
- API эндпоинт /api/settings для получения настроек приложения
- Поддержка параметра tail=all для загрузки всех логов
- Автоматический запуск AJAX обновления при включении чекбокса

Исправления UI:
- Кнопки LogLevels не уезжают вправо при длинных названиях контейнеров
- Добавлено обрезание длинных названий с многоточием
- Фиксированная высота заголовков в multi-view режиме
- Защита от сжатия кнопок LogLevels

Тестирование:
- Добавлены тесты для AJAX обновления (test_ajax_update.py)
- Тест multi-view AJAX обновления (test_multi_view_ajax.py)
- Тест опции 'all logs' (test_all_logs.py)
- Тест отображения длинных названий (test_multi_view_layout.py)
- Команды make test-ajax, make test-multi-view-ajax, make test-all-logs, make test-multi-view-layout

Документация:
- Создана подробная документация AJAX обновления (app/docs/ajax-update.md)
- Обновлен CHANGELOG.md с версиями 1.3.0, 1.5.0, 1.6.0
- Обновлен README.md с описанием новых функций

Автор: Сергей Антропов
Сайт: https://devops.org.ru
This commit is contained in:
2025-08-18 19:35:47 +03:00
parent 2d565d52a6
commit 6e51f00791
14 changed files with 2066 additions and 7 deletions

View File

@@ -1395,6 +1395,7 @@ a{color:var(--link)}
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 40px;
}
.multi-view-title {
@@ -1403,6 +1404,10 @@ a{color:var(--link)}
color: var(--fg);
margin: 0;
flex: 1;
min-width: 0; /* Позволяет flex-элементу сжиматься */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1432,6 +1437,7 @@ a{color:var(--link)}
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 40px;
}
.single-view-title {
@@ -1440,6 +1446,10 @@ a{color:var(--link)}
color: var(--fg);
margin: 0;
flex: 1;
min-width: 0; /* Позволяет flex-элементу сжиматься */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Кнопки уровней логирования для заголовков */
@@ -1448,6 +1458,7 @@ a{color:var(--link)}
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0; /* Предотвращает сжатие кнопок */
}
.level-btn {
@@ -1464,7 +1475,9 @@ a{color:var(--link)}
transition: all 0.2s ease;
font-size: 10px;
min-width: 40px;
max-width: 50px;
position: relative;
flex-shrink: 0; /* Предотвращает сжатие кнопок */
}
.level-btn:hover {
@@ -1984,13 +1997,17 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="control-content" id="tail-content">
<select id="tail">
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</div>
<div class="control-content" id="tail-content">
<select id="tail">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="300">300</option>
<option value="500">500</option>
<option value="1000">1000</option>
<option value="all">All logs</option>
</select>
</div>
</div>
<div class="control-group collapsible" data-section="options">
@@ -2010,9 +2027,15 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
<input type="checkbox" id="wrap" checked>
<label for="wrap">Wrap text</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="autoupdate" checked>
<label for="autoupdate">Auto-update logs</label>
</div>
</div>
</div>
</div>
@@ -6678,6 +6701,404 @@ window.addEventListener('keydown', async (e)=>{
// Инициализируем видимость кнопок LogLevels
updateLogLevelsVisibility();
// ========================================
// AJAX ОБНОВЛЕНИЕ ЛОГОВ
// ========================================
// Глобальные переменные для AJAX обновления
let ajaxUpdateInterval = null;
let ajaxUpdateEnabled = false;
let ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию (будет переопределено из env)
// Состояние для каждого контейнера (для multi-view)
let containerStates = new Map(); // containerId -> {lastTimestamp, lastSecondCount}
/**
* Включить периодическое обновление логов через AJAX
* @param {number} intervalMs - Интервал обновления в миллисекундах
*/
function enableAjaxLogUpdate(intervalMs = null) {
if (ajaxUpdateInterval) {
clearInterval(ajaxUpdateInterval);
}
// Используем переданный интервал или значение по умолчанию
if (intervalMs === null) {
intervalMs = ajaxUpdateIntervalMs;
}
ajaxUpdateEnabled = true;
ajaxUpdateIntervalMs = intervalMs;
console.log(`AJAX обновление логов включено с интервалом ${intervalMs}ms`);
// Запускаем первое обновление сразу
performAjaxLogUpdate();
// Устанавливаем интервал
ajaxUpdateInterval = setInterval(performAjaxLogUpdate, intervalMs);
// Обновляем UI
updateAjaxUpdateCheckbox();
}
/**
* Отключить периодическое обновление логов через AJAX
*/
function disableAjaxLogUpdate() {
if (ajaxUpdateInterval) {
clearInterval(ajaxUpdateInterval);
ajaxUpdateInterval = null;
}
ajaxUpdateEnabled = false;
console.log('AJAX обновление логов отключено');
// Обновляем UI
updateAjaxUpdateCheckbox();
}
/**
* Переключить состояние AJAX обновления
*/
function toggleAjaxLogUpdate() {
if (ajaxUpdateEnabled) {
disableAjaxLogUpdate();
} else {
enableAjaxLogUpdate(ajaxUpdateIntervalMs);
}
}
/**
* Выполнить обновление логов через AJAX
*/
async function performAjaxLogUpdate() {
if (!ajaxUpdateEnabled) {
return;
}
// Получаем значение tail, учитывая опцию "all"
let tailLines = els.tail.value;
if (tailLines === 'all') {
tailLines = 'all'; // Оставляем как строку для API
} else {
tailLines = parseInt(tailLines) || 50;
}
try {
const token = localStorage.getItem('access_token');
if (!token) {
console.error('AJAX Update: No access token found');
return;
}
// Определяем контейнеры для обновления
let containersToUpdate = [];
if (state.multiViewMode && state.selectedContainers.length > 0) {
// Multi-view режим: обновляем все выбранные контейнеры
containersToUpdate = state.selectedContainers;
} else if (state.current) {
// Single-view режим: обновляем текущий контейнер
containersToUpdate = [state.current.id];
} else {
console.log('AJAX Update: Нет контейнеров для обновления');
return;
}
console.log(`AJAX Update: Обновляем ${containersToUpdate.length} контейнеров:`, containersToUpdate);
// Обновляем каждый контейнер
for (const containerId of containersToUpdate) {
await updateContainerLogs(containerId, tailLines, token);
}
} catch (error) {
console.error('AJAX Update Error:', error);
// Не отключаем обновление при ошибке, просто логируем
}
}
/**
* Обновить логи для конкретного контейнера
*/
async function updateContainerLogs(containerId, tailLines, token) {
try {
// Формируем URL с параметрами
const url = new URL(`/api/logs/${containerId}`, window.location.origin);
// Передаем tail параметр как строку (для поддержки "all")
url.searchParams.set('tail', String(tailLines));
// Получаем состояние контейнера
const containerState = containerStates.get(containerId) || { lastTimestamp: null, lastSecondCount: 0 };
// Если у нас есть временная метка последнего обновления, используем её
if (containerState.lastTimestamp) {
url.searchParams.set('since', containerState.lastTimestamp);
}
console.log(`AJAX Update: Запрашиваем логи для ${containerId} с tail=${tailLines}`);
// Формируем заголовки запроса
const headers = {
'Authorization': `Bearer ${token}`,
'Cache-Control': 'no-cache'
};
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
// Клиентская дедупликация: пропускаем уже учтённые строки в ту же секунду
let newPortion = data.logs || [];
// Извлекаем секундную часть из timestamp ответа сервера
const serverTs = (data.timestamp || '').split('.')[0]; // отбрасываем миллисекунды
if (!containerState.lastTimestamp || serverTs !== containerState.lastTimestamp) {
// Новая секунда — сбрасываем счётчик
containerState.lastTimestamp = serverTs;
containerState.lastSecondCount = 0;
}
if (newPortion.length > 0) {
// Обрезаем уже учтённые строки в той же секунде
if (containerState.lastSecondCount > 0 && newPortion.length > containerState.lastSecondCount) {
newPortion = newPortion.slice(containerState.lastSecondCount);
} else if (containerState.lastSecondCount >= newPortion.length) {
newPortion = [];
}
if (newPortion.length > 0) {
console.log(`AJAX Update: К обработке ${newPortion.length} строк для ${containerId} (из ${data.logs.length}), lastSecondCount=${containerState.lastSecondCount}`);
appendNewLogsForContainer(containerId, newPortion);
containerState.lastSecondCount += newPortion.length;
} else {
console.log(`AJAX Update: Новых логов нет для ${containerId} после дедупликации по секундам`);
}
} else {
console.log(`AJAX Update: Логи не пришли для ${containerId}`);
}
// Обновляем состояние контейнера
containerStates.set(containerId, containerState);
} catch (error) {
console.error(`AJAX Update Error for ${containerId}:`, error);
}
}
/**
* Добавить новые логи в конец существующих (универсальная функция для single и multi view)
* @param {string} containerId - ID контейнера
* @param {Array} newLogs - Массив новых логов
*/
function appendNewLogsForContainer(containerId, newLogs) {
const obj = state.open[containerId];
if (!obj) {
console.warn(`AJAX Update: Object not found for container ${containerId}`);
return;
}
// Обрабатываем каждую новую строку лога через handleLine
let addedCount = 0;
newLogs.forEach(log => {
const message = log.message || log.raw || '';
if (message.trim()) {
// Используем существующую функцию handleLine для правильной обработки
handleLine(containerId, message);
addedCount++;
}
});
// Прокручиваем к концу, если включена автопрокрутка
if (state.autoScroll) {
if (state.multiViewMode) {
// Для multi-view прокручиваем все контейнеры
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
if (multiViewLog) {
multiViewLog.scrollTop = multiViewLog.scrollHeight;
}
} else if (els.logContent) {
// Для single-view прокручиваем основной контент
els.logContent.scrollTop = els.logContent.scrollHeight;
}
}
// Обновляем счетчики
if (state.multiViewMode) {
// Для multi-view обновляем счетчики конкретного контейнера
updateContainerCounters(containerId);
} else {
// Для single-view обновляем общие счетчики
recalculateCounters();
}
// Очищаем дублированные строки
if (state.multiViewMode) {
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
if (multiViewLog) {
cleanMultiViewDuplicateLines(multiViewLog);
cleanMultiViewEmptyLines(multiViewLog);
}
} else {
cleanDuplicateLines(els.logContent);
cleanSingleViewEmptyLines(els.logContent);
}
console.log(`AJAX Update: Обработано ${addedCount} новых строк логов для ${containerId} через handleLine`);
}
/**
* Добавить новые логи в конец существующих (для обратной совместимости)
* @param {Array} newLogs - Массив новых логов
*/
function appendNewLogs(newLogs) {
if (!state.current || !els.logContent) {
return;
}
const containerId = state.current.id;
appendNewLogsForContainer(containerId, newLogs);
}
/**
* Обновить чекбокс AJAX обновления в UI
*/
function updateAjaxUpdateCheckbox() {
const checkbox = document.getElementById('autoupdate');
if (checkbox) {
checkbox.checked = ajaxUpdateEnabled;
}
}
/**
* Инициализировать чекбокс AJAX обновления
*/
function initAjaxUpdateCheckbox() {
const checkbox = document.getElementById('autoupdate');
if (!checkbox) {
console.error('AJAX Update Checkbox not found in HTML');
return;
}
// Настраиваем чекбокс
checkbox.title = 'Автоматическое обновление логов через AJAX';
// Добавляем обработчик изменения
checkbox.addEventListener('change', function() {
if (this.checked) {
enableAjaxLogUpdate();
} else {
disableAjaxLogUpdate();
}
});
// Устанавливаем начальное состояние (включен по умолчанию)
checkbox.checked = true;
ajaxUpdateEnabled = true;
console.log('AJAX Update Checkbox initialized');
}
/**
* Инициализация AJAX обновления
*/
async function initAjaxUpdate() {
initAjaxUpdateCheckbox();
// Получаем настройки с сервера
try {
const token = localStorage.getItem('access_token');
if (token) {
const response = await fetch('/api/settings', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const settings = await response.json();
ajaxUpdateIntervalMs = settings.ajax_update_interval || 2000;
console.log(`AJAX Update: Интервал обновления получен с сервера: ${ajaxUpdateIntervalMs}ms`);
} else {
console.warn('AJAX Update: Не удалось получить настройки с сервера, используем значение по умолчанию');
ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию
}
} else {
console.warn('AJAX Update: Токен не найден, используем значение по умолчанию');
ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию
}
} catch (error) {
console.error('AJAX Update: Ошибка получения настроек:', error);
ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию
}
console.log(`AJAX Update: Интервал обновления установлен на ${ajaxUpdateIntervalMs}ms`);
// Останавливаем AJAX обновление при смене контейнера
const originalSwitchToSingle = window.switchToSingle;
window.switchToSingle = function(containerId) {
disableAjaxLogUpdate();
// Очищаем состояние для всех контейнеров
containerStates.clear();
return originalSwitchToSingle.call(this, containerId);
};
// Останавливаем AJAX обновление при переключении в multi-view
const originalSwitchToMultiView = window.switchToMultiView;
window.switchToMultiView = function() {
disableAjaxLogUpdate();
// Очищаем состояние для всех контейнеров
containerStates.clear();
return originalSwitchToMultiView.call(this);
};
console.log('AJAX обновление логов инициализировано');
}
// Запускаем инициализацию AJAX обновления
initAjaxUpdate().then(() => {
// Автоматически запускаем AJAX обновление (так как чекбокс включен по умолчанию)
setTimeout(() => {
if (ajaxUpdateEnabled) {
console.log('AJAX Update: Автоматический запуск обновления логов');
enableAjaxLogUpdate();
}
}, 1000); // Запускаем через 1 секунду после инициализации
});
// Экспортируем функции в глобальную область для отладки
window.enableAjaxLogUpdate = enableAjaxLogUpdate;
window.disableAjaxLogUpdate = disableAjaxLogUpdate;
window.toggleAjaxLogUpdate = toggleAjaxLogUpdate;
window.performAjaxLogUpdate = performAjaxLogUpdate;
window.updateContainerLogs = updateContainerLogs;
// Добавляем обработчик изменения выбранных контейнеров в multi-view
const originalToggleContainerSelection = window.toggleContainerSelection;
window.toggleContainerSelection = function(containerId) {
const result = originalToggleContainerSelection.call(this, containerId);
// Если AJAX обновление активно, очищаем состояние для измененных контейнеров
if (ajaxUpdateEnabled) {
// Очищаем состояние для всех контейнеров, чтобы избежать проблем с синхронизацией
containerStates.clear();
console.log('AJAX Update: Очищено состояние контейнеров после изменения выбора');
}
return result;
};
})();
</script>