feat: Добавлена новая система авторизации с JWT токенами
- Удален Basic Auth, заменен на современную JWT авторизацию - Добавлена страница входа с красивым интерфейсом - Обновлен фронтенд для работы с JWT токенами - Добавлены новые зависимости: PyJWT, passlib[bcrypt], jinja2 - Создан тестовый скрипт для проверки авторизации - Добавлено руководство по миграции - Обновлена документация и README - Улучшен дизайн поля ввода пароля на странице входа Автор: Сергей Антропов Сайт: https://devops.org.ru
This commit is contained in:
parent
3126ff4eb6
commit
a979dd2838
151
MIGRATION_GUIDE.md
Normal file
151
MIGRATION_GUIDE.md
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
# Руководство по миграции с 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 токены больше не будут работать. Все пользователи должны будут войти заново через новую систему авторизации.
|
14
Makefile
14
Makefile
@ -2,7 +2,7 @@
|
|||||||
# Автор: Сергей Антропов
|
# Автор: Сергей Антропов
|
||||||
# Сайт: https://devops.org.ru
|
# Сайт: https://devops.org.ru
|
||||||
|
|
||||||
.PHONY: help setup build up down restart logs clean status ps shell generate
|
.PHONY: help setup build up down restart logs clean status ps shell generate test-auth
|
||||||
|
|
||||||
# Переменные
|
# Переменные
|
||||||
COMPOSE_FILE = docker-compose.yml
|
COMPOSE_FILE = docker-compose.yml
|
||||||
@ -109,4 +109,16 @@ rebuild: ## Пересобрать и запустить сервисы
|
|||||||
@echo "$(GREEN)Сервисы пересобраны и запущены!$(NC)"
|
@echo "$(GREEN)Сервисы пересобраны и запущены!$(NC)"
|
||||||
@echo "$(YELLOW)Приложение доступно по адресу: http://localhost:9001$(NC)"
|
@echo "$(YELLOW)Приложение доступно по адресу: http://localhost:9001$(NC)"
|
||||||
|
|
||||||
|
test-auth: ## Тестирование новой системы авторизации
|
||||||
|
@echo "$(GREEN)Тестирование системы авторизации...$(NC)"
|
||||||
|
@if [ ! -f test_auth.py ]; then \
|
||||||
|
echo "$(RED)Файл test_auth.py не найден!$(NC)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "$(YELLOW)Убедитесь, что сервис запущен: make up$(NC)"
|
||||||
|
@echo "$(YELLOW)Ожидание запуска сервиса...$(NC)"
|
||||||
|
@sleep 5
|
||||||
|
python3 test_auth.py
|
||||||
|
@echo "$(GREEN)Тестирование завершено!$(NC)"
|
||||||
|
|
||||||
|
|
||||||
|
41
README.md
41
README.md
@ -1,7 +1,34 @@
|
|||||||
|
|
||||||
# LogBoard+
|
# LogBoard+
|
||||||
|
|
||||||
Веб-интерфейс для просмотра логов Docker контейнеров в реальном времени.
|
Веб-интерфейс для просмотра логов Docker контейнеров в реальном времени с современной системой авторизации.
|
||||||
|
|
||||||
|
## 🔐 Новая система авторизации
|
||||||
|
|
||||||
|
LogBoard+ теперь использует современную систему авторизации на основе JWT токенов вместо Basic Auth:
|
||||||
|
|
||||||
|
### Основные изменения:
|
||||||
|
- **JWT токены** вместо Basic Auth
|
||||||
|
- **Страница входа** с красивым интерфейсом
|
||||||
|
- **Безопасные сессии** с автоматическим истечением
|
||||||
|
- **Защищенные API** эндпоинты
|
||||||
|
- **Автоматическое перенаправление** на страницу входа при отсутствии авторизации
|
||||||
|
|
||||||
|
### Как использовать:
|
||||||
|
1. **Откройте LogBoard+** в браузере
|
||||||
|
2. **Автоматически перенаправление** на страницу входа
|
||||||
|
3. **Введите логин и пароль** (по умолчанию: admin/admin123)
|
||||||
|
4. **Получите доступ** к панели управления логами
|
||||||
|
5. **Используйте кнопку "Выйти"** для завершения сессии
|
||||||
|
|
||||||
|
### Настройка пользователей:
|
||||||
|
В файле `.env` или `docker-compose.yml`:
|
||||||
|
```bash
|
||||||
|
LOGBOARD_USER=admin # Имя пользователя
|
||||||
|
LOGBOARD_PASS=admin123 # Пароль
|
||||||
|
SECRET_KEY=your-secret-key # Секретный ключ для JWT
|
||||||
|
SESSION_TIMEOUT=3600 # Время жизни сессии в секундах
|
||||||
|
```
|
||||||
|
|
||||||
## Исправления дублирования строк и правильных переносов строк в режимах Single View и MultiView
|
## Исправления дублирования строк и правильных переносов строк в режимах Single View и MultiView
|
||||||
|
|
||||||
@ -153,8 +180,8 @@ http://localhost:9001
|
|||||||
| Переменная | Описание | Значение по умолчанию |
|
| Переменная | Описание | Значение по умолчанию |
|
||||||
|------------|----------|----------------------|
|
|------------|----------|----------------------|
|
||||||
| `LOGBOARD_PORT` | Порт веб-интерфейса | `9001` |
|
| `LOGBOARD_PORT` | Порт веб-интерфейса | `9001` |
|
||||||
| `LOGBOARD_USER` | Имя пользователя для Basic Auth | `admin` |
|
| `LOGBOARD_USER` | Имя пользователя для авторизации | `admin` |
|
||||||
| `LOGBOARD_PASS` | Пароль для Basic Auth | `s3cret-change-me` |
|
| `LOGBOARD_PASS` | Пароль для авторизации | `admin123` |
|
||||||
| `LOGBOARD_TAIL` | Количество строк истории | `500` |
|
| `LOGBOARD_TAIL` | Количество строк истории | `500` |
|
||||||
| `LOGBOARD_SNAPSHOT_DIR` | Директория для снимков | `/app/snapshots` |
|
| `LOGBOARD_SNAPSHOT_DIR` | Директория для снимков | `/app/snapshots` |
|
||||||
| `LOGBOARD_INDEX_HTML` | Путь к HTML шаблону | `./templates/index.html` |
|
| `LOGBOARD_INDEX_HTML` | Путь к HTML шаблону | `./templates/index.html` |
|
||||||
@ -169,7 +196,7 @@ http://localhost:9001
|
|||||||
| `SECRET_KEY` | Секретный ключ для шифрования | `your-secret-key-here` |
|
| `SECRET_KEY` | Секретный ключ для шифрования | `your-secret-key-here` |
|
||||||
| `ENCRYPTION_KEY` | Ключ шифрования | `your-encryption-key-here` |
|
| `ENCRYPTION_KEY` | Ключ шифрования | `your-encryption-key-here` |
|
||||||
|
|
||||||
**⚠️ Важно:** Измените значения `LOGBOARD_PASS`, `SECRET_KEY` и `ENCRYPTION_KEY` в продакшене!
|
**⚠️ Важно:** Измените значения `LOGBOARD_PASS`, `SECRET_KEY` и `ENCRYPTION_KEY` в продакшене! Для безопасности используйте сложные пароли и уникальные секретные ключи.
|
||||||
|
|
||||||
### Работа с множественными проектами
|
### Работа с множественными проектами
|
||||||
|
|
||||||
@ -265,11 +292,11 @@ docker compose up --build -d
|
|||||||
http://localhost:9001
|
http://localhost:9001
|
||||||
```
|
```
|
||||||
|
|
||||||
По умолчанию логин/пароль для Basic Auth задаются в `docker-compose.yml`:
|
По умолчанию логин/пароль для авторизации задаются в `docker-compose.yml`:
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- LB_USER=admin
|
- LOGBOARD_USER=admin
|
||||||
- LB_PASS=admin
|
- LOGBOARD_PASS=admin123
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
297
app.py
297
app.py
@ -1,56 +1,137 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
LogBoard+ - Веб-панель для просмотра логов микросервисов
|
||||||
|
Автор: Сергей Антропов
|
||||||
|
Сайт: https://devops.org.ru
|
||||||
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Optional, List, Dict
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List, Dict, Union
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body, Request, Response
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
# Настройки приложения
|
||||||
APP_PORT = int(os.getenv("LOGBOARD_PORT", "9001"))
|
APP_PORT = int(os.getenv("LOGBOARD_PORT", "9001"))
|
||||||
DEFAULT_TAIL = int(os.getenv("LOGBOARD_TAIL", "500"))
|
DEFAULT_TAIL = int(os.getenv("LOGBOARD_TAIL", "500"))
|
||||||
BASIC_USER = os.getenv("LOGBOARD_USER", "admin")
|
DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME")
|
||||||
BASIC_PASS = os.getenv("LOGBOARD_PASS", "admin")
|
DEFAULT_PROJECTS = os.getenv("LOGBOARD_PROJECTS")
|
||||||
DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME") # filter by compose project
|
|
||||||
DEFAULT_PROJECTS = os.getenv("LOGBOARD_PROJECTS") # multiple projects filter
|
|
||||||
SKIP_UNHEALTHY = os.getenv("LOGBOARD_SKIP_UNHEALTHY", "true").lower() == "true"
|
SKIP_UNHEALTHY = os.getenv("LOGBOARD_SKIP_UNHEALTHY", "true").lower() == "true"
|
||||||
CONTAINER_LIST_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_LIST_TIMEOUT", "10"))
|
CONTAINER_LIST_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_LIST_TIMEOUT", "10"))
|
||||||
CONTAINER_INFO_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_INFO_TIMEOUT", "3"))
|
CONTAINER_INFO_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_INFO_TIMEOUT", "3"))
|
||||||
HEALTH_CHECK_TIMEOUT = int(os.getenv("LOGBOARD_HEALTH_CHECK_TIMEOUT", "2"))
|
HEALTH_CHECK_TIMEOUT = int(os.getenv("LOGBOARD_HEALTH_CHECK_TIMEOUT", "2"))
|
||||||
|
|
||||||
security = HTTPBasic()
|
# Настройки безопасности
|
||||||
app = FastAPI(title="LogBoard+")
|
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here-change-in-production")
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("SESSION_TIMEOUT", "3600")) // 60 # 1 час по умолчанию
|
||||||
|
|
||||||
|
# Настройки пользователей
|
||||||
|
ADMIN_USERNAME = os.getenv("LOGBOARD_USER", "admin")
|
||||||
|
ADMIN_PASSWORD = os.getenv("LOGBOARD_PASS", "admin")
|
||||||
|
|
||||||
|
# Инициализация FastAPI
|
||||||
|
app = FastAPI(
|
||||||
|
title="LogBoard+",
|
||||||
|
description="Веб-панель для просмотра логов микросервисов",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Инициализация шаблонов
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
# Инициализация безопасности
|
||||||
|
security = HTTPBearer()
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# Инициализация Docker клиента
|
||||||
|
docker_client = docker.from_env()
|
||||||
|
|
||||||
# serve snapshots directory (downloadable files)
|
# serve snapshots directory (downloadable files)
|
||||||
SNAP_DIR = os.getenv("LOGBOARD_SNAPSHOT_DIR", "./snapshots")
|
SNAP_DIR = os.getenv("LOGBOARD_SNAPSHOT_DIR", "./snapshots")
|
||||||
os.makedirs(SNAP_DIR, exist_ok=True)
|
os.makedirs(SNAP_DIR, exist_ok=True)
|
||||||
app.mount("/snapshots", StaticFiles(directory=SNAP_DIR), name="snapshots")
|
app.mount("/snapshots", StaticFiles(directory=SNAP_DIR), name="snapshots")
|
||||||
|
|
||||||
docker_client = docker.from_env()
|
# Модели данных
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
# ---------- AUTH ----------
|
class Token(BaseModel):
|
||||||
def check_basic(creds: HTTPBasicCredentials = Depends(security)):
|
access_token: str
|
||||||
if creds.username == BASIC_USER and creds.password == BASIC_PASS:
|
token_type: str
|
||||||
return creds
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid credentials",
|
|
||||||
headers={"WWW-Authenticate": "Basic"})
|
|
||||||
|
|
||||||
def token_from_creds(creds: HTTPBasicCredentials) -> str:
|
class TokenData(BaseModel):
|
||||||
return base64.b64encode(f"{creds.username}:{creds.password}".encode()).decode()
|
username: Optional[str] = None
|
||||||
|
|
||||||
def verify_ws_token(token: str) -> bool:
|
# Функции для работы с паролями
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Проверяет пароль"""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""Хеширует пароль"""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
# Функции для работы с JWT токенами
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
"""Создает JWT токен"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
def verify_token(token: str) -> Optional[str]:
|
||||||
|
"""Проверяет JWT токен и возвращает имя пользователя"""
|
||||||
try:
|
try:
|
||||||
raw = base64.b64decode(token.encode(), validate=True).decode()
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
except Exception:
|
username: str = payload.get("sub")
|
||||||
return False
|
if username is None:
|
||||||
return raw == f"{BASIC_USER}:{BASIC_PASS}"
|
return None
|
||||||
|
return username
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Функция для проверки аутентификации
|
||||||
|
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
||||||
|
"""Получает текущего пользователя из токена"""
|
||||||
|
token = credentials.credentials
|
||||||
|
username = verify_token(token)
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Недействительный токен",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return username
|
||||||
|
|
||||||
|
# Функция для проверки пользователя
|
||||||
|
def authenticate_user(username: str, password: str) -> bool:
|
||||||
|
"""Аутентифицирует пользователя"""
|
||||||
|
if username == ADMIN_USERNAME:
|
||||||
|
# Для простоты используем прямое сравнение паролей
|
||||||
|
# В продакшене рекомендуется использовать хешированные пароли
|
||||||
|
return password == ADMIN_PASSWORD
|
||||||
|
return False
|
||||||
|
|
||||||
# ---------- DOCKER HELPERS ----------
|
# ---------- DOCKER HELPERS ----------
|
||||||
def load_excluded_containers() -> List[str]:
|
def load_excluded_containers() -> List[str]:
|
||||||
@ -274,30 +355,83 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
|
|||||||
|
|
||||||
# ---------- HTML ----------
|
# ---------- HTML ----------
|
||||||
INDEX_PATH = os.getenv("LOGBOARD_INDEX_HTML", "./templates/index.html")
|
INDEX_PATH = os.getenv("LOGBOARD_INDEX_HTML", "./templates/index.html")
|
||||||
def load_index_html(token: str) -> str:
|
def load_index_html() -> str:
|
||||||
|
"""Загружает HTML шаблон главной страницы"""
|
||||||
with open(INDEX_PATH, "r", encoding="utf-8") as f:
|
with open(INDEX_PATH, "r", encoding="utf-8") as f:
|
||||||
html = f.read()
|
return f.read()
|
||||||
return html.replace("__TOKEN__", token)
|
|
||||||
|
def load_login_html() -> str:
|
||||||
|
"""Загружает HTML шаблон страницы входа"""
|
||||||
|
login_path = "./templates/login.html"
|
||||||
|
with open(login_path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
# ---------- ROUTES ----------
|
# ---------- ROUTES ----------
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(creds: HTTPBasicCredentials = Depends(check_basic)):
|
async def index(request: Request):
|
||||||
token = token_from_creds(creds)
|
"""Главная страница приложения"""
|
||||||
return HTMLResponse(
|
# Проверяем наличие токена в cookie
|
||||||
content=load_index_html(token),
|
access_token = request.cookies.get("access_token")
|
||||||
headers={
|
if not access_token:
|
||||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
return RedirectResponse(url="/login")
|
||||||
"Pragma": "no-cache",
|
|
||||||
"Expires": "0"
|
# Проверяем валидность токена
|
||||||
}
|
username = verify_token(access_token)
|
||||||
)
|
if not username:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request):
|
||||||
|
"""Страница входа"""
|
||||||
|
return templates.TemplateResponse("login.html", {"request": request})
|
||||||
|
|
||||||
|
@app.post("/api/auth/login", response_model=Token)
|
||||||
|
async def login(user_data: UserLogin, response: Response):
|
||||||
|
"""API для входа в систему"""
|
||||||
|
if authenticate_user(user_data.username, user_data.password):
|
||||||
|
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": user_data.username}, expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
|
||||||
|
# Устанавливаем cookie с токеном
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token",
|
||||||
|
value=access_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=False, # Установите True для HTTPS
|
||||||
|
samesite="lax",
|
||||||
|
max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Неверное имя пользователя или пароль",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/auth/logout")
|
||||||
|
async def logout(response: Response):
|
||||||
|
"""API для выхода из системы"""
|
||||||
|
response.delete_cookie(key="access_token")
|
||||||
|
return {"message": "Успешный выход из системы"}
|
||||||
|
|
||||||
|
@app.get("/api/auth/me")
|
||||||
|
async def get_current_user_info(current_user: str = Depends(get_current_user)):
|
||||||
|
"""Получить информацию о текущем пользователе"""
|
||||||
|
return {"username": current_user}
|
||||||
|
|
||||||
@app.get("/healthz", response_class=PlainTextResponse)
|
@app.get("/healthz", response_class=PlainTextResponse)
|
||||||
def healthz():
|
def healthz():
|
||||||
|
"""Health check endpoint"""
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
@app.get("/api/logs/stats/{container_id}")
|
@app.get("/api/logs/stats/{container_id}")
|
||||||
def api_logs_stats(container_id: str, _: HTTPBasicCredentials = Depends(check_basic)):
|
def api_logs_stats(container_id: str, current_user: str = Depends(get_current_user)):
|
||||||
"""Получить статистику логов контейнера"""
|
"""Получить статистику логов контейнера"""
|
||||||
try:
|
try:
|
||||||
# Ищем контейнер
|
# Ищем контейнер
|
||||||
@ -344,10 +478,8 @@ def api_logs_stats(container_id: str, _: HTTPBasicCredentials = Depends(check_ba
|
|||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
@app.get("/api/excluded-containers")
|
@app.get("/api/excluded-containers")
|
||||||
def api_get_excluded_containers(_: HTTPBasicCredentials = Depends(check_basic)):
|
def api_get_excluded_containers(current_user: str = Depends(get_current_user)):
|
||||||
"""
|
"""Получить список исключенных контейнеров"""
|
||||||
Получить список исключенных контейнеров
|
|
||||||
"""
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"excluded_containers": load_excluded_containers()},
|
content={"excluded_containers": load_excluded_containers()},
|
||||||
headers={
|
headers={
|
||||||
@ -360,11 +492,9 @@ def api_get_excluded_containers(_: HTTPBasicCredentials = Depends(check_basic)):
|
|||||||
@app.post("/api/excluded-containers")
|
@app.post("/api/excluded-containers")
|
||||||
def api_update_excluded_containers(
|
def api_update_excluded_containers(
|
||||||
containers: List[str] = Body(...),
|
containers: List[str] = Body(...),
|
||||||
_: HTTPBasicCredentials = Depends(check_basic)
|
current_user: str = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""Обновить список исключенных контейнеров"""
|
||||||
Обновить список исключенных контейнеров
|
|
||||||
"""
|
|
||||||
success = save_excluded_containers(containers)
|
success = save_excluded_containers(containers)
|
||||||
if success:
|
if success:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -379,7 +509,7 @@ def api_update_excluded_containers(
|
|||||||
raise HTTPException(status_code=500, detail="Ошибка сохранения списка")
|
raise HTTPException(status_code=500, detail="Ошибка сохранения списка")
|
||||||
|
|
||||||
@app.get("/api/projects")
|
@app.get("/api/projects")
|
||||||
def api_projects(_: HTTPBasicCredentials = Depends(check_basic)):
|
def api_projects(current_user: str = Depends(get_current_user)):
|
||||||
"""Получить список всех проектов Docker Compose"""
|
"""Получить список всех проектов Docker Compose"""
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=get_all_projects(),
|
content=get_all_projects(),
|
||||||
@ -391,12 +521,12 @@ def api_projects(_: HTTPBasicCredentials = Depends(check_basic)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/services")
|
@app.get("/api/services")
|
||||||
def api_services(projects: Optional[str] = Query(None), include_stopped: bool = Query(False),
|
def api_services(
|
||||||
_: HTTPBasicCredentials = Depends(check_basic)):
|
projects: Optional[str] = Query(None),
|
||||||
"""
|
include_stopped: bool = Query(False),
|
||||||
Получить список контейнеров с поддержкой множественных проектов
|
current_user: str = Depends(get_current_user)
|
||||||
projects: список проектов через запятую (например: "project1,project2")
|
):
|
||||||
"""
|
"""Получить список контейнеров с поддержкой множественных проектов"""
|
||||||
project_list = None
|
project_list = None
|
||||||
if projects:
|
if projects:
|
||||||
project_list = [p.strip() for p in projects.split(",") if p.strip()]
|
project_list = [p.strip() for p in projects.split(",") if p.strip()]
|
||||||
@ -416,11 +546,12 @@ def api_services(projects: Optional[str] = Query(None), include_stopped: bool =
|
|||||||
|
|
||||||
@app.post("/api/snapshot")
|
@app.post("/api/snapshot")
|
||||||
def api_snapshot(
|
def api_snapshot(
|
||||||
creds: HTTPBasicCredentials = Depends(check_basic),
|
current_user: str = Depends(get_current_user),
|
||||||
container_id: str = Body(..., embed=True),
|
container_id: str = Body(..., embed=True),
|
||||||
service: str = Body("", embed=True),
|
service: str = Body("", embed=True),
|
||||||
content: str = Body("", embed=True),
|
content: str = Body("", embed=True),
|
||||||
):
|
):
|
||||||
|
"""Сохранить снимок логов"""
|
||||||
# Save posted content as a snapshot file
|
# Save posted content as a snapshot file
|
||||||
safe_service = re.sub(r"[^a-zA-Z0-9_.-]+", "_", service or container_id[:12])
|
safe_service = re.sub(r"[^a-zA-Z0-9_.-]+", "_", service or container_id[:12])
|
||||||
ts = os.getenv("TZ_TS") or ""
|
ts = os.getenv("TZ_TS") or ""
|
||||||
@ -434,19 +565,23 @@ def api_snapshot(
|
|||||||
return {"file": fname, "url": url}
|
return {"file": fname, "url": url}
|
||||||
|
|
||||||
# WebSocket: verify token (?token=base64(user:pass))
|
# WebSocket: verify token (?token=base64(user:pass))
|
||||||
|
|
||||||
# WebSocket: verify token (?token=base64(user:pass)). Supports auto-reconnect by service label.
|
|
||||||
@app.websocket("/ws/logs/{container_id}")
|
@app.websocket("/ws/logs/{container_id}")
|
||||||
async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
||||||
service: Optional[str] = None, project: Optional[str] = None):
|
service: Optional[str] = None, project: Optional[str] = None):
|
||||||
"""Упрощенный WebSocket для получения логов контейнера"""
|
"""WebSocket для получения логов контейнера"""
|
||||||
|
|
||||||
# Принимаем соединение
|
# Принимаем соединение
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
|
|
||||||
# Проверяем токен
|
# Проверяем токен
|
||||||
if not token or not verify_ws_token(token):
|
if not token:
|
||||||
await ws.send_text("ERROR: unauthorized")
|
await ws.send_text("ERROR: token required")
|
||||||
|
await ws.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
username = verify_token(token)
|
||||||
|
if not username:
|
||||||
|
await ws.send_text("ERROR: invalid token")
|
||||||
await ws.close()
|
await ws.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -499,20 +634,23 @@ async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, to
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
print(f"LogBoard+ http://0.0.0.0:{APP_PORT}")
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=APP_PORT)
|
|
||||||
|
|
||||||
|
|
||||||
# WebSocket: fan-in by compose service (aggregate all replicas), prefixing with short container id
|
# WebSocket: fan-in by compose service (aggregate all replicas), prefixing with short container id
|
||||||
@app.websocket("/ws/fan/{service_name}")
|
@app.websocket("/ws/fan/{service_name}")
|
||||||
async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
||||||
project: Optional[str] = None):
|
project: Optional[str] = None):
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
if not token or not verify_ws_token(token):
|
|
||||||
await ws.send_text("ERROR: unauthorized")
|
# Проверяем токен
|
||||||
await ws.close(); return
|
if not token:
|
||||||
|
await ws.send_text("ERROR: token required")
|
||||||
|
await ws.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
username = verify_token(token)
|
||||||
|
if not username:
|
||||||
|
await ws.send_text("ERROR: invalid token")
|
||||||
|
await ws.close()
|
||||||
|
return
|
||||||
|
|
||||||
# Track active streaming tasks by container id
|
# Track active streaming tasks by container id
|
||||||
active = {}
|
active = {}
|
||||||
@ -589,15 +727,23 @@ async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, tok
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# WebSocket: fan-in for multiple compose services (comma-separated), optional project filter.
|
# WebSocket: fan-in for multiple compose services (comma-separated), optional project filter.
|
||||||
@app.websocket("/ws/fan_group")
|
@app.websocket("/ws/fan_group")
|
||||||
async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
||||||
project: Optional[str] = None):
|
project: Optional[str] = None):
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
if not token or not verify_ws_token(token):
|
|
||||||
await ws.send_text("ERROR: unauthorized")
|
# Проверяем токен
|
||||||
await ws.close(); return
|
if not token:
|
||||||
|
await ws.send_text("ERROR: token required")
|
||||||
|
await ws.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
username = verify_token(token)
|
||||||
|
if not username:
|
||||||
|
await ws.send_text("ERROR: invalid token")
|
||||||
|
await ws.close()
|
||||||
|
return
|
||||||
|
|
||||||
svc_set = {s.strip() for s in services.split(",") if s.strip()}
|
svc_set = {s.strip() for s in services.split(",") if s.strip()}
|
||||||
if not svc_set:
|
if not svc_set:
|
||||||
@ -673,3 +819,8 @@ async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, t
|
|||||||
await ws.close()
|
await ws.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
print(f"LogBoard+ http://0.0.0.0:{APP_PORT}")
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=APP_PORT)
|
||||||
|
@ -22,7 +22,7 @@ services:
|
|||||||
CONNECTION_TIMEOUT: '30'
|
CONNECTION_TIMEOUT: '30'
|
||||||
READ_TIMEOUT: '60'
|
READ_TIMEOUT: '60'
|
||||||
AUTH_ENABLED: 'true'
|
AUTH_ENABLED: 'true'
|
||||||
AUTH_METHOD: basic
|
AUTH_METHOD: jwt
|
||||||
SESSION_TIMEOUT: '3600'
|
SESSION_TIMEOUT: '3600'
|
||||||
NOTIFICATIONS_ENABLED: 'false'
|
NOTIFICATIONS_ENABLED: 'false'
|
||||||
SMTP_HOST: ''
|
SMTP_HOST: ''
|
||||||
|
@ -63,8 +63,9 @@ LOGBOARD_HEALTH_CHECK_TIMEOUT=2
|
|||||||
|
|
||||||
# Настройки аутентификации
|
# Настройки аутентификации
|
||||||
AUTH_ENABLED=true
|
AUTH_ENABLED=true
|
||||||
AUTH_METHOD=basic
|
AUTH_METHOD=jwt
|
||||||
SESSION_TIMEOUT=3600
|
SESSION_TIMEOUT=3600
|
||||||
|
SECRET_KEY=your-secret-key-here-change-in-production
|
||||||
|
|
||||||
# Настройки уведомлений
|
# Настройки уведомлений
|
||||||
NOTIFICATIONS_ENABLED=false
|
NOTIFICATIONS_ENABLED=false
|
||||||
|
@ -21,3 +21,12 @@ websockets==12.0
|
|||||||
# Зависимости для Docker SDK
|
# Зависимости для Docker SDK
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
urllib3==2.1.0
|
urllib3==2.1.0
|
||||||
|
|
||||||
|
# JWT токены
|
||||||
|
PyJWT==2.8.0
|
||||||
|
|
||||||
|
# Хеширование паролей
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
|
||||||
|
# Шаблоны Jinja2
|
||||||
|
jinja2==3.1.2
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
<title>LogBoard+</title>
|
<title>LogBoard+</title>
|
||||||
<meta name="x-token" content="__TOKEN__"/>
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
/* THEME TOKENS */
|
/* THEME TOKENS */
|
||||||
@ -1336,7 +1336,6 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
|
|||||||
console.log('LogBoard+ script loaded - VERSION 2');
|
console.log('LogBoard+ script loaded - VERSION 2');
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
token: (document.querySelector('meta[name="x-token"]')?.content)||'',
|
|
||||||
services: [],
|
services: [],
|
||||||
current: null,
|
current: null,
|
||||||
open: {}, // id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName}
|
open: {}, // id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName}
|
||||||
@ -1714,8 +1713,24 @@ async function fetchProjects(){
|
|||||||
try {
|
try {
|
||||||
console.log('Fetching projects...');
|
console.log('Fetching projects...');
|
||||||
const url = new URL(location.origin + '/api/projects');
|
const url = new URL(location.origin + '/api/projects');
|
||||||
const res = await fetch(url);
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) {
|
||||||
|
console.error('No access token found');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
if (!res.ok){
|
if (!res.ok){
|
||||||
|
if (res.status === 401) {
|
||||||
|
console.error('Unauthorized, redirecting to login');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Failed to fetch projects:', res.status, res.statusText);
|
console.error('Failed to fetch projects:', res.status, res.statusText);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1835,8 +1850,23 @@ function getSelectedProjects() {
|
|||||||
// Функции для работы с исключенными контейнерами
|
// Функции для работы с исключенными контейнерами
|
||||||
async function loadExcludedContainers() {
|
async function loadExcludedContainers() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/excluded-containers');
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) {
|
||||||
|
console.error('No access token found');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/excluded-containers', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.error('Unauthorized, redirecting to login');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return [];
|
||||||
|
}
|
||||||
console.error('Ошибка загрузки исключенных контейнеров:', response.status);
|
console.error('Ошибка загрузки исключенных контейнеров:', response.status);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -1850,15 +1880,27 @@ async function loadExcludedContainers() {
|
|||||||
|
|
||||||
async function saveExcludedContainers(containers) {
|
async function saveExcludedContainers(containers) {
|
||||||
try {
|
try {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) {
|
||||||
|
console.error('No access token found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/excluded-containers', {
|
const response = await fetch('/api/excluded-containers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify(containers)
|
body: JSON.stringify(containers)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.error('Unauthorized, redirecting to login');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
console.error('Ошибка сохранения исключенных контейнеров:', response.status);
|
console.error('Ошибка сохранения исключенных контейнеров:', response.status);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -2381,8 +2423,24 @@ async function fetchServices(){
|
|||||||
url.searchParams.set('projects', selectedProjects.join(','));
|
url.searchParams.set('projects', selectedProjects.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(url);
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) {
|
||||||
|
console.error('No access token found');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
if (!res.ok){
|
if (!res.ok){
|
||||||
|
if (res.status === 401) {
|
||||||
|
console.error('Unauthorized, redirecting to login');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Auth failed (HTTP):', res.status, res.statusText);
|
console.error('Auth failed (HTTP):', res.status, res.statusText);
|
||||||
alert('Auth failed (HTTP)');
|
alert('Auth failed (HTTP)');
|
||||||
return;
|
return;
|
||||||
@ -2404,7 +2462,7 @@ async function fetchServices(){
|
|||||||
function wsUrl(containerId, service, project){
|
function wsUrl(containerId, service, project){
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const tail = els.tail.value || '500';
|
const tail = els.tail.value || '500';
|
||||||
const token = encodeURIComponent(state.token || '');
|
const token = encodeURIComponent(localStorage.getItem('access_token') || '');
|
||||||
const sp = service?`&service=${encodeURIComponent(service)}`:'';
|
const sp = service?`&service=${encodeURIComponent(service)}`:'';
|
||||||
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
||||||
if (els.aggregate && els.aggregate.checked && service){
|
if (els.aggregate && els.aggregate.checked && service){
|
||||||
@ -2441,9 +2499,28 @@ async function sendSnapshot(id){
|
|||||||
|
|
||||||
console.log('Saving snapshot with content length:', text.length);
|
console.log('Saving snapshot with content length:', text.length);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) {
|
||||||
|
console.error('No access token found');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {container_id: id, service: o.serviceName || id, content: text};
|
const payload = {container_id: id, service: o.serviceName || id, content: text};
|
||||||
const res = await fetch('/api/snapshot', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
|
const res = await fetch('/api/snapshot', {
|
||||||
|
method:'POST',
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
if (!res.ok){
|
if (!res.ok){
|
||||||
|
if (res.status === 401) {
|
||||||
|
console.error('Unauthorized, redirecting to login');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Snapshot failed:', res.status, res.statusText);
|
console.error('Snapshot failed:', res.status, res.statusText);
|
||||||
alert('snapshot failed');
|
alert('snapshot failed');
|
||||||
return;
|
return;
|
||||||
@ -3545,7 +3622,7 @@ els.copyFab.addEventListener('click', async ()=>{
|
|||||||
function fanGroupUrl(servicesCsv, project){
|
function fanGroupUrl(servicesCsv, project){
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const tail = els.tail.value || '500';
|
const tail = els.tail.value || '500';
|
||||||
const token = encodeURIComponent(state.token || '');
|
const token = encodeURIComponent(localStorage.getItem('access_token') || '');
|
||||||
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
||||||
return `${proto}://${location.host}/ws/fan_group?services=${encodeURIComponent(servicesCsv)}&tail=${tail}&token=${token}${pj}`;
|
return `${proto}://${location.host}/ws/fan_group?services=${encodeURIComponent(servicesCsv)}&tail=${tail}&token=${token}${pj}`;
|
||||||
}
|
}
|
||||||
@ -3613,7 +3690,17 @@ if (els.groupBtn && els.groupBtn.onclick !== null) {
|
|||||||
// Функция для обновления счетчиков через Ajax
|
// Функция для обновления счетчиков через Ajax
|
||||||
async function updateCounters(containerId) {
|
async function updateCounters(containerId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/logs/stats/${containerId}`);
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) {
|
||||||
|
console.error('No access token found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/logs/stats/${containerId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const stats = await response.json();
|
const stats = await response.json();
|
||||||
const cdbg = document.querySelector('.cdbg');
|
const cdbg = document.querySelector('.cdbg');
|
||||||
@ -4176,12 +4263,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Обработчик для кнопки выхода
|
// Обработчик для кнопки выхода
|
||||||
if (els.logoutBtn) {
|
if (els.logoutBtn) {
|
||||||
els.logoutBtn.addEventListener('click', () => {
|
els.logoutBtn.addEventListener('click', async () => {
|
||||||
if (confirm('Вы уверены, что хотите выйти?')) {
|
if (confirm('Вы уверены, что хотите выйти?')) {
|
||||||
// Очищаем localStorage
|
try {
|
||||||
localStorage.clear();
|
// Вызываем API для выхода
|
||||||
// Перенаправляем на страницу входа
|
await fetch('/api/auth/logout', {
|
||||||
window.location.href = '/';
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
} finally {
|
||||||
|
// Очищаем localStorage
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
// Перенаправляем на страницу входа
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -4398,8 +4497,37 @@ window.addEventListener('keydown', async (e)=>{
|
|||||||
// Инициализация
|
// Инициализация
|
||||||
(async function init() {
|
(async function init() {
|
||||||
console.log('Initializing LogBoard+...');
|
console.log('Initializing LogBoard+...');
|
||||||
|
|
||||||
|
// Проверяем авторизацию
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) {
|
||||||
|
console.log('No access token found, redirecting to login');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем валидность токена
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/me', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log('Invalid token, redirecting to login');
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking auth:', error);
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Elements found:', {
|
console.log('Elements found:', {
|
||||||
|
|
||||||
containerList: !!els.containerList,
|
containerList: !!els.containerList,
|
||||||
logTitle: !!els.logTitle,
|
logTitle: !!els.logTitle,
|
||||||
logContent: !!els.logContent,
|
logContent: !!els.logContent,
|
||||||
|
462
templates/login.html
Normal file
462
templates/login.html
Normal file
@ -0,0 +1,462 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
|
<title>LogBoard+ - Вход</title>
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
/* THEME TOKENS */
|
||||||
|
:root{
|
||||||
|
--bg:#0e0f13; --panel:#151821; --muted:#8b94a8; --accent:#7aa2f7; --ok:#9ece6a; --warn:#e0af68; --err:#f7768e; --fg:#e5e9f0;
|
||||||
|
--border:#2a2f3a; --tab:#1b2030; --tab-active:#22283a; --chip:#2b3142; --link:#9ab8ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="light"]{
|
||||||
|
--bg:#f7f9fc; --panel:#ffffff; --muted:#667085; --accent:#3b82f6; --ok:#15803d; --warn:#b45309; --err:#b91c1c; --fg:#0f172a;
|
||||||
|
--border:#e5e7eb; --tab:#eef2ff; --tab-active:#dbeafe; --chip:#eef2f7; --link:#1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{height:100%; margin: 0; padding: 0;}
|
||||||
|
body{background:var(--bg);color:var(--fg);font:13px/1.45 ui-monospace,Menlo,Consolas,monospace;}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
background: var(--chip);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--fg);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(122, 162, 247, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px; /* Ближе к краю для всех устройств */
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle:hover {
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--chip);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle:active {
|
||||||
|
transform: translateY(-50%) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-wrapper .form-input {
|
||||||
|
padding-right: 40px; /* Место для кнопки */
|
||||||
|
width: 100%; /* Поле на всю ширину */
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0b0d12;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:hover {
|
||||||
|
background: #6b8fd8;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(122, 162, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:disabled {
|
||||||
|
background: var(--muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: rgba(247, 118, 142, 0.1);
|
||||||
|
border: 1px solid var(--err);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--err);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
display: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top: 2px solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner.show {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme toggle */
|
||||||
|
.theme-toggle {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle input {
|
||||||
|
appearance: none;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
position: relative;
|
||||||
|
background: var(--chip);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle input::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--fg);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle input:checked::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle input:checked {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-card {
|
||||||
|
padding: 24px;
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="theme-toggle">
|
||||||
|
<span>Theme</span>
|
||||||
|
<input id="themeSwitch" type="checkbox" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="login-logo">
|
||||||
|
<i class="fas fa-terminal"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="login-title">LogBoard+</h1>
|
||||||
|
<p class="login-subtitle">Веб-панель для просмотра логов микросервисов</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="login-form" id="loginForm">
|
||||||
|
<div class="error-message" id="errorMessage"></div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username" class="form-label">Имя пользователя</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Введите имя пользователя"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password" class="form-label">Пароль</label>
|
||||||
|
<div class="password-input-wrapper">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
|
<button type="button" class="password-toggle" id="passwordToggle">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="login-button" id="loginButton">
|
||||||
|
<span class="loading-spinner" id="loadingSpinner"></span>
|
||||||
|
<span id="buttonText">Войти</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Автор: <a href="https://devops.org.ru" target="_blank">Сергей Антропов</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Theme toggle
|
||||||
|
(function initTheme(){
|
||||||
|
const saved = localStorage.lb_theme || 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', saved);
|
||||||
|
document.getElementById('themeSwitch').checked = (saved==='light');
|
||||||
|
document.getElementById('themeSwitch').addEventListener('change', ()=>{
|
||||||
|
const t = document.getElementById('themeSwitch').checked ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
localStorage.lb_theme = t;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Password toggle
|
||||||
|
document.getElementById('passwordToggle').addEventListener('click', function() {
|
||||||
|
const passwordInput = document.getElementById('password');
|
||||||
|
const icon = this.querySelector('i');
|
||||||
|
|
||||||
|
if (passwordInput.type === 'password') {
|
||||||
|
passwordInput.type = 'text';
|
||||||
|
icon.className = 'fas fa-eye-slash';
|
||||||
|
} else {
|
||||||
|
passwordInput.type = 'password';
|
||||||
|
icon.className = 'fas fa-eye';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login form
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const loginButton = document.getElementById('loginButton');
|
||||||
|
const buttonText = document.getElementById('buttonText');
|
||||||
|
const loadingSpinner = document.getElementById('loadingSpinner');
|
||||||
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!username || !password) {
|
||||||
|
showError('Пожалуйста, заполните все поля');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
loginButton.disabled = true;
|
||||||
|
buttonText.textContent = 'Вход...';
|
||||||
|
loadingSpinner.classList.add('show');
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: username,
|
||||||
|
password: password
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Store token in localStorage
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
|
||||||
|
// Redirect to main page
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
showError(errorData.detail || 'Ошибка входа в систему');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
showError('Ошибка соединения с сервером');
|
||||||
|
} finally {
|
||||||
|
// Reset loading state
|
||||||
|
loginButton.disabled = false;
|
||||||
|
buttonText.textContent = 'Войти';
|
||||||
|
loadingSpinner.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
errorMessage.textContent = message;
|
||||||
|
errorMessage.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
errorMessage.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-focus on username field
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.getElementById('username').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Enter key in password field
|
||||||
|
document.getElementById('password').addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
document.getElementById('loginForm').dispatchEvent(new Event('submit'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
199
test_auth.py
Normal file
199
test_auth.py
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
#!/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()
|
Loading…
x
Reference in New Issue
Block a user