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:
parent
a979dd2838
commit
d0a4b57233
@ -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
213
app.py
@ -18,6 +18,8 @@ from pathlib import Path
|
||||
|
||||
import docker
|
||||
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.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@ -52,6 +54,182 @@ app = FastAPI(
|
||||
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")
|
||||
|
||||
@ -119,7 +297,7 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Недействительный токен",
|
||||
detail="Недействительный токен. Требуется авторизация.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return username
|
||||
@ -410,7 +588,7 @@ async def login(user_data: UserLogin, response: Response):
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверное имя пользователя или пароль",
|
||||
detail="Неверное имя пользователя или пароль. Проверьте учетные данные и попробуйте снова.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@ -430,6 +608,32 @@ def healthz():
|
||||
"""Health check endpoint"""
|
||||
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}")
|
||||
def api_logs_stats(container_id: str, current_user: str = Depends(get_current_user)):
|
||||
"""Получить статистику логов контейнера"""
|
||||
@ -506,7 +710,10 @@ def api_update_excluded_containers(
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Ошибка сохранения списка")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Ошибка сохранения списка исключенных контейнеров. Попробуйте еще раз или обратитесь к администратору."
|
||||
)
|
||||
|
||||
@app.get("/api/projects")
|
||||
def api_projects(current_user: str = Depends(get_current_user)):
|
||||
|
259
templates/error.html
Normal file
259
templates/error.html
Normal 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>
|
@ -562,6 +562,15 @@ a{color:var(--link)}
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.other-btn {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.other-btn:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
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-value cerr">0</span>
|
||||
</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="Обновить логи и счетчики">
|
||||
<i class="fas fa-sync-alt"></i> Refresh
|
||||
</button>
|
||||
@ -1341,7 +1354,7 @@ const state = {
|
||||
open: {}, // id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName}
|
||||
layout: 'tabs', // 'tabs' | 'grid2' | 'grid3' | 'grid4'
|
||||
filter: null,
|
||||
levels: {debug:true, info:true, warn:true, err:true},
|
||||
levels: {debug:true, info:true, warn:true, err:true, other:true},
|
||||
selectedContainers: [], // Массив ID выбранных контейнеров для мультипросмотра
|
||||
multiViewMode: false, // Режим мультипросмотра
|
||||
};
|
||||
@ -1365,6 +1378,7 @@ const els = {
|
||||
lvlInfo: document.getElementById('lvlInfo'),
|
||||
lvlWarn: document.getElementById('lvlWarn'),
|
||||
lvlErr: document.getElementById('lvlErr'),
|
||||
lvlOther: document.getElementById('lvlOther'),
|
||||
layoutBadge: document.getElementById('layoutBadge') || { textContent: '' },
|
||||
aggregate: document.getElementById('aggregate') || { checked: false },
|
||||
themeSwitch: document.getElementById('themeSwitch'),
|
||||
@ -1528,7 +1542,7 @@ function allowedByLevel(cls){
|
||||
if (cls==='err') return state.levels.err;
|
||||
if (cls==='warn') return state.levels.warn;
|
||||
if (cls==='ok') return state.levels.info;
|
||||
if (cls==='other') return true; // Всегда показываем неклассифицированные строки
|
||||
if (cls==='other') return state.levels.other; // Показываем неклассифицированные строки в зависимости от настройки
|
||||
return true;
|
||||
}
|
||||
function applyFilter(line){
|
||||
@ -2345,7 +2359,7 @@ function openMultiViewWs(service) {
|
||||
serviceName: service.service,
|
||||
logEl: logEl,
|
||||
wrapEl: logEl,
|
||||
counters: {dbg:0, info:0, warn:0, err:0},
|
||||
counters: {dbg:0, info:0, warn:0, err:0, other:0},
|
||||
pausedBuffer: [],
|
||||
allLogs: [] // Добавляем буфер для логов
|
||||
};
|
||||
@ -2543,7 +2557,8 @@ function openWs(svc, panel){
|
||||
const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo');
|
||||
const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn');
|
||||
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||''));
|
||||
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;
|
||||
cwarn.textContent = counters.warn;
|
||||
cerr.textContent = counters.err;
|
||||
if (cother) cother.textContent = counters.other;
|
||||
};
|
||||
|
||||
// Убираем автоматический refresh - теперь только по кнопке
|
||||
@ -3253,7 +3269,7 @@ function handleLine(id, line){
|
||||
// Отладочная информация для первых нескольких строк
|
||||
if (!obj.counters) {
|
||||
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" для всех режимов
|
||||
@ -3278,6 +3294,7 @@ function handleLine(id, line){
|
||||
if (cls==='ok') obj.counters.info++;
|
||||
if (cls==='warn') obj.counters.warn++;
|
||||
if (cls==='err') obj.counters.err++;
|
||||
if (cls==='other') obj.counters.other++;
|
||||
}
|
||||
|
||||
// Для Single View НЕ добавляем перенос строки после каждой строки лога
|
||||
@ -3643,7 +3660,8 @@ function openFanGroup(services){
|
||||
const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo');
|
||||
const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn');
|
||||
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||''));
|
||||
state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: ('group:'+services.join(','))};
|
||||
@ -3663,6 +3681,7 @@ function openFanGroup(services){
|
||||
cinfo.textContent = counters.info;
|
||||
cwarn.textContent = counters.warn;
|
||||
cerr.textContent = counters.err;
|
||||
if (cother) cother.textContent = counters.other;
|
||||
|
||||
// Обновляем видимость счетчиков
|
||||
updateCounterVisibility();
|
||||
@ -3707,11 +3726,13 @@ async function updateCounters(containerId) {
|
||||
const cinfo = document.querySelector('.cinfo');
|
||||
const cwarn = document.querySelector('.cwarn');
|
||||
const cerr = document.querySelector('.cerr');
|
||||
const cother = document.querySelector('.cother');
|
||||
|
||||
if (cdbg) cdbg.textContent = stats.debug || 0;
|
||||
if (cinfo) cinfo.textContent = stats.info || 0;
|
||||
if (cwarn) cwarn.textContent = stats.warn || 0;
|
||||
if (cerr) cerr.textContent = stats.error || 0;
|
||||
if (cother) cother.textContent = stats.other || 0;
|
||||
|
||||
// Обновляем видимость счетчиков
|
||||
updateCounterVisibility();
|
||||
@ -3779,7 +3800,7 @@ function recalculateCounters() {
|
||||
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 => {
|
||||
@ -3789,6 +3810,7 @@ function recalculateCounters() {
|
||||
if (logEntry.cls === 'ok') obj.counters.info++;
|
||||
if (logEntry.cls === 'warn') obj.counters.warn++;
|
||||
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 cwarn = document.querySelector('.cwarn');
|
||||
const cerr = document.querySelector('.cerr');
|
||||
const cother = document.querySelector('.cother');
|
||||
|
||||
if (cdbg) cdbg.textContent = obj.counters.dbg;
|
||||
if (cinfo) cinfo.textContent = obj.counters.info;
|
||||
if (cwarn) cwarn.textContent = obj.counters.warn;
|
||||
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);
|
||||
}
|
||||
@ -3822,6 +3846,7 @@ function recalculateMultiViewCounters() {
|
||||
let totalInfo = 0;
|
||||
let totalWarn = 0;
|
||||
let totalError = 0;
|
||||
let totalOther = 0;
|
||||
|
||||
// Пересчитываем счетчики для каждого контейнера
|
||||
for (const containerId of state.selectedContainers) {
|
||||
@ -3832,7 +3857,7 @@ function recalculateMultiViewCounters() {
|
||||
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 => {
|
||||
@ -3842,6 +3867,7 @@ function recalculateMultiViewCounters() {
|
||||
if (logEntry.cls === 'ok') obj.counters.info++;
|
||||
if (logEntry.cls === 'warn') obj.counters.warn++;
|
||||
if (logEntry.cls === 'err') obj.counters.err++;
|
||||
if (logEntry.cls === 'other') obj.counters.other++;
|
||||
}
|
||||
});
|
||||
|
||||
@ -3850,6 +3876,7 @@ function recalculateMultiViewCounters() {
|
||||
totalInfo += obj.counters.info;
|
||||
totalWarn += obj.counters.warn;
|
||||
totalError += obj.counters.err;
|
||||
totalOther += obj.counters.other;
|
||||
}
|
||||
|
||||
// Обновляем отображение счетчиков
|
||||
@ -3857,13 +3884,15 @@ function recalculateMultiViewCounters() {
|
||||
const cinfo = document.querySelector('.cinfo');
|
||||
const cwarn = document.querySelector('.cwarn');
|
||||
const cerr = document.querySelector('.cerr');
|
||||
const cother = document.querySelector('.cother');
|
||||
|
||||
if (cdbg) cdbg.textContent = totalDebug;
|
||||
if (cinfo) cinfo.textContent = totalInfo;
|
||||
if (cwarn) cwarn.textContent = totalWarn;
|
||||
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 warnBtn = document.querySelector('.warn-btn');
|
||||
const errorBtn = document.querySelector('.error-btn');
|
||||
const otherBtn = document.querySelector('.other-btn');
|
||||
|
||||
if (debugBtn) {
|
||||
debugBtn.classList.toggle('disabled', !state.levels.debug);
|
||||
@ -3885,6 +3915,9 @@ function updateCounterVisibility() {
|
||||
if (errorBtn) {
|
||||
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 warnBtn = document.querySelector('.warn-btn');
|
||||
const errorBtn = document.querySelector('.error-btn');
|
||||
const otherBtn = document.querySelector('.other-btn');
|
||||
|
||||
if (debugBtn) {
|
||||
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
|
||||
window.addEventListener('keydown', async (e)=>{
|
||||
|
199
test_auth.py
199
test_auth.py
@ -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()
|
Loading…
x
Reference in New Issue
Block a user