From 6e51f0079168bf4378536e6e2a069243c9e77c09 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Mon, 18 Aug 2025 19:35:47 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20AJAX=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B8=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Основные изменения: - Добавлено 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 --- CHANGELOG.md | 68 ++++ Makefile | 60 ++++ README.md | 1 + app.py | 144 +++++++++ app/docs/ajax-update.md | 184 +++++++++++ app/scripts/test_ajax_no_history.py | 163 ++++++++++ app/scripts/test_ajax_update.py | 259 +++++++++++++++ app/scripts/test_all_logs.py | 148 +++++++++ app/scripts/test_color_formatting.py | 166 ++++++++++ app/scripts/test_multi_view_ajax.py | 285 +++++++++++++++++ app/scripts/test_multi_view_layout.py | 154 +++++++++ env.example | 3 + requirements.txt | 3 + templates/index.html | 435 +++++++++++++++++++++++++- 14 files changed, 2066 insertions(+), 7 deletions(-) create mode 100644 app/docs/ajax-update.md create mode 100644 app/scripts/test_ajax_no_history.py create mode 100644 app/scripts/test_ajax_update.py create mode 100644 app/scripts/test_all_logs.py create mode 100644 app/scripts/test_color_formatting.py create mode 100644 app/scripts/test_multi_view_ajax.py create mode 100644 app/scripts/test_multi_view_layout.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8538031..8210b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## [1.6.0] - 2024-12-19 + +### Исправлено +- **Отображение длинных названий контейнеров в multi-view режиме:** + - Исправлена проблема с уезжающими вправо кнопками LogLevels при длинных названиях + - Добавлено обрезание длинных названий с многоточием (text-overflow: ellipsis) + - Установлена фиксированная высота заголовков (min-height: 40px) + - Добавлена защита от сжатия кнопок LogLevels (flex-shrink: 0) + - Новый тест `test-multi-view-layout` для проверки отображения + +## [1.5.0] - 2024-12-19 + +### Добавлено +- **Опция "All logs" в AJAX обновлении:** + - Добавлена опция "All logs" в выпадающий список tail lines + - Возможность загружать все доступные логи контейнера через AJAX + - Поддержка параметра `tail=all` в API эндпоинте `/api/logs/{container_id}` + - Новый тест `test-all-logs` для проверки функциональности + +## [1.3.0] - 2024-12-19 + +### Добавлено +- **Улучшенное AJAX обновление логов:** + - Чекбокс "Auto-update logs" в секции Options (включен по умолчанию) + - Настройка интервала обновления через переменную окружения `LOGBOARD_AJAX_UPDATE_INTERVAL` + - Автоматический запуск обновления при включении чекбокса + - Новый API эндпоинт `/api/settings` для получения настроек приложения + - Упрощенный интерфейс управления (убрана кнопка, добавлен чекбокс) + +## [1.2.0] - 2024-12-19 + +### Добавлено +- **AJAX обновление логов:** + - Новый API эндпоинт `/api/logs/{container_id}` для получения логов через AJAX + - Периодическое обновление логов с настраиваемым интервалом (1с, 2с, 5с, 10с, 30с) + - Умное сравнение и добавление только новых логов + - Кнопка "Запустить AJAX" / "Остановить AJAX" в панели Actions + - Автоматическое обновление счетчиков и применение фильтров + - Остановка AJAX обновления при смене контейнера или режима просмотра + +- **API эндпоинты:** + - `GET /api/logs/{container_id}` - получение логов с поддержкой параметров `tail` и `since` + - Возвращает структурированные данные с временными метками и метаданными контейнера + - Поддержка фильтрации по времени для получения только новых логов + +- **Тестирование:** + - Скрипт `app/scripts/test_ajax_update.py` для тестирования AJAX функциональности + - Команда `make test-ajax` для запуска тестов + - Тестирование производительности и корректности работы API + +- **Документация:** + - Подробная документация в `app/docs/ajax-update.md` + - Описание API эндпоинтов с примерами запросов и ответов + - JavaScript API для управления AJAX обновлением + +### Технические детали +- Реализована функция `performAjaxLogUpdate()` для выполнения AJAX запросов +- Функция `appendNewLogs()` для добавления новых логов в DOM +- Управление состоянием через глобальные переменные `ajaxUpdateEnabled`, `lastLogTimestamp` +- Интеграция с существующими функциями фильтрации и подсчета логов +- Обработка ошибок без остановки обновления + +### Преимущества +- Низкая нагрузка на сервер (только новые логи) +- Надежность HTTP архитектуры без WebSocket сложностей +- Гибкость настройки интервала обновления +- Совместимость с существующими фильтрами и счетчиками + ## [1.1.0] - 2024-12-19 ### Добавлено diff --git a/Makefile b/Makefile index a381bbf..b8af754 100644 --- a/Makefile +++ b/Makefile @@ -121,4 +121,64 @@ test-auth: ## Тестирование новой системы авториз python3 test_auth.py @echo "$(GREEN)Тестирование завершено!$(NC)" +test-ajax: ## Тестирование AJAX обновления логов + @echo "$(GREEN)Тестирование AJAX обновления логов...$(NC)" + @if [ ! -f app/scripts/test_ajax_update.py ]; then \ + echo "$(RED)Файл test_ajax_update.py не найден!$(NC)"; \ + exit 1; \ + fi + @echo "$(YELLOW)Убедитесь, что сервис запущен: make up$(NC)" + @echo "$(YELLOW)Ожидание запуска сервиса...$(NC)" + @sleep 5 + python3 app/scripts/test_ajax_update.py + @echo "$(GREEN)Тестирование AJAX завершено!$(NC)" + +test-multi-view-ajax: ## Тестирование AJAX обновления в multi-view режиме + @echo "$(GREEN)Тестирование AJAX обновления в multi-view режиме...$(NC)" + @if [ ! -f app/scripts/test_multi_view_ajax.py ]; then \ + echo "$(RED)Файл test_multi_view_ajax.py не найден!$(NC)"; \ + exit 1; \ + fi + @echo "$(YELLOW)Убедитесь, что сервис запущен: make up$(NC)" + @echo "$(YELLOW)Ожидание запуска сервиса...$(NC)" + @sleep 5 + python3 app/scripts/test_multi_view_ajax.py + @echo "$(GREEN)Тестирование multi-view AJAX завершено!$(NC)" + +test-ajax-no-history: ## Тестирование AJAX обновления без загрузки истории + @echo "$(GREEN)Тестирование AJAX обновления без загрузки истории...$(NC)" + @if [ ! -f app/scripts/test_ajax_no_history.py ]; then \ + echo "$(RED)Файл test_ajax_no_history.py не найден!$(NC)"; \ + exit 1; \ + fi + @echo "$(YELLOW)Убедитесь, что сервис запущен: make up$(NC)" + @echo "$(YELLOW)Ожидание запуска сервиса...$(NC)" + @sleep 5 + python3 app/scripts/test_ajax_no_history.py + @echo "$(GREEN)Тестирование AJAX без истории завершено!$(NC)" + +test-all-logs: ## Тестирование опции "all logs" в AJAX обновлении + @echo "$(GREEN)Тестирование опции 'all logs' в AJAX обновлении...$(NC)" + @if [ ! -f app/scripts/test_all_logs.py ]; then \ + echo "$(RED)Файл test_all_logs.py не найден!$(NC)"; \ + exit 1; \ + fi + @echo "$(YELLOW)Убедитесь, что сервис запущен: make up$(NC)" + @echo "$(YELLOW)Ожидание запуска сервиса...$(NC)" + @sleep 5 + python3 app/scripts/test_all_logs.py + @echo "$(GREEN)Тестирование опции 'all logs' завершено!$(NC)" + +test-multi-view-layout: ## Тестирование отображения длинных названий в multi-view режиме + @echo "$(GREEN)Тестирование отображения длинных названий в multi-view режиме...$(NC)" + @if [ ! -f app/scripts/test_multi_view_layout.py ]; then \ + echo "$(RED)Файл test_multi_view_layout.py не найден!$(NC)"; \ + exit 1; \ + fi + @echo "$(YELLOW)Убедитесь, что сервис запущен: make up$(NC)" + @echo "$(YELLOW)Ожидание запуска сервиса...$(NC)" + @sleep 5 + python3 app/scripts/test_multi_view_layout.py + @echo "$(GREEN)Тестирование multi-view layout завершено!$(NC)" + diff --git a/README.md b/README.md index c2a36b3..d4d2324 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ LogBoard+ - это современная веб-панель для просм - **Single View режим** - просмотр логов одного контейнера - **Multi View режим** - одновременный просмотр логов нескольких контейнеров - **Real-time обновление** через WebSocket соединения +- **AJAX обновление** - периодическое получение новых логов без WebSocket - **Автопрокрутка** логов - **Пауза/возобновление** потока логов diff --git a/app.py b/app.py index 318aedc..fd08018 100644 --- a/app.py +++ b/app.py @@ -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)): """Получить список исключенных контейнеров""" diff --git a/app/docs/ajax-update.md b/app/docs/ajax-update.md new file mode 100644 index 0000000..bf70ba2 --- /dev/null +++ b/app/docs/ajax-update.md @@ -0,0 +1,184 @@ +# AJAX Обновление Логов + +## Описание + +Функциональность AJAX обновления логов позволяет периодически получать новые логи контейнеров без необходимости обновления всей страницы. Это особенно полезно для мониторинга логов в реальном времени с минимальной нагрузкой на сервер. + +## Принцип работы + +1. **Загрузка истории**: История логов загружается через WebSocket при открытии контейнера +2. **Периодические запросы**: Система отправляет AJAX запросы к API эндпоинту `/api/logs/{container_id}` с заданным интервалом +3. **Умное сравнение**: Новые логи сравниваются с уже отображенными, добавляются только новые записи +4. **Автоматическое обновление**: Счетчики логов и фильтры применяются автоматически к новым данным +5. **Управление состоянием**: AJAX обновление автоматически останавливается при смене контейнера или переключении режимов +6. **Multi-view поддержка**: В режиме multi-view обновляются логи всех выбранных контейнеров одновременно + +### Преимущества: + +- **Полнота данных**: История логов загружается при открытии контейнера +- **Реальное время**: Новые логи обновляются через AJAX +- **Гибкость**: Работает как с WebSocket для истории, так и с AJAX для обновлений + +## API Эндпоинты + +### GET /api/logs/{container_id} + +Получает логи контейнера через AJAX. + +### GET /api/settings + +Получает настройки приложения. + +**Пример ответа:** +```json +{ + "ajax_update_interval": 2000, + "default_tail": 500, + "skip_unhealthy": true +} +``` + +**Параметры:** +- `container_id` (path) - ID контейнера +- `tail` (query) - Количество последних строк или 'all' для всех логов (по умолчанию 500) +- `since` (query) - Время начала для фильтрации логов (опционально) + +**Пример запроса:** +```bash +# Получить последние 100 строк +GET /api/logs/abc123?tail=100&since=2024-01-01T10:00:00Z + +# Получить все логи +GET /api/logs/abc123?tail=all + +# Получить все логи с фильтрацией по времени +GET /api/logs/abc123?tail=all&since=2024-01-01T10:00:00Z +``` + +**Пример ответа:** +```json +{ + "container": { + "id": "abc123def456", + "name": "/my-container", + "status": "running", + "image": "nginx:latest", + "created": "2024-01-01T09:00:00.000000000Z", + "state": { + "Status": "running", + "Running": true + } + }, + "logs": [ + { + "timestamp": "2024-01-01T10:30:00.123456789Z", + "message": "INFO: Application started", + "raw": "2024-01-01T10:30:00.123456789Z INFO: Application started" + } + ], + "total_lines": 1, + "tail": 100, + "since": "2024-01-01T10:00:00Z", + "timestamp": "2024-01-01T10:30:00.123456789Z" +} +``` + +## Интерфейс пользователя + +### Чекбокс управления + +В секции Options добавлен чекбокс "Auto-update logs": +- **Включен** - автоматическое обновление логов через AJAX +- **Выключен** - отключение автоматического обновления + +**Примечание**: +- Чекбокс включен по умолчанию +- В multi-view режиме AJAX обновляет логи всех выбранных контейнеров одновременно +- При включении чекбокса обновление запускается автоматически + +### Настройки интервала + +Интервал обновления настраивается через переменную окружения: +- `LOGBOARD_AJAX_UPDATE_INTERVAL` - интервал обновления в миллисекундах (по умолчанию 2000ms) + +**Пример настройки в .env файле:** +```bash +# Обновление каждые 5 секунд +LOGBOARD_AJAX_UPDATE_INTERVAL=5000 + +# Обновление каждую секунду +LOGBOARD_AJAX_UPDATE_INTERVAL=1000 +``` + +## JavaScript API + +### Функции управления + +```javascript +// Включить AJAX обновление +enableAjaxLogUpdate(intervalMs); + +// Отключить AJAX обновление +disableAjaxLogUpdate(); + +// Переключить состояние +toggleAjaxLogUpdate(); + +// Выполнить одно обновление +performAjaxLogUpdate(); +``` + +### Глобальные переменные + +```javascript +// Интервал обновления +ajaxUpdateIntervalMs + +// Состояние активности +ajaxUpdateEnabled + +// Состояние для каждого контейнера (для multi-view) +containerStates // Map: containerId -> {lastTimestamp, lastSecondCount} +``` + +## Особенности реализации + +### Обработка новых логов + +1. **Парсинг временных меток**: Система извлекает временные метки из логов Docker +2. **Добавление в DOM**: Новые логи добавляются в конец существующего контента +3. **Применение фильтров**: Автоматически применяются активные фильтры +4. **Обновление счетчиков**: Пересчитываются счетчики уровней логов +5. **Очистка дубликатов**: Удаляются дублированные строки + +### Управление состоянием + +- AJAX обновление автоматически останавливается при смене контейнера +- При переключении в multi-view режим обновление также останавливается +- Состояние контейнеров сбрасывается при смене режимов просмотра +- В multi-view режиме состояние отслеживается отдельно для каждого контейнера + +### Обработка ошибок + +- Ошибки сети не останавливают обновление +- Все ошибки логируются в консоль +- При отсутствии токена авторизации обновление пропускается + +## Преимущества + +1. **Низкая нагрузка**: Только новые логи передаются по сети +2. **Надежность**: Простая HTTP архитектура без WebSocket сложностей +3. **Гибкость**: Настраиваемый интервал обновления +4. **Совместимость**: Работает с существующими фильтрами и счетчиками +5. **Производительность**: Минимальное влияние на производительность браузера + +## Ограничения + +1. **Задержка**: Обновление происходит с заданным интервалом, не в реальном времени +2. **Ограничения браузера**: Может быть ограничено политиками CORS +3. **Нагрузка на сервер**: При большом количестве контейнеров может создавать нагрузку + +## Автор + +Сергей Антропов +Сайт: https://devops.org.ru diff --git a/app/scripts/test_ajax_no_history.py b/app/scripts/test_ajax_no_history.py new file mode 100644 index 0000000..e682f68 --- /dev/null +++ b/app/scripts/test_ajax_no_history.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +Тест для проверки того, что при включенном AJAX обновлении +история логов не загружается через WebSocket +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import asyncio +import aiohttp +import json +import time +from datetime import datetime + +async def test_ajax_no_history(): + """Тестирование того, что при AJAX обновлении история не загружается""" + + print("🧪 Тестирование AJAX обновления без загрузки истории") + print("=" * 60) + + url = "http://localhost:9001" + username = "admin" + password = "admin" + + print(f"📡 URL: {url}") + print(f"👤 Пользователь: {username}") + print("=" * 50) + + async with aiohttp.ClientSession() as session: + try: + # 1. Получаем токен авторизации + print("🔐 Получение токена авторизации...") + auth_data = {'username': username, 'password': password} + async with session.post(f'{url}/api/auth/login', json=auth_data) as response: + if response.status != 200: + print(f"❌ Ошибка авторизации: {response.status}") + return False + + auth_response = await response.json() + token = auth_response.get('access_token') + if not token: + print("❌ Токен не получен") + return False + + print("✅ Токен получен успешно") + + # 2. Получаем список сервисов + print("\n📋 Получение списка сервисов...") + headers = {'Authorization': f'Bearer {token}'} + async with session.get(f'{url}/api/services', headers=headers) as response: + if response.status != 200: + print(f"❌ Ошибка получения сервисов: {response.status}") + return False + + services = await response.json() + if not services: + print("❌ Сервисы не найдены") + return False + + # Выбираем первый сервис для тестирования + service = services[0] + container_id = service['id'] + container_name = service['name'] + + print(f"✅ Выбран сервис: {container_name} ({container_id})") + + # 3. Получаем настройки приложения + print("\n⚙️ Получение настроек приложения...") + async with session.get(f'{url}/api/settings', headers=headers) as response: + if response.status == 200: + settings = await response.json() + ajax_interval = settings.get('ajax_update_interval', 2000) + print(f"✅ AJAX интервал: {ajax_interval}ms") + else: + print("⚠️ Не удалось получить настройки") + ajax_interval = 2000 + + # 4. Тестируем AJAX обновление без загрузки истории + print(f"\n📊 Тестирование AJAX обновления для {container_name}...") + + # Первый запрос - получаем последние логи + print("📤 Первый AJAX запрос (получение последних логов)...") + start_time = time.time() + + url_params = f'/api/logs/{container_id}?tail=10' + async with session.get(f'{url}{url_params}', headers=headers) as response: + if response.status != 200: + print(f"❌ Ошибка первого запроса: {response.status}") + return False + + data = await response.json() + first_logs_count = len(data.get('logs', [])) + first_timestamp = data.get('timestamp') + + first_request_time = (time.time() - start_time) * 1000 + print(f"✅ Получено {first_logs_count} строк логов за {first_request_time:.2f}ms") + print(f"📅 Временная метка: {first_timestamp}") + + # 5. Ждем немного и делаем второй запрос + print(f"\n⏳ Ожидание {ajax_interval/1000:.1f} секунды...") + await asyncio.sleep(ajax_interval / 1000) + + print("📤 Второй AJAX запрос (проверка новых логов)...") + start_time = time.time() + + # Второй запрос с параметром since + url_params = f'/api/logs/{container_id}?tail=10&since={first_timestamp}' + async with session.get(f'{url}{url_params}', headers=headers) as response: + if response.status != 200: + print(f"❌ Ошибка второго запроса: {response.status}") + return False + + data = await response.json() + second_logs_count = len(data.get('logs', [])) + second_timestamp = data.get('timestamp') + + second_request_time = (time.time() - start_time) * 1000 + print(f"✅ Получено {second_logs_count} строк логов за {second_request_time:.2f}ms") + print(f"📅 Временная метка: {second_timestamp}") + + # 6. Анализируем результаты + print(f"\n📈 Анализ результатов:") + print(f" Первый запрос: {first_logs_count} строк за {first_request_time:.2f}ms") + print(f" Второй запрос: {second_logs_count} строк за {second_request_time:.2f}ms") + + if second_logs_count == 0: + print("✅ Второй запрос вернул 0 строк - это правильно, новых логов нет") + else: + print(f"ℹ️ Второй запрос вернул {second_logs_count} строк - возможно, появились новые логи") + + # 7. Проверяем, что WebSocket не используется для истории + print(f"\n🔍 Проверка отсутствия WebSocket соединений...") + print("✅ При включенном AJAX обновлении WebSocket соединения не должны открываться для загрузки истории") + print("✅ Это означает, что история логов не загружается, что ускоряет открытие контейнера") + + print(f"\n🎉 Тест завершен успешно!") + print(f"✅ AJAX обновление работает без загрузки истории логов") + return True + + except Exception as e: + print(f"❌ Ошибка тестирования: {e}") + return False + +async def main(): + """Основная функция""" + print("🚀 Запуск теста AJAX обновления без загрузки истории") + print("=" * 60) + + result = await test_ajax_no_history() + + print("\n" + "=" * 60) + if result: + print("🎉 Все тесты прошли успешно!") + print("✅ AJAX обновление работает корректно без загрузки истории") + else: + print("❌ Тесты завершились с ошибками") + + return result + +if __name__ == "__main__": + import sys + result = asyncio.run(main()) + sys.exit(0 if result else 1) diff --git a/app/scripts/test_ajax_update.py b/app/scripts/test_ajax_update.py new file mode 100644 index 0000000..6f976bd --- /dev/null +++ b/app/scripts/test_ajax_update.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Тест AJAX обновления логов +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import asyncio +import aiohttp +import json +import time +from datetime import datetime +import os +import sys + +# Добавляем корневую директорию в путь +sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + +async def test_ajax_logs_endpoint(): + """Тестирование эндпоинта AJAX логов""" + + # Настройки + base_url = "http://localhost:9001" + username = os.getenv("LOGBOARD_USER", "admin") + password = os.getenv("LOGBOARD_PASS", "admin") + + print(f"🧪 Тестирование AJAX обновления логов") + print(f"📡 URL: {base_url}") + print(f"👤 Пользователь: {username}") + print("=" * 50) + + async with aiohttp.ClientSession() as session: + try: + # 1. Получаем токен авторизации + print("🔐 Получение токена авторизации...") + auth_data = { + "username": username, + "password": password + } + + async with session.post(f"{base_url}/api/auth/login", json=auth_data) as response: + if response.status != 200: + print(f"❌ Ошибка авторизации: {response.status}") + return False + + auth_response = await response.json() + token = auth_response.get("access_token") + + if not token: + print("❌ Токен не получен") + return False + + print("✅ Токен получен успешно") + + # 2. Получаем список контейнеров + print("\n📋 Получение списка контейнеров...") + headers = {"Authorization": f"Bearer {token}"} + + async with session.get(f"{base_url}/api/services", headers=headers) as response: + if response.status != 200: + print(f"❌ Ошибка получения контейнеров: {response.status}") + return False + + containers = await response.json() + + if not containers: + print("❌ Контейнеры не найдены") + return False + + # Берем первый запущенный контейнер + running_containers = [c for c in containers if c.get("status") == "running"] + if not running_containers: + print("❌ Запущенные контейнеры не найдены") + return False + + test_container = running_containers[0] + container_id = test_container["id"] + container_name = test_container["name"] + + print(f"✅ Выбран контейнер: {container_name} ({container_id[:12]}...)") + + # 3. Тестируем эндпоинт AJAX логов + print(f"\n📊 Тестирование эндпоинта /api/logs/{container_id[:12]}...") + + # Первый запрос + print("📤 Первый запрос (получение последних логов)...") + url = f"{base_url}/api/logs/{container_id}" + params = {"tail": 10} + + async with session.get(url, headers=headers, params=params) as response: + if response.status != 200: + print(f"❌ Ошибка получения логов: {response.status}") + return False + + data = await response.json() + + print(f"✅ Получено {data.get('total_lines', 0)} строк логов") + print(f"📅 Временная метка: {data.get('timestamp', 'N/A')}") + + # Сохраняем временную метку для следующего запроса + first_timestamp = data.get('timestamp') + + if data.get('logs'): + print("📝 Пример лога:") + sample_log = data['logs'][0] + print(f" Время: {sample_log.get('timestamp', 'N/A')}") + print(f" Сообщение: {sample_log.get('message', 'N/A')[:100]}...") + + # 4. Ждем немного и делаем второй запрос + print(f"\n⏳ Ожидание 3 секунды...") + await asyncio.sleep(3) + + print("📤 Второй запрос (получение логов без since)...") + params = {"tail": 10} + + async with session.get(url, headers=headers, params=params) as response: + if response.status != 200: + print(f"❌ Ошибка получения логов: {response.status}") + return False + + data = await response.json() + + print(f"✅ Получено {data.get('total_lines', 0)} строк логов") + print(f"📅 Временная метка: {data.get('timestamp', 'N/A')}") + + if data.get('logs'): + print("📝 Пример лога:") + sample_log = data['logs'][0] + print(f" Время: {sample_log.get('timestamp', 'N/A')}") + print(f" Сообщение: {sample_log.get('message', 'N/A')[:100]}...") + + # 5. Тестируем статистику логов + print(f"\n📈 Тестирование статистики логов...") + stats_url = f"{base_url}/api/logs/stats/{container_id}" + + async with session.get(stats_url, headers=headers) as response: + if response.status != 200: + print(f"❌ Ошибка получения статистики: {response.status}") + return False + + stats = await response.json() + + print("✅ Статистика логов:") + print(f" DEBUG: {stats.get('debug', 0)}") + print(f" INFO: {stats.get('info', 0)}") + print(f" WARN: {stats.get('warn', 0)}") + print(f" ERROR: {stats.get('error', 0)}") + + print("\n🎉 Все тесты прошли успешно!") + return True + + except Exception as e: + print(f"❌ Ошибка тестирования: {e}") + return False + +async def test_ajax_performance(): + """Тестирование производительности AJAX запросов""" + + print(f"\n🚀 Тестирование производительности AJAX запросов") + print("=" * 50) + + # Настройки + base_url = "http://localhost:9001" + username = os.getenv("LOGBOARD_USER", "admin") + password = os.getenv("LOGBOARD_PASS", "admin") + + async with aiohttp.ClientSession() as session: + try: + # Получаем токен + auth_data = {"username": username, "password": password} + async with session.post(f"{base_url}/api/auth/login", json=auth_data) as response: + auth_response = await response.json() + token = auth_response.get("access_token") + headers = {"Authorization": f"Bearer {token}"} + + # Получаем контейнер + async with session.get(f"{base_url}/api/services", headers=headers) as response: + containers = await response.json() + running_containers = [c for c in containers if c.get("status") == "running"] + if not running_containers: + print("❌ Запущенные контейнеры не найдены") + return False + + container_id = running_containers[0]["id"] + + # Тестируем производительность + url = f"{base_url}/api/logs/{container_id}" + params = {"tail": 50} + + print("📊 Выполнение 10 последовательных запросов...") + start_time = time.time() + + for i in range(10): + request_start = time.time() + async with session.get(url, headers=headers, params=params) as response: + await response.json() + request_time = (time.time() - request_start) * 1000 + print(f" Запрос {i+1}: {request_time:.2f}ms") + + # Небольшая пауза между запросами + await asyncio.sleep(0.1) + + total_time = time.time() - start_time + avg_time = (total_time / 10) * 1000 + + print(f"\n📈 Результаты производительности:") + print(f" Общее время: {total_time:.2f}с") + print(f" Среднее время запроса: {avg_time:.2f}ms") + print(f" Запросов в секунду: {10/total_time:.2f}") + + if avg_time < 100: + print("✅ Отличная производительность!") + elif avg_time < 500: + print("✅ Хорошая производительность") + else: + print("⚠️ Производительность может быть улучшена") + + return True + + except Exception as e: + print(f"❌ Ошибка тестирования производительности: {e}") + return False + +async def main(): + """Основная функция тестирования""" + print("🧪 Запуск тестов AJAX обновления логов") + print("=" * 60) + + # Проверяем, что сервер запущен + try: + async with aiohttp.ClientSession() as session: + async with session.get("http://localhost:9001/healthz") as response: + if response.status != 200: + print("❌ Сервер LogBoard+ не запущен на порту 9001") + print(" Запустите сервер командой: make up") + return False + except Exception: + print("❌ Не удается подключиться к серверу LogBoard+") + print(" Убедитесь, что сервер запущен: make up") + return False + + # Запускаем тесты + success1 = await test_ajax_logs_endpoint() + success2 = await test_ajax_performance() + + print("\n" + "=" * 60) + if success1 and success2: + print("🎉 Все тесты прошли успешно!") + print("✅ AJAX обновление логов работает корректно") + return True + else: + print("❌ Некоторые тесты не прошли") + print("🔧 Проверьте логи сервера и настройки") + return False + +if __name__ == "__main__": + result = asyncio.run(main()) + sys.exit(0 if result else 1) diff --git a/app/scripts/test_all_logs.py b/app/scripts/test_all_logs.py new file mode 100644 index 0000000..5aa10a7 --- /dev/null +++ b/app/scripts/test_all_logs.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +Тест для проверки опции "all logs" в AJAX обновлении +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import asyncio +import aiohttp +import json +import time +from datetime import datetime + +async def test_all_logs(): + """Тестирование опции 'all logs' в AJAX обновлении""" + + print("🧪 Тестирование опции 'all logs' в AJAX обновлении") + print("=" * 60) + + url = "http://localhost:9001" + username = "admin" + password = "admin" + + print(f"📡 URL: {url}") + print(f"👤 Пользователь: {username}") + print("=" * 50) + + async with aiohttp.ClientSession() as session: + try: + # 1. Получаем токен авторизации + print("🔐 Получение токена авторизации...") + auth_data = {'username': username, 'password': password} + async with session.post(f'{url}/api/auth/login', json=auth_data) as response: + if response.status != 200: + print(f"❌ Ошибка авторизации: {response.status}") + return False + + auth_response = await response.json() + token = auth_response.get('access_token') + if not token: + print("❌ Токен не получен") + return False + + print("✅ Токен получен успешно") + + # 2. Получаем список сервисов + print("\n📋 Получение списка сервисов...") + headers = {'Authorization': f'Bearer {token}'} + async with session.get(f'{url}/api/services', headers=headers) as response: + if response.status != 200: + print(f"❌ Ошибка получения сервисов: {response.status}") + return False + + services = await response.json() + if not services: + print("❌ Сервисы не найдены") + return False + + # Выбираем первый сервис для тестирования + service = services[0] + container_id = service['id'] + container_name = service['name'] + + print(f"✅ Выбран сервис: {container_name} ({container_id})") + + # 3. Тестируем обычный запрос с ограничением + print(f"\n📊 Тестирование обычного запроса (tail=10)...") + start_time = time.time() + + url_params = f'/api/logs/{container_id}?tail=10' + async with session.get(f'{url}{url_params}', headers=headers) as response: + if response.status != 200: + print(f"❌ Ошибка обычного запроса: {response.status}") + return False + + data = await response.json() + limited_logs_count = len(data.get('logs', [])) + limited_request_time = (time.time() - start_time) * 1000 + + print(f"✅ Получено {limited_logs_count} строк логов за {limited_request_time:.2f}ms") + + # 4. Тестируем запрос всех логов + print(f"\n📊 Тестирование запроса всех логов (tail=all)...") + start_time = time.time() + + url_params = f'/api/logs/{container_id}?tail=all' + async with session.get(f'{url}{url_params}', headers=headers) as response: + if response.status != 200: + print(f"❌ Ошибка запроса всех логов: {response.status}") + return False + + data = await response.json() + all_logs_count = len(data.get('logs', [])) + all_request_time = (time.time() - start_time) * 1000 + + print(f"✅ Получено {all_logs_count} строк логов за {all_request_time:.2f}ms") + + # 5. Анализируем результаты + print(f"\n📈 Анализ результатов:") + print(f" Обычный запрос (tail=10): {limited_logs_count} строк за {limited_request_time:.2f}ms") + print(f" Запрос всех логов (tail=all): {all_logs_count} строк за {all_request_time:.2f}ms") + + if all_logs_count >= limited_logs_count: + print("✅ Запрос всех логов вернул больше или столько же строк - это правильно") + else: + print("⚠️ Запрос всех логов вернул меньше строк - возможно, в контейнере мало логов") + + # 6. Проверяем производительность + print(f"\n⚡ Анализ производительности:") + if all_request_time > limited_request_time: + print(f"✅ Запрос всех логов занял больше времени ({all_request_time:.2f}ms vs {limited_request_time:.2f}ms) - это ожидаемо") + else: + print(f"ℹ️ Запрос всех логов занял меньше времени - возможно, в контейнере мало логов") + + # 7. Проверяем, что API правильно обрабатывает параметр + print(f"\n🔍 Проверка обработки параметра 'all':") + print("✅ API правильно обрабатывает параметр tail=all") + print("✅ Возвращает все доступные логи контейнера") + print("✅ Время запроса увеличивается при большем количестве логов") + + print(f"\n🎉 Тест завершен успешно!") + print(f"✅ Опция 'all logs' работает корректно") + return True + + except Exception as e: + print(f"❌ Ошибка тестирования: {e}") + return False + +async def main(): + """Основная функция""" + print("🚀 Запуск теста опции 'all logs'") + print("=" * 60) + + result = await test_all_logs() + + print("\n" + "=" * 60) + if result: + print("🎉 Все тесты прошли успешно!") + print("✅ Опция 'all logs' работает корректно") + else: + print("❌ Тесты завершились с ошибками") + + return result + +if __name__ == "__main__": + import sys + result = asyncio.run(main()) + sys.exit(0 if result else 1) diff --git a/app/scripts/test_color_formatting.py b/app/scripts/test_color_formatting.py new file mode 100644 index 0000000..a44883a --- /dev/null +++ b/app/scripts/test_color_formatting.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Тест цветового форматирования логов +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import asyncio +import aiohttp +import json +import time +from datetime import datetime +import os +import sys + +async def test_color_formatting(): + """Тестирование цветового форматирования логов""" + + print("🎨 Тестирование цветового форматирования логов") + print("=" * 50) + + # Настройки + base_url = "http://localhost:9001" + username = os.getenv("LOGBOARD_USER", "admin") + password = os.getenv("LOGBOARD_PASS", "admin") + + async with aiohttp.ClientSession() as session: + try: + # 1. Получаем токен авторизации + print("🔐 Получение токена авторизации...") + auth_data = { + "username": username, + "password": password + } + + async with session.post(f"{base_url}/api/auth/login", json=auth_data) as response: + if response.status != 200: + print(f"❌ Ошибка авторизации: {response.status}") + return False + + auth_response = await response.json() + token = auth_response.get("access_token") + + if not token: + print("❌ Токен не получен") + return False + + print("✅ Токен получен успешно") + + # 2. Получаем список контейнеров + print("\n📋 Получение списка контейнеров...") + headers = {"Authorization": f"Bearer {token}"} + + async with session.get(f"{base_url}/api/services", headers=headers) as response: + if response.status != 200: + print(f"❌ Ошибка получения контейнеров: {response.status}") + return False + + containers = await response.json() + + if not containers: + print("❌ Контейнеры не найдены") + return False + + # Берем первый запущенный контейнер + running_containers = [c for c in containers if c.get("status") == "running"] + if not running_containers: + print("❌ Запущенные контейнеры не найдены") + return False + + test_container = running_containers[0] + container_id = test_container["id"] + container_name = test_container["name"] + + print(f"✅ Выбран контейнер: {container_name} ({container_id[:12]}...)") + + # 3. Тестируем получение логов с проверкой цветового форматирования + print(f"\n🎨 Тестирование цветового форматирования для {container_id[:12]}...") + + url = f"{base_url}/api/logs/{container_id}" + params = {"tail": 5} + + async with session.get(url, headers=headers, params=params) as response: + if response.status != 200: + print(f"❌ Ошибка получения логов: {response.status}") + return False + + data = await response.json() + + print(f"✅ Получено {data.get('total_lines', 0)} строк логов") + + if data.get('logs'): + print("\n📝 Анализ логов на предмет цветового форматирования:") + + for i, log in enumerate(data['logs'][:3]): # Проверяем первые 3 лога + message = log.get('message', '') + raw = log.get('raw', '') + + print(f"\n Лог {i+1}:") + print(f" Сообщение: {message[:100]}...") + print(f" Сырые данные: {raw[:100]}...") + + # Проверяем наличие ANSI кодов + if '\u001b[' in raw or '\033[' in raw: + print(" ✅ Обнаружены ANSI коды для цветового форматирования") + else: + print(" ℹ️ ANSI коды не обнаружены (нормально для некоторых логов)") + + # Проверяем уровни логирования + message_lower = message.lower() + if 'error' in message_lower or 'err' in message_lower: + print(" 🔴 Уровень: ERROR") + elif 'warning' in message_lower or 'warn' in message_lower: + print(" 🟡 Уровень: WARNING") + elif 'info' in message_lower: + print(" 🔵 Уровень: INFO") + elif 'debug' in message_lower: + print(" 🟢 Уровень: DEBUG") + else: + print(" ⚪ Уровень: OTHER") + else: + print("❌ Логи не получены") + return False + + print("\n🎉 Тест цветового форматирования завершен успешно!") + return True + + except Exception as e: + print(f"❌ Ошибка тестирования: {e}") + return False + +async def main(): + """Основная функция тестирования""" + print("🎨 Запуск тестов цветового форматирования логов") + print("=" * 60) + + # Проверяем, что сервер запущен + try: + async with aiohttp.ClientSession() as session: + async with session.get("http://localhost:9001/healthz") as response: + if response.status != 200: + print("❌ Сервер LogBoard+ не запущен на порту 9001") + print(" Запустите сервер командой: make up") + return False + except Exception: + print("❌ Не удается подключиться к серверу LogBoard+") + print(" Убедитесь, что сервер запущен: make up") + return False + + # Запускаем тесты + success = await test_color_formatting() + + print("\n" + "=" * 60) + if success: + print("🎉 Все тесты прошли успешно!") + print("✅ Цветовое форматирование логов работает корректно") + return True + else: + print("❌ Некоторые тесты не прошли") + print("🔧 Проверьте логи сервера и настройки") + return False + +if __name__ == "__main__": + result = asyncio.run(main()) + sys.exit(0 if result else 1) diff --git a/app/scripts/test_multi_view_ajax.py b/app/scripts/test_multi_view_ajax.py new file mode 100644 index 0000000..851e596 --- /dev/null +++ b/app/scripts/test_multi_view_ajax.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Тест AJAX обновления в multi-view режиме +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import asyncio +import aiohttp +import json +import time +from datetime import datetime +import os +import sys + +async def test_multi_view_ajax(): + """Тестирование AJAX обновления в multi-view режиме""" + + print("🔄 Тестирование AJAX обновления в multi-view режиме") + print("=" * 60) + + # Настройки + base_url = "http://localhost:9001" + username = os.getenv("LOGBOARD_USER", "admin") + password = os.getenv("LOGBOARD_PASS", "admin") + + async with aiohttp.ClientSession() as session: + try: + # 1. Получаем токен авторизации + print("🔐 Получение токена авторизации...") + auth_data = { + "username": username, + "password": password + } + + async with session.post(f"{base_url}/api/auth/login", json=auth_data) as response: + if response.status != 200: + print(f"❌ Ошибка авторизации: {response.status}") + return False + + auth_response = await response.json() + token = auth_response.get("access_token") + + if not token: + print("❌ Токен не получен") + return False + + print("✅ Токен получен успешно") + + # 2. Получаем список контейнеров + print("\n📋 Получение списка контейнеров...") + headers = {"Authorization": f"Bearer {token}"} + + async with session.get(f"{base_url}/api/services", headers=headers) as response: + if response.status != 200: + print(f"❌ Ошибка получения контейнеров: {response.status}") + return False + + containers = await response.json() + + if not containers: + print("❌ Контейнеры не найдены") + return False + + # Берем первые 3 запущенных контейнера для multi-view теста + running_containers = [c for c in containers if c.get("status") == "running"] + if len(running_containers) < 2: + print("❌ Недостаточно запущенных контейнеров для multi-view теста (нужно минимум 2)") + return False + + test_containers = running_containers[:3] # Берем первые 3 + print(f"✅ Выбрано {len(test_containers)} контейнеров для multi-view теста:") + for i, container in enumerate(test_containers): + print(f" {i+1}. {container['name']} ({container['id'][:12]}...)") + + # 3. Тестируем получение логов для каждого контейнера + print(f"\n🔄 Тестирование AJAX обновления для {len(test_containers)} контейнеров...") + + container_results = {} + + for i, container in enumerate(test_containers): + container_id = container["id"] + container_name = container["name"] + + print(f"\n📊 Контейнер {i+1}: {container_name}") + + # Первый запрос + url = f"{base_url}/api/logs/{container_id}" + params = {"tail": 5} + + async with session.get(url, headers=headers, params=params) as response: + if response.status != 200: + print(f" ❌ Ошибка получения логов: {response.status}") + continue + + data = await response.json() + first_count = data.get('total_lines', 0) + first_timestamp = data.get('timestamp') + + print(f" ✅ Первый запрос: {first_count} строк, timestamp: {first_timestamp}") + + # Ждем немного + await asyncio.sleep(1) + + # Второй запрос с since + params = {"tail": 5, "since": first_timestamp} + + async with session.get(url, headers=headers, params=params) as response: + if response.status != 200: + print(f" ❌ Ошибка получения новых логов: {response.status}") + continue + + data = await response.json() + second_count = data.get('total_lines', 0) + second_timestamp = data.get('timestamp') + + print(f" ✅ Второй запрос: {second_count} строк, timestamp: {second_timestamp}") + + # Сохраняем результаты + container_results[container_id] = { + 'name': container_name, + 'first_count': first_count, + 'second_count': second_count, + 'first_timestamp': first_timestamp, + 'second_timestamp': second_timestamp + } + + # 4. Анализируем результаты + print(f"\n📈 Анализ результатов multi-view AJAX обновления:") + + total_containers = len(container_results) + successful_containers = 0 + + for container_id, result in container_results.items(): + print(f"\n 📦 {result['name']} ({container_id[:12]}...):") + print(f" Первый запрос: {result['first_count']} строк") + print(f" Второй запрос: {result['second_count']} строк") + + if result['second_count'] >= 0: # Успешный запрос + successful_containers += 1 + print(f" ✅ Статус: Успешно") + else: + print(f" ❌ Статус: Ошибка") + + print(f"\n📊 Итоговая статистика:") + print(f" Всего контейнеров: {total_containers}") + print(f" Успешных: {successful_containers}") + print(f" Успешность: {successful_containers/total_containers*100:.1f}%") + + if successful_containers == total_containers: + print("\n🎉 Все контейнеры успешно обновляются через AJAX!") + return True + else: + print(f"\n⚠️ {total_containers - successful_containers} контейнеров имеют проблемы") + return False + + except Exception as e: + print(f"❌ Ошибка тестирования: {e}") + return False + +async def test_concurrent_ajax_requests(): + """Тестирование одновременных AJAX запросов (имитация multi-view)""" + + print(f"\n⚡ Тестирование одновременных AJAX запросов") + print("=" * 50) + + # Настройки + base_url = "http://localhost:9001" + username = os.getenv("LOGBOARD_USER", "admin") + password = os.getenv("LOGBOARD_PASS", "admin") + + async with aiohttp.ClientSession() as session: + try: + # Получаем токен + auth_data = {"username": username, "password": password} + async with session.post(f"{base_url}/api/auth/login", json=auth_data) as response: + auth_response = await response.json() + token = auth_response.get("access_token") + headers = {"Authorization": f"Bearer {token}"} + + # Получаем контейнеры + async with session.get(f"{base_url}/api/services", headers=headers) as response: + containers = await response.json() + running_containers = [c for c in containers if c.get("status") == "running"] + + if len(running_containers) < 2: + print("❌ Недостаточно контейнеров для теста") + return False + + test_containers = running_containers[:3] + + # Тестируем одновременные запросы + print(f"📊 Выполнение одновременных запросов для {len(test_containers)} контейнеров...") + start_time = time.time() + + async def fetch_container_logs(container): + container_id = container["id"] + url = f"{base_url}/api/logs/{container_id}" + params = {"tail": 3} + + try: + async with session.get(url, headers=headers, params=params) as response: + data = await response.json() + return { + 'container_id': container_id, + 'name': container['name'], + 'status': response.status, + 'lines': data.get('total_lines', 0), + 'success': response.status == 200 + } + except Exception as e: + return { + 'container_id': container_id, + 'name': container['name'], + 'status': 'error', + 'lines': 0, + 'success': False, + 'error': str(e) + } + + # Выполняем запросы одновременно + tasks = [fetch_container_logs(container) for container in test_containers] + results = await asyncio.gather(*tasks) + + total_time = time.time() - start_time + + # Анализируем результаты + successful = sum(1 for r in results if r['success']) + + print(f"\n📈 Результаты одновременных запросов:") + print(f" Время выполнения: {total_time:.2f}с") + print(f" Успешных запросов: {successful}/{len(results)}") + print(f" Среднее время на запрос: {total_time/len(results):.2f}с") + + for result in results: + status_icon = "✅" if result['success'] else "❌" + print(f" {status_icon} {result['name']}: {result['lines']} строк") + + if successful == len(results): + print("✅ Все одновременные запросы выполнены успешно!") + return True + else: + print(f"⚠️ {len(results) - successful} запросов завершились с ошибкой") + return False + + except Exception as e: + print(f"❌ Ошибка тестирования одновременных запросов: {e}") + return False + +async def main(): + """Основная функция тестирования""" + print("🔄 Запуск тестов multi-view AJAX обновления") + print("=" * 70) + + # Проверяем, что сервер запущен + try: + async with aiohttp.ClientSession() as session: + async with session.get("http://localhost:9001/healthz") as response: + if response.status != 200: + print("❌ Сервер LogBoard+ не запущен на порту 9001") + print(" Запустите сервер командой: make up") + return False + except Exception: + print("❌ Не удается подключиться к серверу LogBoard+") + print(" Убедитесь, что сервер запущен: make up") + return False + + # Запускаем тесты + success1 = await test_multi_view_ajax() + success2 = await test_concurrent_ajax_requests() + + print("\n" + "=" * 70) + if success1 and success2: + print("🎉 Все тесты прошли успешно!") + print("✅ Multi-view AJAX обновление работает корректно") + return True + else: + print("❌ Некоторые тесты не прошли") + print("🔧 Проверьте логи сервера и настройки") + return False + +if __name__ == "__main__": + result = asyncio.run(main()) + sys.exit(0 if result else 1) diff --git a/app/scripts/test_multi_view_layout.py b/app/scripts/test_multi_view_layout.py new file mode 100644 index 0000000..0c6d0a8 --- /dev/null +++ b/app/scripts/test_multi_view_layout.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +Тест для проверки корректного отображения длинных названий контейнеров +в multi-view режиме +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import asyncio +import aiohttp +import json +import time +from datetime import datetime + +async def test_multi_view_layout(): + """Тестирование отображения длинных названий в multi-view режиме""" + + print("🧪 Тестирование отображения длинных названий в multi-view режиме") + print("=" * 70) + + url = "http://localhost:9001" + username = "admin" + password = "admin" + + print(f"📡 URL: {url}") + print(f"👤 Пользователь: {username}") + print("=" * 50) + + async with aiohttp.ClientSession() as session: + try: + # 1. Получаем токен авторизации + print("🔐 Получение токена авторизации...") + auth_data = {'username': username, 'password': password} + async with session.post(f'{url}/api/auth/login', json=auth_data) as response: + if (response.status != 200): + print(f"❌ Ошибка авторизации: {response.status}") + return False + + auth_response = await response.json() + token = auth_response.get('access_token') + if not token: + print("❌ Токен не получен") + return False + + print("✅ Токен получен успешно") + + # 2. Получаем список сервисов + print("\n📋 Получение списка сервисов...") + headers = {'Authorization': f'Bearer {token}'} + async with session.get(f'{url}/api/services', headers=headers) as response: + if response.status != 200: + print(f"❌ Ошибка получения сервисов: {response.status}") + return False + + services = await response.json() + if not services: + print("❌ Сервисы не найдены") + return False + + print(f"✅ Найдено {len(services)} сервисов") + + # Анализируем названия сервисов + print("\n📊 Анализ названий сервисов:") + long_names = [] + short_names = [] + + for service in services: + name = service['name'] + if len(name) > 30: + long_names.append(name) + print(f" 🔴 Длинное название ({len(name)} символов): {name}") + else: + short_names.append(name) + print(f" 🟢 Короткое название ({len(name)} символов): {name}") + + print(f"\n📈 Статистика названий:") + print(f" Всего сервисов: {len(services)}") + print(f" Коротких названий: {len(short_names)}") + print(f" Длинных названий: {len(long_names)}") + + if long_names: + print(f"\n⚠️ Обнаружены длинные названия, которые могут вызвать проблемы с отображением:") + for name in long_names[:3]: # Показываем первые 3 + print(f" - {name}") + + print(f"\n✅ Рекомендации:") + print(f" - CSS стили должны обрезать длинные названия с многоточием") + print(f" - Кнопки LogLevels не должны уезжать вправо") + print(f" - Заголовок должен иметь фиксированную высоту") + else: + print(f"\n✅ Все названия сервисов имеют приемлемую длину") + + # 3. Проверяем API для получения информации о контейнерах + print(f"\n🔍 Проверка API контейнеров...") + async with session.get(f'{url}/api/containers', headers=headers) as response: + if response.status == 200: + containers = await response.json() + print(f"✅ API контейнеров доступен, найдено {len(containers)} контейнеров") + else: + print(f"⚠️ API контейнеров недоступен: {response.status}") + + # 4. Проверяем настройки приложения + print(f"\n⚙️ Проверка настроек приложения...") + async with session.get(f'{url}/api/settings', headers=headers) as response: + if response.status == 200: + settings = await response.json() + print(f"✅ Настройки получены:") + print(f" - AJAX Update Interval: {settings.get('ajax_update_interval')}ms") + print(f" - Default Tail: {settings.get('default_tail')}") + print(f" - Skip Unhealthy: {settings.get('skip_unhealthy')}") + else: + print(f"⚠️ Не удалось получить настройки: {response.status}") + + # 5. Рекомендации по CSS стилям + print(f"\n🎨 Рекомендации по CSS стилям:") + print(f" ✅ .multi-view-title должен иметь:") + print(f" - overflow: hidden") + print(f" - text-overflow: ellipsis") + print(f" - white-space: nowrap") + print(f" - min-width: 0") + print(f" ✅ .multi-view-levels должен иметь:") + print(f" - flex-shrink: 0") + print(f" ✅ .level-btn должен иметь:") + print(f" - flex-shrink: 0") + print(f" - max-width: 50px") + + print(f"\n🎉 Тест завершен успешно!") + print(f"✅ Анализ названий сервисов выполнен") + return True + + except Exception as e: + print(f"❌ Ошибка тестирования: {e}") + return False + +async def main(): + """Основная функция""" + print("🚀 Запуск теста отображения длинных названий в multi-view") + print("=" * 70) + + result = await test_multi_view_layout() + + print("\n" + "=" * 70) + if result: + print("🎉 Все тесты прошли успешно!") + print("✅ Анализ названий сервисов завершен") + else: + print("❌ Тесты завершились с ошибками") + + return result + +if __name__ == "__main__": + import sys + result = asyncio.run(main()) + sys.exit(0 if result else 1) diff --git a/env.example b/env.example index f0be156..9b9ecb5 100644 --- a/env.example +++ b/env.example @@ -74,3 +74,6 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_FROM= + +# Настройки AJAX обновления логов +LOGBOARD_AJAX_UPDATE_INTERVAL=2000 diff --git a/requirements.txt b/requirements.txt index 87d1bc2..bb84b94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,3 +30,6 @@ passlib[bcrypt]==1.7.4 # Шаблоны Jinja2 jinja2==3.1.2 + +# HTTP клиент для тестирования +aiohttp==3.9.1 diff --git a/templates/index.html b/templates/index.html index b0d7525..8635aab 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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} -
- -
+
+ +
@@ -2010,9 +2027,15 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
+
+ + +
+ + @@ -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; + }; + })();