feat: добавлены страницы ошибок и кнопка OTHER в LogLevels

- Добавлена кнопка OTHER в LogLevels для неклассифицированных логов
- Созданы красивые страницы ошибок с поддержкой темной/светлой темы
- Добавлены обработчики для ошибок 401, 403, 404, 500
- Реализована безопасность: убраны детали ошибок из пользовательского интерфейса
- Кнопка 'Войти в систему' показывается только на странице ошибки 403
- На странице 403 убран error-message, оставлен только auth-notice
- Обновлены счетчики логов для поддержки уровня OTHER
- Добавлены тестовые маршруты для проверки страниц ошибок
- Улучшен UX: адаптивный дизайн, интерактивность, навигация

Автор: Сергей Антропов
Сайт: https://devops.org.ru
This commit is contained in:
Сергей Антропов 2025-08-17 18:49:54 +03:00
parent a979dd2838
commit d0a4b57233
5 changed files with 547 additions and 362 deletions

View File

@ -1,151 +0,0 @@
# Руководство по миграции с Basic Auth на JWT
## 🔄 Миграция с Basic Auth на JWT авторизацию
Это руководство поможет вам перейти с устаревшей системы Basic Auth на современную систему авторизации на основе JWT токенов.
## 📋 Что изменилось
### ✅ Новые возможности:
- **JWT токены** вместо Basic Auth
- **Страница входа** с современным интерфейсом
- **Безопасные сессии** с автоматическим истечением
- **Защищенные API** эндпоинты
- **Автоматическое перенаправление** на страницу входа
### 🔧 Технические изменения:
- Обновлен `app.py` с новой системой авторизации
- Добавлена страница входа `templates/login.html`
- Обновлен фронтенд для работы с JWT токенами
- Добавлены новые зависимости в `requirements.txt`
- Обновлены переменные окружения
## 🚀 Быстрая миграция
### 1. Обновление зависимостей
```bash
# Остановите текущий сервис
make down
# Обновите зависимости
pip install -r requirements.txt
# или для Docker
docker compose build --no-cache
```
### 2. Обновление переменных окружения
```bash
# Обновите .env файл
LOGBOARD_USER=admin # Ваше имя пользователя
LOGBOARD_PASS=admin123 # Ваш пароль
SECRET_KEY=your-secret-key # Уникальный секретный ключ
AUTH_METHOD=jwt # Изменено с basic на jwt
SESSION_TIMEOUT=3600 # Время жизни сессии в секундах
```
### 3. Запуск обновленного сервиса
```bash
# Запустите обновленный сервис
make up
# Проверьте работу
make test-auth
```
## 🔐 Настройка безопасности
### Рекомендуемые настройки для продакшена:
```bash
# Генерируйте уникальные ключи
SECRET_KEY=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
# Используйте сложные пароли
LOGBOARD_PASS=YourComplexPassword123!
# Настройте время жизни сессии
SESSION_TIMEOUT=3600 # 1 час
```
### Переменные окружения:
| Переменная | Описание | Значение по умолчанию |
|------------|----------|----------------------|
| `LOGBOARD_USER` | Имя пользователя | `admin` |
| `LOGBOARD_PASS` | Пароль | `admin123` |
| `SECRET_KEY` | Секретный ключ для JWT | `your-secret-key-here` |
| `AUTH_METHOD` | Метод авторизации | `jwt` |
| `SESSION_TIMEOUT` | Время жизни сессии (сек) | `3600` |
## 🧪 Тестирование
### Автоматическое тестирование:
```bash
# Запустите тесты авторизации
make test-auth
```
### Ручное тестирование:
1. Откройте браузер: `http://localhost:9001`
2. Должны быть перенаправлены на страницу входа
3. Введите логин и пароль
4. Проверьте доступ к панели управления
## 🔄 API изменения
### Новые эндпоинты:
- `POST /api/auth/login` - вход в систему
- `POST /api/auth/logout` - выход из системы
- `GET /api/auth/me` - информация о текущем пользователе
### Изменения в существующих эндпоинтах:
Все API эндпоинты теперь требуют JWT токен в заголовке:
```
Authorization: Bearer <your-jwt-token>
```
### WebSocket изменения:
WebSocket соединения теперь используют JWT токены вместо base64 токенов.
## 🐛 Устранение неполадок
### Проблема: "Unauthorized" ошибки
**Решение:** Проверьте правильность логина и пароля в `.env` файле
### Проблема: Токен истекает слишком быстро
**Решение:** Увеличьте `SESSION_TIMEOUT` в настройках
### Проблема: Не работает WebSocket
**Решение:** Убедитесь, что JWT токен передается в URL параметре `token`
### Проблема: Страница входа не загружается
**Решение:** Проверьте, что `templates/login.html` существует и доступен
## 📝 Логи изменений
### Версия 2.0.0:
- ✅ Удален Basic Auth
- ✅ Добавлена JWT авторизация
- ✅ Создана страница входа
- ✅ Обновлен фронтенд
- ✅ Добавлены тесты авторизации
- ✅ Обновлена документация
## 🆘 Поддержка
Если у вас возникли проблемы с миграцией:
1. Проверьте логи: `make logs`
2. Запустите тесты: `make test-auth`
3. Проверьте настройки в `.env` файле
4. Убедитесь, что все зависимости установлены
## 📞 Контакты
**Автор:** Сергей Антропов
**Сайт:** https://devops.org.ru
---
**Примечание:** После миграции старые Basic Auth токены больше не будут работать. Все пользователи должны будут войти заново через новую систему авторизации.

213
app.py
View File

@ -18,6 +18,8 @@ from pathlib import Path
import docker import docker
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body, Request, Response from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body, Request, Response
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -52,6 +54,182 @@ app = FastAPI(
version="1.0.0" version="1.0.0"
) )
# Обработчики исключений
@app.exception_handler(404)
async def not_found_handler(request: Request, exc: HTTPException):
"""Обработчик ошибки 404 - Страница не найдена"""
return templates.TemplateResponse("error.html", {
"request": request,
"error_code": 404,
"error_title": "Страница не найдена",
"error_message": "Запрашиваемая страница не существует или была перемещена."
}, status_code=404)
@app.exception_handler(401)
async def unauthorized_handler(request: Request, exc: HTTPException):
"""Обработчик ошибки 401 - Не авторизован"""
return templates.TemplateResponse("error.html", {
"request": request,
"error_code": 401,
"error_title": "Требуется авторизация",
"error_message": "Для доступа к этой странице необходимо войти в систему."
}, status_code=401)
@app.exception_handler(403)
async def forbidden_handler(request: Request, exc: HTTPException):
"""Обработчик ошибки 403 - Доступ запрещен"""
return templates.TemplateResponse("error.html", {
"request": request,
"error_code": 403,
"error_title": "Доступ запрещен",
"error_message": "У вас нет прав для доступа к этой странице."
}, status_code=403)
@app.exception_handler(500)
async def internal_server_error_handler(request: Request, exc: HTTPException):
"""Обработчик ошибки 500 - Внутренняя ошибка сервера"""
return templates.TemplateResponse("error.html", {
"request": request,
"error_code": 500,
"error_title": "Внутренняя ошибка сервера",
"error_message": "Произошла непредвиденная ошибка. Попробуйте обновить страницу или обратитесь к администратору."
}, status_code=500)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""Общий обработчик HTTP исключений"""
# Для API маршрутов возвращаем JSON ответ
if request.url.path.startswith("/api/"):
if exc.status_code == 401:
return JSONResponse(
status_code=401,
content={
"error": "unauthorized",
"message": "Требуется авторизация",
"details": "Для доступа к этому API необходимо войти в систему."
},
headers={"WWW-Authenticate": "Bearer"}
)
elif exc.status_code == 403:
return JSONResponse(
status_code=403,
content={
"error": "forbidden",
"message": "Доступ запрещен",
"details": "У вас нет прав для доступа к этому API."
}
)
else:
return JSONResponse(
status_code=exc.status_code,
content={
"error": f"http_{exc.status_code}",
"message": exc.detail or "Произошла ошибка при обработке запроса.",
"details": f"URL: {request.url.path}"
}
)
# Для обычных страниц возвращаем HTML
if exc.status_code == 401:
title = "Требуется авторизация"
message = "Для доступа к этой странице необходимо войти в систему."
elif exc.status_code == 403:
title = "Доступ запрещен"
message = "У вас нет прав для доступа к этой странице."
elif exc.status_code == 404:
title = "Страница не найдена"
message = "Запрашиваемая страница не существует или была перемещена."
elif exc.status_code == 500:
title = "Внутренняя ошибка сервера"
message = "Произошла непредвиденная ошибка. Попробуйте обновить страницу или обратитесь к администратору."
else:
title = f"Ошибка {exc.status_code}"
message = exc.detail or "Произошла ошибка при обработке запроса."
return templates.TemplateResponse("error.html", {
"request": request,
"error_code": exc.status_code,
"error_title": title,
"error_message": message
}, status_code=exc.status_code)
@app.exception_handler(StarletteHTTPException)
async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPException):
"""Обработчик Starlette HTTP исключений (включая ошибки безопасности)"""
# Для API маршрутов возвращаем JSON ответ
if request.url.path.startswith("/api/"):
if exc.status_code == 401:
return JSONResponse(
status_code=401,
content={
"error": "unauthorized",
"message": "Требуется авторизация",
"details": "Для доступа к этому API необходимо войти в систему."
},
headers={"WWW-Authenticate": "Bearer"}
)
elif exc.status_code == 403:
return JSONResponse(
status_code=403,
content={
"error": "forbidden",
"message": "Доступ запрещен",
"details": "У вас нет прав для доступа к этому API."
}
)
else:
return JSONResponse(
status_code=exc.status_code,
content={
"error": f"http_{exc.status_code}",
"message": exc.detail or "Произошла ошибка при обработке запроса.",
"details": f"URL: {request.url.path}"
}
)
# Для обычных страниц возвращаем HTML
if exc.status_code == 401:
title = "Требуется авторизация"
message = "Для доступа к этой странице необходимо войти в систему."
elif exc.status_code == 403:
title = "Доступ запрещен"
message = "У вас нет прав для доступа к этой странице."
elif exc.status_code == 404:
title = "Страница не найдена"
message = "Запрашиваемая страница не существует или была перемещена."
elif exc.status_code == 500:
title = "Внутренняя ошибка сервера"
message = "Произошла непредвиденная ошибка. Попробуйте обновить страницу или обратитесь к администратору."
else:
title = f"Ошибка {exc.status_code}"
message = exc.detail or "Произошла ошибка при обработке запроса."
return templates.TemplateResponse("error.html", {
"request": request,
"error_code": exc.status_code,
"error_title": title,
"error_message": message
}, status_code=exc.status_code)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Общий обработчик всех исключений"""
import traceback
# Логируем ошибку
print(f"❌ Необработанная ошибка: {exc}")
print(f"❌ URL: {request.url.path}")
print(f"❌ Traceback: {traceback.format_exc()}")
return templates.TemplateResponse("error.html", {
"request": request,
"error_code": 500,
"error_title": "Внутренняя ошибка сервера",
"error_message": "Произошла непредвиденная ошибка. Попробуйте обновить страницу или обратитесь к администратору."
}, status_code=500)
# Инициализация шаблонов # Инициализация шаблонов
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@ -119,7 +297,7 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s
if username is None: if username is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен", detail="Недействительный токен. Требуется авторизация.",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
return username return username
@ -410,7 +588,7 @@ async def login(user_data: UserLogin, response: Response):
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверное имя пользователя или пароль", detail="Неверное имя пользователя или пароль. Проверьте учетные данные и попробуйте снова.",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
@ -430,6 +608,32 @@ def healthz():
"""Health check endpoint""" """Health check endpoint"""
return "ok" return "ok"
# Маршруты для тестирования страниц ошибок (только в режиме разработки)
@app.get("/test/error/404")
async def test_404_error():
"""Тест страницы ошибки 404"""
raise HTTPException(status_code=404, detail="Тестовая ошибка 404")
@app.get("/test/error/401")
async def test_401_error():
"""Тест страницы ошибки 401"""
raise HTTPException(status_code=401, detail="Тестовая ошибка 401")
@app.get("/test/error/403")
async def test_403_error():
"""Тест страницы ошибки 403"""
raise HTTPException(status_code=403, detail="Тестовая ошибка 403")
@app.get("/test/error/500")
async def test_500_error():
"""Тест страницы ошибки 500"""
raise HTTPException(status_code=500, detail="Тестовая ошибка 500")
@app.get("/test/error/general")
async def test_general_error():
"""Тест общей ошибки"""
raise Exception("Тестовая общая ошибка")
@app.get("/api/logs/stats/{container_id}") @app.get("/api/logs/stats/{container_id}")
def api_logs_stats(container_id: str, current_user: str = Depends(get_current_user)): def api_logs_stats(container_id: str, current_user: str = Depends(get_current_user)):
"""Получить статистику логов контейнера""" """Получить статистику логов контейнера"""
@ -506,7 +710,10 @@ def api_update_excluded_containers(
} }
) )
else: else:
raise HTTPException(status_code=500, detail="Ошибка сохранения списка") raise HTTPException(
status_code=500,
detail="Ошибка сохранения списка исключенных контейнеров. Попробуйте еще раз или обратитесь к администратору."
)
@app.get("/api/projects") @app.get("/api/projects")
def api_projects(current_user: str = Depends(get_current_user)): def api_projects(current_user: str = Depends(get_current_user)):

259
templates/error.html Normal file
View File

@ -0,0 +1,259 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ error_title }} - LogBoard+</title>
<style>
:root {
--bg: #1a1b26;
--fg: #c0caf5;
--panel: #24283b;
--border: #414868;
--accent: #7aa2f7;
--muted: #565a6e;
--ok: #9ece6a;
--warn: #e0af68;
--err: #f7768e;
--chip: #414868;
}
[data-theme="light"] {
--bg: #d5d6db;
--fg: #343b58;
--panel: #e1e2e7;
--border: #9699a3;
--accent: #34548a;
--muted: #9699a3;
--ok: #485e30;
--warn: #8f5e15;
--err: #8c4351;
--chip: #d5d6db;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.6;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
max-width: 600px;
padding: 2rem;
text-align: center;
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
color: var(--err);
}
.error-code {
font-size: 3rem;
font-weight: bold;
color: var(--accent);
margin-bottom: 0.5rem;
}
.error-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--fg);
}
.error-message {
font-size: 1rem;
color: var(--muted);
margin-bottom: 2rem;
line-height: 1.6;
}
.error-details {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 2rem;
text-align: left;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
color: var(--muted);
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
background: var(--accent);
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
transition: all 0.2s ease;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn:hover {
background: var(--accent);
opacity: 0.9;
transform: translateY(-1px);
}
.btn-secondary {
background: var(--chip);
color: var(--fg);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--border);
}
.btn-group {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.auth-notice {
background: var(--warn);
color: var(--bg);
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
font-weight: 500;
}
.footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.9rem;
}
.footer a {
color: var(--accent);
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.error-container {
padding: 1rem;
}
.error-code {
font-size: 2rem;
}
.error-title {
font-size: 1.25rem;
}
.btn-group {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 300px;
}
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">
{% if error_code == 401 %}
🔐
{% elif error_code == 403 %}
🚫
{% elif error_code == 404 %}
🔍
{% elif error_code == 500 %}
⚠️
{% else %}
{% endif %}
</div>
<div class="error-code">{{ error_code }}</div>
<div class="error-title">{{ error_title }}</div>
{% if error_code == 401 %}
<div class="auth-notice">
🔐 Эта страница требует авторизации
</div>
{% elif error_code == 403 %}
<div class="auth-notice">
🚫 У вас нет прав для доступа к этой странице
</div>
{% endif %}
{% if error_code != 403 %}
<div class="error-message">
{{ error_message }}
</div>
{% endif %}
<div class="btn-group">
<a href="/" class="btn">На главную</a>
{% if error_code == 403 %}
<a href="/login" class="btn btn-secondary">Войти в систему</a>
{% endif %}
<button onclick="history.back()" class="btn btn-secondary">Назад</button>
</div>
<div class="footer">
<p>
LogBoard+ - Веб-панель для просмотра логов микросервисов<br>
Автор: <a href="https://devops.org.ru" target="_blank">Сергей Антропов</a>
</p>
</div>
</div>
<script>
// Автоматическое определение темы
const savedTheme = localStorage.getItem('lb_theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
// Переключатель темы
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('lb_theme', newTheme);
}
// Добавляем обработчик клавиш для переключения темы (Ctrl+T)
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 't') {
e.preventDefault();
toggleTheme();
}
});
</script>
</body>
</html>

View File

@ -562,6 +562,15 @@ a{color:var(--link)}
background: #c82333; background: #c82333;
} }
.other-btn {
background: #6c757d;
color: white;
}
.other-btn:hover {
background: #5a6268;
}
.btn-group { .btn-group {
display: flex; display: flex;
gap: 8px; gap: 8px;
@ -1313,6 +1322,10 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
<span class="counter-label">ERROR</span> <span class="counter-label">ERROR</span>
<span class="counter-value cerr">0</span> <span class="counter-value cerr">0</span>
</button> </button>
<button class="counter-btn other-btn" title="OTHER">
<span class="counter-label">OTHER</span>
<span class="counter-value cother">0</span>
</button>
<button id="logRefreshBtn" class="btn btn-small" title="Обновить логи и счетчики"> <button id="logRefreshBtn" class="btn btn-small" title="Обновить логи и счетчики">
<i class="fas fa-sync-alt"></i> Refresh <i class="fas fa-sync-alt"></i> Refresh
</button> </button>
@ -1341,7 +1354,7 @@ const state = {
open: {}, // id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName} open: {}, // id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName}
layout: 'tabs', // 'tabs' | 'grid2' | 'grid3' | 'grid4' layout: 'tabs', // 'tabs' | 'grid2' | 'grid3' | 'grid4'
filter: null, filter: null,
levels: {debug:true, info:true, warn:true, err:true}, levels: {debug:true, info:true, warn:true, err:true, other:true},
selectedContainers: [], // Массив ID выбранных контейнеров для мультипросмотра selectedContainers: [], // Массив ID выбранных контейнеров для мультипросмотра
multiViewMode: false, // Режим мультипросмотра multiViewMode: false, // Режим мультипросмотра
}; };
@ -1365,6 +1378,7 @@ const els = {
lvlInfo: document.getElementById('lvlInfo'), lvlInfo: document.getElementById('lvlInfo'),
lvlWarn: document.getElementById('lvlWarn'), lvlWarn: document.getElementById('lvlWarn'),
lvlErr: document.getElementById('lvlErr'), lvlErr: document.getElementById('lvlErr'),
lvlOther: document.getElementById('lvlOther'),
layoutBadge: document.getElementById('layoutBadge') || { textContent: '' }, layoutBadge: document.getElementById('layoutBadge') || { textContent: '' },
aggregate: document.getElementById('aggregate') || { checked: false }, aggregate: document.getElementById('aggregate') || { checked: false },
themeSwitch: document.getElementById('themeSwitch'), themeSwitch: document.getElementById('themeSwitch'),
@ -1528,7 +1542,7 @@ function allowedByLevel(cls){
if (cls==='err') return state.levels.err; if (cls==='err') return state.levels.err;
if (cls==='warn') return state.levels.warn; if (cls==='warn') return state.levels.warn;
if (cls==='ok') return state.levels.info; if (cls==='ok') return state.levels.info;
if (cls==='other') return true; // Всегда показываем неклассифицированные строки if (cls==='other') return state.levels.other; // Показываем неклассифицированные строки в зависимости от настройки
return true; return true;
} }
function applyFilter(line){ function applyFilter(line){
@ -2345,7 +2359,7 @@ function openMultiViewWs(service) {
serviceName: service.service, serviceName: service.service,
logEl: logEl, logEl: logEl,
wrapEl: logEl, wrapEl: logEl,
counters: {dbg:0, info:0, warn:0, err:0}, counters: {dbg:0, info:0, warn:0, err:0, other:0},
pausedBuffer: [], pausedBuffer: [],
allLogs: [] // Добавляем буфер для логов allLogs: [] // Добавляем буфер для логов
}; };
@ -2543,7 +2557,8 @@ function openWs(svc, panel){
const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo'); const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo');
const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn'); const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn');
const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr'); const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr');
const counters = {dbg:0,info:0,warn:0,err:0}; const cother = panel.querySelector('.cother') || document.querySelector('.cother');
const counters = {dbg:0,info:0,warn:0,err:0,other:0};
const ws = new WebSocket(wsUrl(id, (svc.service||svc.name), svc.project||'')); const ws = new WebSocket(wsUrl(id, (svc.service||svc.name), svc.project||''));
state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: (svc.service||svc.name)}; state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: (svc.service||svc.name)};
@ -2621,6 +2636,7 @@ function openWs(svc, panel){
cinfo.textContent = counters.info; cinfo.textContent = counters.info;
cwarn.textContent = counters.warn; cwarn.textContent = counters.warn;
cerr.textContent = counters.err; cerr.textContent = counters.err;
if (cother) cother.textContent = counters.other;
}; };
// Убираем автоматический refresh - теперь только по кнопке // Убираем автоматический refresh - теперь только по кнопке
@ -3253,7 +3269,7 @@ function handleLine(id, line){
// Отладочная информация для первых нескольких строк // Отладочная информация для первых нескольких строк
if (!obj.counters) { if (!obj.counters) {
console.error(`handleLine: Counters not initialized for container ${id}`); console.error(`handleLine: Counters not initialized for container ${id}`);
obj.counters = {dbg:0, info:0, warn:0, err:0}; obj.counters = {dbg:0, info:0, warn:0, err:0, other:0};
} }
// Фильтруем сообщение "Connected to container" для всех режимов // Фильтруем сообщение "Connected to container" для всех режимов
@ -3278,6 +3294,7 @@ function handleLine(id, line){
if (cls==='ok') obj.counters.info++; if (cls==='ok') obj.counters.info++;
if (cls==='warn') obj.counters.warn++; if (cls==='warn') obj.counters.warn++;
if (cls==='err') obj.counters.err++; if (cls==='err') obj.counters.err++;
if (cls==='other') obj.counters.other++;
} }
// Для Single View НЕ добавляем перенос строки после каждой строки лога // Для Single View НЕ добавляем перенос строки после каждой строки лога
@ -3643,7 +3660,8 @@ function openFanGroup(services){
const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo'); const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo');
const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn'); const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn');
const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr'); const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr');
const counters = {dbg:0,info:0,warn:0,err:0}; const cother = panel.querySelector('.cother') || document.querySelector('.cother');
const counters = {dbg:0,info:0,warn:0,err:0,other:0};
const ws = new WebSocket(fanGroupUrl(services.join(','), fake.project||'')); const ws = new WebSocket(fanGroupUrl(services.join(','), fake.project||''));
state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: ('group:'+services.join(','))}; state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: ('group:'+services.join(','))};
@ -3663,6 +3681,7 @@ function openFanGroup(services){
cinfo.textContent = counters.info; cinfo.textContent = counters.info;
cwarn.textContent = counters.warn; cwarn.textContent = counters.warn;
cerr.textContent = counters.err; cerr.textContent = counters.err;
if (cother) cother.textContent = counters.other;
// Обновляем видимость счетчиков // Обновляем видимость счетчиков
updateCounterVisibility(); updateCounterVisibility();
@ -3707,11 +3726,13 @@ async function updateCounters(containerId) {
const cinfo = document.querySelector('.cinfo'); const cinfo = document.querySelector('.cinfo');
const cwarn = document.querySelector('.cwarn'); const cwarn = document.querySelector('.cwarn');
const cerr = document.querySelector('.cerr'); const cerr = document.querySelector('.cerr');
const cother = document.querySelector('.cother');
if (cdbg) cdbg.textContent = stats.debug || 0; if (cdbg) cdbg.textContent = stats.debug || 0;
if (cinfo) cinfo.textContent = stats.info || 0; if (cinfo) cinfo.textContent = stats.info || 0;
if (cwarn) cwarn.textContent = stats.warn || 0; if (cwarn) cwarn.textContent = stats.warn || 0;
if (cerr) cerr.textContent = stats.error || 0; if (cerr) cerr.textContent = stats.error || 0;
if (cother) cother.textContent = stats.other || 0;
// Обновляем видимость счетчиков // Обновляем видимость счетчиков
updateCounterVisibility(); updateCounterVisibility();
@ -3779,7 +3800,7 @@ function recalculateCounters() {
const visibleLogs = obj.allLogs.slice(-tailLines); const visibleLogs = obj.allLogs.slice(-tailLines);
// Сбрасываем счетчики // Сбрасываем счетчики
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0}; obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0};
// Пересчитываем счетчики только для отображаемых логов // Пересчитываем счетчики только для отображаемых логов
visibleLogs.forEach(logEntry => { visibleLogs.forEach(logEntry => {
@ -3789,6 +3810,7 @@ function recalculateCounters() {
if (logEntry.cls === 'ok') obj.counters.info++; if (logEntry.cls === 'ok') obj.counters.info++;
if (logEntry.cls === 'warn') obj.counters.warn++; if (logEntry.cls === 'warn') obj.counters.warn++;
if (logEntry.cls === 'err') obj.counters.err++; if (logEntry.cls === 'err') obj.counters.err++;
if (logEntry.cls === 'other') obj.counters.other++;
} }
}); });
@ -3797,11 +3819,13 @@ function recalculateCounters() {
const cinfo = document.querySelector('.cinfo'); const cinfo = document.querySelector('.cinfo');
const cwarn = document.querySelector('.cwarn'); const cwarn = document.querySelector('.cwarn');
const cerr = document.querySelector('.cerr'); const cerr = document.querySelector('.cerr');
const cother = document.querySelector('.cother');
if (cdbg) cdbg.textContent = obj.counters.dbg; if (cdbg) cdbg.textContent = obj.counters.dbg;
if (cinfo) cinfo.textContent = obj.counters.info; if (cinfo) cinfo.textContent = obj.counters.info;
if (cwarn) cwarn.textContent = obj.counters.warn; if (cwarn) cwarn.textContent = obj.counters.warn;
if (cerr) cerr.textContent = obj.counters.err; if (cerr) cerr.textContent = obj.counters.err;
if (cother) cother.textContent = obj.counters.other;
console.log(`Counters recalculated for container ${containerId} (tail: ${tailLines}):`, obj.counters); console.log(`Counters recalculated for container ${containerId} (tail: ${tailLines}):`, obj.counters);
} }
@ -3822,6 +3846,7 @@ function recalculateMultiViewCounters() {
let totalInfo = 0; let totalInfo = 0;
let totalWarn = 0; let totalWarn = 0;
let totalError = 0; let totalError = 0;
let totalOther = 0;
// Пересчитываем счетчики для каждого контейнера // Пересчитываем счетчики для каждого контейнера
for (const containerId of state.selectedContainers) { for (const containerId of state.selectedContainers) {
@ -3832,7 +3857,7 @@ function recalculateMultiViewCounters() {
const visibleLogs = obj.allLogs.slice(-tailLines); const visibleLogs = obj.allLogs.slice(-tailLines);
// Сбрасываем счетчики для этого контейнера // Сбрасываем счетчики для этого контейнера
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0}; obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0};
// Пересчитываем счетчики только для отображаемых логов // Пересчитываем счетчики только для отображаемых логов
visibleLogs.forEach(logEntry => { visibleLogs.forEach(logEntry => {
@ -3842,6 +3867,7 @@ function recalculateMultiViewCounters() {
if (logEntry.cls === 'ok') obj.counters.info++; if (logEntry.cls === 'ok') obj.counters.info++;
if (logEntry.cls === 'warn') obj.counters.warn++; if (logEntry.cls === 'warn') obj.counters.warn++;
if (logEntry.cls === 'err') obj.counters.err++; if (logEntry.cls === 'err') obj.counters.err++;
if (logEntry.cls === 'other') obj.counters.other++;
} }
}); });
@ -3850,6 +3876,7 @@ function recalculateMultiViewCounters() {
totalInfo += obj.counters.info; totalInfo += obj.counters.info;
totalWarn += obj.counters.warn; totalWarn += obj.counters.warn;
totalError += obj.counters.err; totalError += obj.counters.err;
totalOther += obj.counters.other;
} }
// Обновляем отображение счетчиков // Обновляем отображение счетчиков
@ -3857,13 +3884,15 @@ function recalculateMultiViewCounters() {
const cinfo = document.querySelector('.cinfo'); const cinfo = document.querySelector('.cinfo');
const cwarn = document.querySelector('.cwarn'); const cwarn = document.querySelector('.cwarn');
const cerr = document.querySelector('.cerr'); const cerr = document.querySelector('.cerr');
const cother = document.querySelector('.cother');
if (cdbg) cdbg.textContent = totalDebug; if (cdbg) cdbg.textContent = totalDebug;
if (cinfo) cinfo.textContent = totalInfo; if (cinfo) cinfo.textContent = totalInfo;
if (cwarn) cwarn.textContent = totalWarn; if (cwarn) cwarn.textContent = totalWarn;
if (cerr) cerr.textContent = totalError; if (cerr) cerr.textContent = totalError;
if (cother) cother.textContent = totalOther;
console.log(`Multi-view counters recalculated (tail: ${tailLines}):`, { totalDebug, totalInfo, totalWarn, totalError }); console.log(`Multi-view counters recalculated (tail: ${tailLines}):`, { totalDebug, totalInfo, totalWarn, totalError, totalOther });
} }
// Функция для обновления видимости счетчиков // Функция для обновления видимости счетчиков
@ -3872,6 +3901,7 @@ function updateCounterVisibility() {
const infoBtn = document.querySelector('.info-btn'); const infoBtn = document.querySelector('.info-btn');
const warnBtn = document.querySelector('.warn-btn'); const warnBtn = document.querySelector('.warn-btn');
const errorBtn = document.querySelector('.error-btn'); const errorBtn = document.querySelector('.error-btn');
const otherBtn = document.querySelector('.other-btn');
if (debugBtn) { if (debugBtn) {
debugBtn.classList.toggle('disabled', !state.levels.debug); debugBtn.classList.toggle('disabled', !state.levels.debug);
@ -3885,6 +3915,9 @@ function updateCounterVisibility() {
if (errorBtn) { if (errorBtn) {
errorBtn.classList.toggle('disabled', !state.levels.err); errorBtn.classList.toggle('disabled', !state.levels.err);
} }
if (otherBtn) {
otherBtn.classList.toggle('disabled', !state.levels.other);
}
} }
// Функция для обновления логов и счетчиков // Функция для обновления логов и счетчиков
@ -4020,6 +4053,7 @@ function addCounterClickHandlers() {
const infoBtn = document.querySelector('.info-btn'); const infoBtn = document.querySelector('.info-btn');
const warnBtn = document.querySelector('.warn-btn'); const warnBtn = document.querySelector('.warn-btn');
const errorBtn = document.querySelector('.error-btn'); const errorBtn = document.querySelector('.error-btn');
const otherBtn = document.querySelector('.other-btn');
if (debugBtn) { if (debugBtn) {
debugBtn.onclick = () => { debugBtn.onclick = () => {
@ -4084,6 +4118,22 @@ function addCounterClickHandlers() {
} }
}; };
} }
if (otherBtn) {
otherBtn.onclick = () => {
state.levels.other = !state.levels.other;
updateCounterVisibility();
refreshAllLogs();
// Добавляем refresh для обновления логов
if (state.current) {
refreshLogsAndCounters();
}
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
};
}
} }
@ -4464,6 +4514,25 @@ if (els.lvlErr) {
} }
}; };
} }
if (els.lvlOther) {
els.lvlOther.onchange = ()=> {
state.levels.other = els.lvlOther.checked;
updateCounterVisibility();
refreshAllLogs();
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
setTimeout(() => {
recalculateMultiViewCounters();
}, 100);
} else {
// Пересчитываем счетчики для Single View
setTimeout(() => {
recalculateCounters();
}, 100);
}
};
}
// Hotkeys: [ ] — navigation between containers // Hotkeys: [ ] — navigation between containers
window.addEventListener('keydown', async (e)=>{ window.addEventListener('keydown', async (e)=>{

View File

@ -1,199 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Тестовый скрипт для проверки новой системы авторизации LogBoard+
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import requests
import json
import sys
from datetime import datetime
# Настройки
BASE_URL = "http://localhost:9001"
USERNAME = "admin"
PASSWORD = "admin123"
def test_login():
"""Тест входа в систему"""
print("🔐 Тестирование входа в систему...")
try:
response = requests.post(
f"{BASE_URL}/api/auth/login",
json={
"username": USERNAME,
"password": PASSWORD
},
headers={"Content-Type": "application/json"}
)
if response.status_code == 200:
data = response.json()
print(f"✅ Успешный вход! Получен токен: {data['access_token'][:20]}...")
return data['access_token']
else:
print(f"❌ Ошибка входа: {response.status_code} - {response.text}")
return None
except Exception as e:
print(f"❌ Ошибка соединения: {e}")
return None
def test_protected_endpoint(token):
"""Тест защищенного эндпоинта"""
print("\n🔒 Тестирование защищенного эндпоинта...")
try:
response = requests.get(
f"{BASE_URL}/api/auth/me",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code == 200:
data = response.json()
print(f"✅ Доступ к защищенному эндпоинту: {data}")
return True
else:
print(f"❌ Ошибка доступа: {response.status_code} - {response.text}")
return False
except Exception as e:
print(f"❌ Ошибка соединения: {e}")
return False
def test_projects_api(token):
"""Тест API проектов"""
print("\n📋 Тестирование API проектов...")
try:
response = requests.get(
f"{BASE_URL}/api/projects",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code == 200:
projects = response.json()
print(f"✅ Получен список проектов: {projects}")
return True
else:
print(f"❌ Ошибка получения проектов: {response.status_code} - {response.text}")
return False
except Exception as e:
print(f"❌ Ошибка соединения: {e}")
return False
def test_services_api(token):
"""Тест API сервисов"""
print("\n🐳 Тестирование API сервисов...")
try:
response = requests.get(
f"{BASE_URL}/api/services",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code == 200:
services = response.json()
print(f"✅ Получен список сервисов: {len(services)} контейнеров")
return True
else:
print(f"❌ Ошибка получения сервисов: {response.status_code} - {response.text}")
return False
except Exception as e:
print(f"❌ Ошибка соединения: {e}")
return False
def test_logout(token):
"""Тест выхода из системы"""
print("\n🚪 Тестирование выхода из системы...")
try:
response = requests.post(
f"{BASE_URL}/api/auth/logout",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code == 200:
print("✅ Успешный выход из системы")
return True
else:
print(f"❌ Ошибка выхода: {response.status_code} - {response.text}")
return False
except Exception as e:
print(f"❌ Ошибка соединения: {e}")
return False
def test_unauthorized_access():
"""Тест доступа без авторизации"""
print("\n🚫 Тестирование доступа без авторизации...")
try:
response = requests.get(f"{BASE_URL}/api/projects")
if response.status_code == 401:
print("✅ Правильно отклонен доступ без авторизации")
return True
else:
print(f"❌ Неожиданный ответ: {response.status_code} - {response.text}")
return False
except Exception as e:
print(f"❌ Ошибка соединения: {e}")
return False
def main():
"""Основная функция тестирования"""
print("🧪 Тестирование новой системы авторизации LogBoard+")
print("=" * 60)
print(f"📅 Время тестирования: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"🌐 URL: {BASE_URL}")
print(f"👤 Пользователь: {USERNAME}")
print("=" * 60)
# Проверяем доступность сервера
try:
response = requests.get(f"{BASE_URL}/healthz")
if response.status_code != 200:
print("❌ Сервер недоступен")
sys.exit(1)
print("✅ Сервер доступен")
except Exception as e:
print(f"❌ Сервер недоступен: {e}")
sys.exit(1)
# Тестируем вход
token = test_login()
if not token:
print("❌ Тест провален: не удалось войти в систему")
sys.exit(1)
# Тестируем защищенные эндпоинты
success = True
success &= test_protected_endpoint(token)
success &= test_projects_api(token)
success &= test_services_api(token)
# Тестируем выход
success &= test_logout(token)
# Тестируем доступ без авторизации
success &= test_unauthorized_access()
print("\n" + "=" * 60)
if success:
print("🎉 Все тесты пройдены успешно!")
print("✅ Новая система авторизации работает корректно")
else:
print("❌ Некоторые тесты провалились")
print("🔧 Проверьте настройки и логи сервера")
print("=" * 60)
if __name__ == "__main__":
main()