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

144
app.py
View File

@@ -47,6 +47,9 @@ ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("SESSION_TIMEOUT", "3600")) // 60 #
ADMIN_USERNAME = os.getenv("LOGBOARD_USER", "admin")
ADMIN_PASSWORD = os.getenv("LOGBOARD_PASS", "admin")
# Настройки AJAX обновления
AJAX_UPDATE_INTERVAL = int(os.getenv("LOGBOARD_AJAX_UPDATE_INTERVAL", "2000"))
# Инициализация FastAPI
app = FastAPI(
title="LogBoard+",
@@ -603,6 +606,15 @@ async def get_current_user_info(current_user: str = Depends(get_current_user)):
"""Получить информацию о текущем пользователе"""
return {"username": current_user}
@app.get("/api/settings")
async def get_settings(current_user: str = Depends(get_current_user)):
"""Получить настройки приложения"""
return {
"ajax_update_interval": AJAX_UPDATE_INTERVAL,
"default_tail": DEFAULT_TAIL,
"skip_unhealthy": SKIP_UNHEALTHY
}
@app.get("/healthz", response_class=PlainTextResponse)
def healthz():
"""Health check endpoint"""
@@ -681,6 +693,138 @@ def api_logs_stats(container_id: str, current_user: str = Depends(get_current_us
print(f"Error getting log stats for {container_id}: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/api/logs/{container_id}")
def api_logs(
container_id: str,
tail: str = Query(str(DEFAULT_TAIL), description="Количество последних строк логов или 'all' для всех логов"),
since: Optional[str] = Query(None, description="Время начала в формате ISO или относительное время (например, '10m', '1h')"),
current_user: str = Depends(get_current_user)
):
"""
Получить логи контейнера через AJAX
Args:
container_id: ID контейнера
tail: Количество последних строк или 'all' для всех логов (по умолчанию 500)
since: Время начала для фильтрации логов
Returns:
JSON с логами и метаданными
"""
try:
# Ищем контейнер
container = None
for c in docker_client.containers.list(all=True):
if c.id.startswith(container_id):
container = c
break
if container is None:
return JSONResponse({"error": "Container not found"}, status_code=404)
# Формируем параметры для получения логов
log_params = {
'timestamps': True
}
# Обрабатываем параметр tail
if tail.lower() == 'all':
# Для всех логов не указываем параметр tail
pass
else:
try:
tail_lines = int(tail)
log_params['tail'] = tail_lines
except ValueError:
# Если не удалось преобразовать в число, используем значение по умолчанию
log_params['tail'] = DEFAULT_TAIL
# Добавляем фильтр по времени, если указан (используем Unix timestamp секундной точности)
if since:
def _parse_since(value: str) -> Optional[int]:
try:
# Числовое значение (unix timestamp)
if re.fullmatch(r"\d+", value or ""):
return int(value)
# ISO 8601 с Z
if value.endswith('Z'):
dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
return int(dt.timestamp())
# Пытаемся распарсить как ISO без Z
try:
dt2 = datetime.fromisoformat(value)
if dt2.tzinfo is None:
# Считаем UTC, если таймзона не указана
from datetime import timezone
dt2 = dt2.replace(tzinfo=timezone.utc)
return int(dt2.timestamp())
except Exception:
pass
except Exception:
return None
return None
parsed_since = _parse_since(since)
if parsed_since is not None:
log_params['since'] = parsed_since
# Получаем логи
logs = container.logs(**log_params).decode(errors="ignore")
# Разбиваем на строки и обрабатываем
log_lines = []
for line in logs.split('\n'):
if line.strip():
# Извлекаем временную метку и сообщение
timestamp_match = re.match(r'^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.+)$', line)
if timestamp_match:
timestamp, message = timestamp_match.groups()
log_lines.append({
'timestamp': timestamp,
'message': message,
'raw': line
})
else:
# Если временная метка не найдена, используем всю строку как сообщение
log_lines.append({
'timestamp': None,
'message': line,
'raw': line
})
# Сервер больше не фильтрует по содержимому (фильтрация по времени делается Docker'ом),
# дополнительная дедупликация выполняется на клиенте с учётом количества строк в ту же секунду
# Получаем информацию о контейнере
container_info = {
'id': container.id,
'name': container.name,
'status': container.status,
'image': container.image.tags[0] if container.image.tags else container.image.id,
'created': container.attrs['Created'],
'state': container.attrs['State']
}
return JSONResponse(
content={
'container': container_info,
'logs': log_lines,
'total_lines': len(log_lines),
'tail': tail,
'since': since,
'timestamp': datetime.now().isoformat()
},
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
except Exception as e:
print(f"Error getting logs for {container_id}: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/api/excluded-containers")
def api_get_excluded_containers(current_user: str = Depends(get_current_user)):
"""Получить список исключенных контейнеров"""