import asyncio import logging import os from datetime import datetime, timedelta import psutil from connections.keycloak import get_valid_access_token, keycloak_openid from connections.local_files import ( delete_client_info, delete_token, delete_verifier, load_client_info, load_token, load_verifier, save_client_info, save_token, save_verifier, ) from connections.quantum_backend import get_device_by_id, get_or_create_device_by_name from connections.rabbitmq import ( rabbitmq_manager, ) from fasthtml.common import ( H1, H2, H3, A, Button, Div, FastHTML, Form, Input, Meta, P, RedirectResponse, Script, Span, Strong, Titled, ) from fasthtml.pico import Card, Container, picolink from fasthtml.xtend import Style from keycloak.pkce_utils import generate_code_challenge, generate_code_verifier from modules.rabbitmq import consume_messages_topic logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) REDIRECT_URI = f"http://localhost:{os.environ['PORT']}/callback" css = Style(":root {--pico-font-size:90%,--pico-font-family: Pacifico, cursive;}") app = FastHTML(hdrs=(picolink, css)) @app.route("/", methods="get") async def main_page(): # Проверяем наличие валидного токена token_data = load_token() client_info = load_client_info() if token_data: expires_at = datetime.fromisoformat(token_data.get("expires_at", "2000-01-01")) if expires_at > datetime.now(): # Если есть токен, но нет информации о клиенте, перенаправляем на регистрацию if not client_info: return RedirectResponse("/register-client", status_code=303) return RedirectResponse("/dashboard", status_code=303) else: delete_token() delete_client_info() return Titled( "Главная", H1("Добро пожаловать в Квантовый Симулятор"), P("Пожалуйста, войдите для продолжения"), A("Войти через Keycloak", href="/login", cls="button"), ) @app.route("/login", methods="get") async def login(): # Генерация PKCE кода верификатора и запроса code_verifier = generate_code_verifier() code_challenge, code_challenge_method = generate_code_challenge( code_verifier, method="S256" ) # Сохраняем верификатор локально save_verifier(code_verifier) # Формирование URL авторизации auth_url = keycloak_openid.auth_url( redirect_uri=REDIRECT_URI, scope="openid profile email", code_challenge=code_challenge, code_challenge_method=code_challenge_method, ) return RedirectResponse(auth_url, status_code=303) @app.route("/callback", methods="get") async def callback(request): code = request.query_params.get("code") if not code: return Titled( "Ошибка", P("Код не найден"), A("Попробовать снова", href="/login") ) # Загружаем верификатор code_verifier = load_verifier() if not code_verifier: return Titled( "Ошибка", P("PKCE верификатор не найден"), A("Попробовать снова", href="/login"), ) # Очищаем верификатор сразу после использования delete_verifier() try: # Обмен кода на токен token = keycloak_openid.token( grant_type="authorization_code", code=code, redirect_uri=REDIRECT_URI, code_verifier=code_verifier, ) # Расчет времени истечения expires_in = token.get("expires_in", 3600) expires_at = datetime.now() + timedelta(seconds=expires_in) # Получение информации о пользователе access_token = token.get("access_token") if not access_token: raise Exception user_info = keycloak_openid.userinfo(access_token) # Сохраняем токен token_data_to_save = { "access_token": access_token, "refresh_token": token.get("refresh_token"), "expires_at": expires_at.isoformat(), "id_token": token.get("id_token"), "user_info": user_info, } save_token(token_data_to_save) # Перенаправление на регистрацию клиента (проверит, зарегистрирован ли уже) return RedirectResponse("/register-client", status_code=303) except Exception as e: logger.error(f"Ошибка при обмене токена: {e}") return Titled("Ошибка", P(str(e)), A("Попробовать снова", href="/login")) @app.route("/register-client", methods="get") async def register_client(request): """Страница регистрации клиента в центральном микросервисе""" token_data = load_token() if not token_data: return RedirectResponse("/login", status_code=303) # Проверяем, не зарегистрирован ли уже client_info = load_client_info() if client_info: return RedirectResponse("/dashboard", status_code=303) # Проверяем истечение токена expires_at = datetime.fromisoformat(str(token_data.get("expires_at"))) if expires_at <= datetime.now(): delete_token() return RedirectResponse("/login", status_code=303) user_info = token_data.get("user_info", {}) username = user_info.get( "preferred_username", user_info.get("name", "Пользователь") ) return Titled( "Регистрация клиента", Container( Card( H2("Добро пожаловать в Квантовый Симулятор!"), P(f"Здравствуйте, {username}!"), P("Это устройство должно быть зарегистрировано в центральном сервисе."), P( "Пожалуйста, укажите уникальное имя и количество кубит для этого клиента:" ), Form( Input( type="text", name="client_name", id="client_name", placeholder="например: рабочий-ноутбук, домашний-пк, raspberry-pi-01", required=True, style="width: 100%; padding: 10px; margin: 10px 0;", ), Input( type="number", name="number_qubits", id="number_qubits", placeholder=0, required=True, style="width: 100%; padding: 10px; margin: 10px 0;", ), Div( Button( "Зарегистрировать клиент", type="submit", cls="button", style="background-color: #4CAF50;", ), style="margin-top: 10px;", ), hx_post="/register-client", hx_target="#registration-result", hx_swap="innerHTML", style="margin-top: 20px;", ), Div(id="registration-result"), ), Div( Button( "Выйти", onclick="window.location.href='/logout'", cls="button", style="background-color: #f44336; margin-top: 20px;", ), style="text-align: center;", ), ), ) @app.route("/register-client", methods="post") async def register_client_post(request): """Обработка POST запроса регистрации клиента""" form_data = await request.form() client_name = form_data.get("client_name") number_qubits = form_data.get("number_qubits") if not number_qubits: return Div( P("❌ Обязательно необходимо указать колчество кубит", style="color: red;"), ) if not client_name: return Div( P("❌ Имя клиента обязательно", style="color: red;"), ) # Проверка формата имени клиента import re if not re.match(r"^[a-zA-Z0-9_-]+$", client_name): return Div( P( "❌ Имя клиента может содержать только буквы, цифры, дефисы и подчеркивания", style="color: red;", ), Button("Попробовать снова", onclick="location.reload()", cls="button"), ) token_data = load_token() if not token_data: return Div( P("❌ Сессия истекла. Пожалуйста, войдите снова.", style="color: red;"), A("Войти", href="/login", cls="button"), ) access_token = token_data.get("access_token") # Регистрация в центральном микросервисе try: response = get_or_create_device_by_name( client_name, number_qubits, access_token ) save_client_info( { "system_id": response["system_id"], } ) return Div( P( "✅ Клиент успешно зарегистрирован!", style="color: green; font-weight: bold;", ), P(f"Имя клиента: {client_name}"), P("Перенаправление на панель управления..."), Meta(http_equiv="refresh", content="2;url=/dashboard"), Script("setTimeout(() => { window.location.href = '/dashboard'; }, 2000);"), ) except Exception: return Div( P("❌ Ошибка регистрации", style="color: red;"), Button("Попробовать снова", onclick="location.reload()", cls="button"), P( "Если проблема повторяется, обратитесь к администратору.", style="font-size: 12px; margin-top: 10px;", ), ) @app.route("/dashboard", methods="get") async def dashboard(): access_token = get_valid_access_token() if rabbitmq_manager._connection and access_token: await rabbitmq_manager._connection.update_secret( access_token, reason="Token expired" ) if not access_token: # Token refresh failed, redirect to login return RedirectResponse("/", status_code=303) token_data = load_token() client_info = load_client_info() if not token_data or not client_info: return RedirectResponse("/", status_code=303) user_info = token_data.get("user_info", {}) username = user_info.get( "preferred_username", user_info.get("name", "Пользователь") ) system_id = client_info.get("system_id", None) if not token_data: return Div( P("❌ Сессия истекла. Пожалуйста, войдите снова.", style="color: red;"), A("Войти", href="/login", cls="button"), ) try: if system_id: device_data = get_device_by_id(system_id, access_token) device_name = device_data["system"]["system_name"] max_qubits = device_data["system"]["max_qubits"] created_at = device_data["system"]["created_at"] else: device_name = "error" max_qubits = "error" created_at = "error" except Exception as a: device_name = "error" max_qubits = "error" created_at = "error" return Titled( "Панель управления", Container( Card( P(Strong("Имя пользователя: "), username), P(Strong("Email: "), user_info.get("email", "Не указан")), P(Strong("Устройство зарегестрировано: "), created_at), Div(style="border: 1px solid black"), P(Strong("Имя вычислительной системы: "), device_name), P(Strong("Макс. количество кубит: "), max_qubits), Div(Button("Изменить", style="background-color: blue;")), Div( id="client-status", hx_get="/client-status", hx_trigger="load, every 15s", ), ), Div(id="memory-stats", hx_get="/memory-stats", hx_trigger="load, every 5s"), A( Button( "Выйти", style="background-color: #f44336;", ), href="/logout", style="margin: 0px;color: inherit;text-decoration: inherit;", ), ), ) @app.route("/client-status", methods="get") async def status(): """Получить статус клиента из центрального микросервиса""" access_token = get_valid_access_token() client_info = load_client_info() if not access_token or not client_info: return P("Клиент не зарегистрирован", style="color: orange;") if rabbitmq_manager._connection: # TODO: FIX status = ( rabbitmq_manager._connection.connected ) # get_client_status(client_name, access_token) if status: await consume_messages_topic() return Div( P( Strong("Статус клиента: "), Span("✅ Активен", style="color: green;"), ), style="padding: 10px; border-radius: 5px; margin: 10px 0;", ) else: return Div( P( Strong("Статус клиента: "), Span("Не подключен", style="color: orange;"), ), P( "Не удалось связаться с центральным сервисом или данному клиенту отказано в подключении" ), style="padding: 10px; border-radius: 5px; margin: 10px 0;", ) else: return Div( P( Strong("Статус клиента: "), Span("Не подключен", style="color: orange;"), ), P( "Не удалось связаться с центральным сервисом или данному клиенту отказано в подключении" ), style="padding: 10px; border-radius: 5px; margin: 10px 0;", ) @app.route("/memory-stats", methods="get") async def memory_stats(): """Endpoint, возвращающий карточку использования памяти (для обновления через HTMX)""" process = psutil.Process(os.getpid()) memory_info = process.memory_info() current_time = datetime.now() timestamp = current_time.strftime("%Y-%m-%d %H:%M:%S") def format_bytes(bytes): for unit in ["Б", "КБ", "МБ", "ГБ"]: if bytes < 1024.0: return f"{bytes:.1f} {unit}" bytes /= 1024.0 return f"{bytes:.1f} ГБ" mem = 0 if os.path.isfile("/sys/fs/cgroup/memory.max"): with open("/sys/fs/cgroup/memory.max") as limit: try: mem = int(limit.read()) except Exception: mem = 0 # resource.setrlimit(resource.RLIMIT_AS, (mem, mem)) rss_memory = format_bytes(memory_info.rss) if mem > 0: memory_percent = (memory_info.rss / mem) * 100 else: memory_percent = process.memory_percent() bar_color = ( "#4CAF50" if memory_percent < 1 else "#FFC107" if memory_percent < 5 else "#F44336" ) return Div( H2("Использование памяти"), Card( Div( H3(rss_memory), style="width: 100%;display:flex;flex-direction:column;align-items:center", ), Div( Div( style=f"width: {min(memory_percent, 100)}%; background-color: {bar_color}; height: 20px; border-radius: 10px;" ), style="width: 100%; background-color: #e0e0e0; border-radius: 10px; overflow: hidden; margin: 10px 0;", ), P( f"{memory_percent:.2f}% от системной памяти", style="text-align: center; font-size: 12px;", ), P( Strong("Обновлено: "), timestamp, style="text-align: center; font-size: 11px; color: #666;", ), ), ) @app.route("/logout", methods="get") async def logout(): token_data = load_token() if token_data and token_data.get("refresh_token"): keycloak_openid.logout(token_data["refresh_token"]) # Очищаем локальные файлы delete_token() delete_client_info() delete_verifier() return Titled( "Выход выполнен", H2("До свидания!"), P("Вы успешно вышли из системы."), P("Ваш клиент был отменен в центральном сервисе."), A("Войти снова", href="/", cls="button"), ) # Опционально: endpoint для проверки работоспособности для центрального сервиса @app.route("/health", methods="get") async def health(): return {"status": "healthy", "timestamp": datetime.now().isoformat()}