From fcfc5d1473717ed2d23bda8e942cd27bea90d4a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Dec 2025 10:31:00 +0000 Subject: [PATCH] Refactor app initialization and UI routing Move lifespan and UI setup to separate modules to improve organization and maintainability. Co-authored-by: db2002dominic --- routstr/core/main.py | 283 ++---------------------------------------- routstr/core/tasks.py | 127 +++++++++++++++++++ routstr/core/ui.py | 150 ++++++++++++++++++++++ 3 files changed, 284 insertions(+), 276 deletions(-) create mode 100644 routstr/core/tasks.py create mode 100644 routstr/core/ui.py diff --git a/routstr/core/main.py b/routstr/core/main.py index 06b86d65..3dbc769d 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -1,33 +1,21 @@ -import asyncio import os -from contextlib import asynccontextmanager -from pathlib import Path -from typing import AsyncGenerator from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, RedirectResponse -from fastapi.staticfiles import StaticFiles +from fastapi.responses import RedirectResponse from starlette.exceptions import HTTPException from ..balance import balance_router, deprecated_wallet_router -from ..discovery import providers_cache_refresher, providers_router -from ..nip91 import announce_provider -from ..payment.models import ( - cleanup_enabled_models_periodically, - models_router, - update_sats_pricing, -) -from ..payment.price import update_prices_periodically -from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically -from ..wallet import periodic_payout +from ..discovery import providers_router +from ..payment.models import models_router +from ..proxy import proxy_router from .admin import admin_router -from .db import create_session, init_db, run_migrations from .exceptions import general_exception_handler, http_exception_handler from .logging import get_logger, setup_logging from .middleware import LoggingMiddleware -from .settings import SettingsService from .settings import settings as global_settings +from .tasks import lifespan +from .ui import setup_ui_routes # Initialize logging first setup_logging() @@ -39,126 +27,6 @@ else: __version__ = "0.2.1" -@asynccontextmanager -async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: - logger.info("Application startup initiated", extra={"version": __version__}) - - btc_price_task = None - pricing_task = None - payout_task = None - nip91_task = None - providers_task = None - models_refresh_task = None - models_cleanup_task = None - model_maps_refresh_task = None - - try: - # Run database migrations on startup - # This ensures the database schema is always up-to-date in production - # Migrations are idempotent - running them multiple times is safe - logger.info("Running database migrations") - run_migrations() - - # Initialize database connection pools - # This creates any tables that might not be tracked by migrations yet - await init_db() - - # Initialize application settings (env -> computed -> DB precedence) - async with create_session() as session: - s = await SettingsService.initialize(session) - - # Apply app metadata from settings - try: - app.title = s.name - app.description = s.description - except Exception: - pass - - # await ensure_models_bootstrapped() - - from ..payment.price import _update_prices - from ..proxy import get_upstreams - from ..upstream.helpers import refresh_upstreams_models_periodically - - _update_prices_task = asyncio.create_task(_update_prices()) - _initialize_upstreams_task = asyncio.create_task(initialize_upstreams()) - - btc_price_task = asyncio.create_task(update_prices_periodically()) - pricing_task = asyncio.create_task(update_sats_pricing()) - if global_settings.models_refresh_interval_seconds > 0: - models_refresh_task = asyncio.create_task( - refresh_upstreams_models_periodically(get_upstreams()) - ) - models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically()) - model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically()) - payout_task = asyncio.create_task(periodic_payout()) - if global_settings.nsec: - nip91_task = asyncio.create_task(announce_provider()) - if global_settings.providers_refresh_interval_seconds > 0: - providers_task = asyncio.create_task(providers_cache_refresher()) - - # ensure both setup tasks complete - await asyncio.gather( - _update_prices_task, _initialize_upstreams_task, return_exceptions=True - ) - - yield - - except Exception as e: - logger.error( - "Application startup failed", - extra={"error": str(e), "error_type": type(e).__name__}, - ) - raise - finally: - logger.info("Application shutdown initiated") - - if btc_price_task is not None: - btc_price_task.cancel() - if pricing_task is not None: - pricing_task.cancel() - if payout_task is not None: - payout_task.cancel() - if nip91_task is not None: - nip91_task.cancel() - if providers_task is not None: - providers_task.cancel() - if models_refresh_task is not None: - models_refresh_task.cancel() - if models_cleanup_task is not None: - models_cleanup_task.cancel() - if model_maps_refresh_task is not None: - model_maps_refresh_task.cancel() - - try: - tasks_to_wait = [] - if btc_price_task is not None: - tasks_to_wait.append(btc_price_task) - if pricing_task is not None: - tasks_to_wait.append(pricing_task) - if payout_task is not None: - tasks_to_wait.append(payout_task) - if nip91_task is not None: - tasks_to_wait.append(nip91_task) - if providers_task is not None: - tasks_to_wait.append(providers_task) - if models_refresh_task is not None: - tasks_to_wait.append(models_refresh_task) - if models_cleanup_task is not None: - tasks_to_wait.append(models_cleanup_task) - if model_maps_refresh_task is not None: - tasks_to_wait.append(model_maps_refresh_task) - - if tasks_to_wait: - await asyncio.gather(*tasks_to_wait, return_exceptions=True) - logger.info("Background tasks stopped successfully") - except Exception as e: - logger.error( - "Error stopping background tasks", - extra={"error": str(e), "error_type": type(e).__name__}, - ) - - app = FastAPI(version=__version__, lifespan=lifespan) @@ -197,144 +65,7 @@ async def providers() -> RedirectResponse: return RedirectResponse("/v1/providers/") -UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out" - -if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir(): - logger.info(f"Serving static UI from {UI_DIST_PATH}") - - app.mount( - "/_next", - StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True), - name="next-static", - ) - - @app.get("/", include_in_schema=False) - async def serve_root_ui() -> FileResponse: - return FileResponse(UI_DIST_PATH / "index.html") - - # Add explicit route for /index.txt to redirect to / - @app.get("/index.txt", include_in_schema=False) - async def redirect_index_txt() -> RedirectResponse: - return RedirectResponse("/") - - @app.get("/admin") - async def admin_redirect() -> FileResponse: - return FileResponse(UI_DIST_PATH / "index.html") - - @app.get("/dashboard", include_in_schema=False) - async def serve_dashboard_ui() -> FileResponse: - return FileResponse(UI_DIST_PATH / "index.html") - - @app.get("/login", include_in_schema=False) - async def serve_login_ui() -> FileResponse: - return FileResponse(UI_DIST_PATH / "login" / "index.html") - - # Add explicit route for /login/index.txt to redirect to /login - @app.get("/login/index.txt", include_in_schema=False) - async def redirect_login_index_txt() -> RedirectResponse: - return RedirectResponse("/login") - - @app.get("/model", include_in_schema=False) - async def serve_models_ui() -> FileResponse: - return FileResponse(UI_DIST_PATH / "model" / "index.html") - - # Add explicit route for /model/index.txt to redirect to /model - @app.get("/model/index.txt", include_in_schema=False) - async def redirect_model_index_txt() -> RedirectResponse: - return RedirectResponse("/model") - - @app.get("/providers", include_in_schema=False) - async def serve_providers_ui() -> FileResponse: - return FileResponse(UI_DIST_PATH / "providers" / "index.html") - - # Add explicit route for /providers/index.txt to redirect to /providers - @app.get("/providers/index.txt", include_in_schema=False) - async def redirect_providers_index_txt() -> RedirectResponse: - return RedirectResponse("/providers") - - @app.get("/settings", include_in_schema=False) - async def serve_settings_ui() -> FileResponse: - return FileResponse(UI_DIST_PATH / "settings" / "index.html") - - # Add explicit route for /settings/index.txt to redirect to /settings - @app.get("/settings/index.txt", include_in_schema=False) - async def redirect_settings_index_txt() -> RedirectResponse: - return RedirectResponse("/settings") - - @app.get("/transactions", include_in_schema=False) - async def serve_transactions_ui() -> FileResponse: - return FileResponse(UI_DIST_PATH / "transactions" / "index.html") - - # Add explicit route for /transactions/index.txt to redirect to /transactions - @app.get("/transactions/index.txt", include_in_schema=False) - async def redirect_transactions_index_txt() -> RedirectResponse: - return RedirectResponse("/transactions") - - @app.get("/balances", include_in_schema=False) - async def serve_balances_ui() -> FileResponse: - return FileResponse(UI_DIST_PATH / "balances" / "index.html") - - # Add explicit route for /balances/index.txt to redirect to /balances - @app.get("/balances/index.txt", include_in_schema=False) - async def redirect_balances_index_txt() -> RedirectResponse: - return RedirectResponse("/balances") - - @app.get("/logs", include_in_schema=False) - async def serve_logs_ui() -> FileResponse: - return FileResponse(UI_DIST_PATH / "logs" / "index.html") - - # Add explicit route for /logs/index.txt to redirect to /logs - @app.get("/logs/index.txt", include_in_schema=False) - async def redirect_logs_index_txt() -> RedirectResponse: - return RedirectResponse("/logs") - - @app.get("/usage", include_in_schema=False) - async def serve_usage_ui() -> FileResponse: - return FileResponse(UI_DIST_PATH / "usage" / "index.html") - - # Add explicit route for /usage/index.txt to redirect to /usage - @app.get("/usage/index.txt", include_in_schema=False) - async def redirect_usage_index_txt() -> RedirectResponse: - return RedirectResponse("/usage") - - @app.get("/unauthorized", include_in_schema=False) - async def serve_unauthorized_ui() -> FileResponse: - return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html") - - # Add explicit route for /unauthorized/index.txt to redirect to /unauthorized - @app.get("/unauthorized/index.txt", include_in_schema=False) - async def redirect_unauthorized_index_txt() -> RedirectResponse: - return RedirectResponse("/unauthorized") - - @app.get("/favicon.ico", include_in_schema=False) - async def serve_favicon() -> FileResponse: - icon_path = UI_DIST_PATH / "icon.ico" - if icon_path.exists(): - return FileResponse(icon_path) - return FileResponse(UI_DIST_PATH / "favicon.ico") - - @app.get("/icon.ico", include_in_schema=False) - async def serve_icon() -> FileResponse: - return FileResponse(UI_DIST_PATH / "icon.ico") - - app.mount( - "/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static" - ) -else: - logger.warning( - f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving" - ) - - @app.get("/", include_in_schema=False) - async def root_fallback() -> dict: - return { - "name": global_settings.name, - "description": global_settings.description, - "version": __version__, - "status": "running", - "ui": "not available", - } - +setup_ui_routes(app) app.include_router(models_router) app.include_router(admin_router) diff --git a/routstr/core/tasks.py b/routstr/core/tasks.py new file mode 100644 index 00000000..f38bb376 --- /dev/null +++ b/routstr/core/tasks.py @@ -0,0 +1,127 @@ +import asyncio +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from fastapi import FastAPI + +from ..balance import balance_router, deprecated_wallet_router +from ..discovery import providers_cache_refresher +from ..nip91 import announce_provider +from ..payment.models import ( + cleanup_enabled_models_periodically, + update_sats_pricing, +) +from ..payment.price import _update_prices, update_prices_periodically +from ..proxy import get_upstreams, initialize_upstreams, refresh_model_maps_periodically +from ..upstream.helpers import refresh_upstreams_models_periodically +from ..wallet import periodic_payout +from .db import create_session, init_db, run_migrations +from .logging import get_logger +from .settings import SettingsService +from .settings import settings as global_settings + +logger = get_logger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + version = getattr(app, "version", "unknown") + + logger.info("Application startup initiated", extra={"version": version}) + + btc_price_task: asyncio.Task | None = None + pricing_task: asyncio.Task | None = None + payout_task: asyncio.Task | None = None + nip91_task: asyncio.Task | None = None + providers_task: asyncio.Task | None = None + models_refresh_task: asyncio.Task | None = None + models_cleanup_task: asyncio.Task | None = None + model_maps_refresh_task: asyncio.Task | None = None + + try: + logger.info("Running database migrations") + run_migrations() + + await init_db() + + async with create_session() as session: + s = await SettingsService.initialize(session) + + try: + app.title = s.name + app.description = s.description + except Exception: # pragma: no cover - defensive + pass + + await _update_prices() + await initialize_upstreams() + + btc_price_task = asyncio.create_task(update_prices_periodically()) + pricing_task = asyncio.create_task(update_sats_pricing()) + if global_settings.models_refresh_interval_seconds > 0: + models_refresh_task = asyncio.create_task( + refresh_upstreams_models_periodically(get_upstreams()) + ) + models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically()) + model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically()) + payout_task = asyncio.create_task(periodic_payout()) + if global_settings.nsec: + nip91_task = asyncio.create_task(announce_provider()) + if global_settings.providers_refresh_interval_seconds > 0: + providers_task = asyncio.create_task(providers_cache_refresher()) + + yield + + except Exception as e: # pragma: no cover - logged and re-raised + logger.error( + "Application startup failed", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + raise + finally: + logger.info("Application shutdown initiated") + + if btc_price_task is not None: + btc_price_task.cancel() + if pricing_task is not None: + pricing_task.cancel() + if payout_task is not None: + payout_task.cancel() + if nip91_task is not None: + nip91_task.cancel() + if providers_task is not None: + providers_task.cancel() + if models_refresh_task is not None: + models_refresh_task.cancel() + if models_cleanup_task is not None: + models_cleanup_task.cancel() + if model_maps_refresh_task is not None: + model_maps_refresh_task.cancel() + + try: + tasks_to_wait: list[asyncio.Task] = [] + if btc_price_task is not None: + tasks_to_wait.append(btc_price_task) + if pricing_task is not None: + tasks_to_wait.append(pricing_task) + if payout_task is not None: + tasks_to_wait.append(payout_task) + if nip91_task is not None: + tasks_to_wait.append(nip91_task) + if providers_task is not None: + tasks_to_wait.append(providers_task) + if models_refresh_task is not None: + tasks_to_wait.append(models_refresh_task) + if models_cleanup_task is not None: + tasks_to_wait.append(models_cleanup_task) + if model_maps_refresh_task is not None: + tasks_to_wait.append(model_maps_refresh_task) + + if tasks_to_wait: + await asyncio.gather(*tasks_to_wait, return_exceptions=True) + logger.info("Background tasks stopped successfully") + except Exception as e: # pragma: no cover - defensive + logger.error( + "Error stopping background tasks", + extra={"error": str(e), "error_type": type(e).__name__}, + ) diff --git a/routstr/core/ui.py b/routstr/core/ui.py new file mode 100644 index 00000000..8b33b0aa --- /dev/null +++ b/routstr/core/ui.py @@ -0,0 +1,150 @@ +from pathlib import Path + +from fastapi import APIRouter, FastAPI +from fastapi.responses import FileResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles + +from .logging import get_logger +from .settings import settings as global_settings + +logger = get_logger(__name__) + + +def setup_ui_routes(app: FastAPI) -> None: + ui_dist_path = Path(__file__).parent.parent.parent / "ui_out" + + if ui_dist_path.exists() and ui_dist_path.is_dir(): + logger.info(f"Serving static UI from {ui_dist_path}") + + app.mount( + "/_next", + StaticFiles(directory=ui_dist_path / "_next", check_dir=True), + name="next-static", + ) + + router = APIRouter() + + @router.get("/", include_in_schema=False) + async def serve_root_ui() -> FileResponse: + return FileResponse(ui_dist_path / "index.html") + + @router.get("/index.txt", include_in_schema=False) + async def redirect_index_txt() -> RedirectResponse: + return RedirectResponse("/") + + @router.get("/admin") + async def admin_redirect() -> FileResponse: + return FileResponse(ui_dist_path / "index.html") + + @router.get("/dashboard", include_in_schema=False) + async def serve_dashboard_ui() -> FileResponse: + return FileResponse(ui_dist_path / "index.html") + + @router.get("/login", include_in_schema=False) + async def serve_login_ui() -> FileResponse: + return FileResponse(ui_dist_path / "login" / "index.html") + + @router.get("/login/index.txt", include_in_schema=False) + async def redirect_login_index_txt() -> RedirectResponse: + return RedirectResponse("/login") + + @router.get("/model", include_in_schema=False) + async def serve_models_ui() -> FileResponse: + return FileResponse(ui_dist_path / "model" / "index.html") + + @router.get("/model/index.txt", include_in_schema=False) + async def redirect_model_index_txt() -> RedirectResponse: + return RedirectResponse("/model") + + @router.get("/providers", include_in_schema=False) + async def serve_providers_ui() -> FileResponse: + return FileResponse(ui_dist_path / "providers" / "index.html") + + @router.get("/providers/index.txt", include_in_schema=False) + async def redirect_providers_index_txt() -> RedirectResponse: + return RedirectResponse("/providers") + + @router.get("/settings", include_in_schema=False) + async def serve_settings_ui() -> FileResponse: + return FileResponse(ui_dist_path / "settings" / "index.html") + + @router.get("/settings/index.txt", include_in_schema=False) + async def redirect_settings_index_txt() -> RedirectResponse: + return RedirectResponse("/settings") + + @router.get("/transactions", include_in_schema=False) + async def serve_transactions_ui() -> FileResponse: + return FileResponse(ui_dist_path / "transactions" / "index.html") + + @router.get("/transactions/index.txt", include_in_schema=False) + async def redirect_transactions_index_txt() -> RedirectResponse: + return RedirectResponse("/transactions") + + @router.get("/balances", include_in_schema=False) + async def serve_balances_ui() -> FileResponse: + return FileResponse(ui_dist_path / "balances" / "index.html") + + @router.get("/balances/index.txt", include_in_schema=False) + async def redirect_balances_index_txt() -> RedirectResponse: + return RedirectResponse("/balances") + + @router.get("/logs", include_in_schema=False) + async def serve_logs_ui() -> FileResponse: + return FileResponse(ui_dist_path / "logs" / "index.html") + + @router.get("/logs/index.txt", include_in_schema=False) + async def redirect_logs_index_txt() -> RedirectResponse: + return RedirectResponse("/logs") + + @router.get("/usage", include_in_schema=False) + async def serve_usage_ui() -> FileResponse: + return FileResponse(ui_dist_path / "usage" / "index.html") + + @router.get("/usage/index.txt", include_in_schema=False) + async def redirect_usage_index_txt() -> RedirectResponse: + return RedirectResponse("/usage") + + @router.get("/unauthorized", include_in_schema=False) + async def serve_unauthorized_ui() -> FileResponse: + return FileResponse(ui_dist_path / "unauthorized" / "index.html") + + @router.get("/unauthorized/index.txt", include_in_schema=False) + async def redirect_unauthorized_index_txt() -> RedirectResponse: + return RedirectResponse("/unauthorized") + + @router.get("/favicon.ico", include_in_schema=False) + async def serve_favicon() -> FileResponse: + icon_path = ui_dist_path / "icon.ico" + if icon_path.exists(): + return FileResponse(icon_path) + return FileResponse(ui_dist_path / "favicon.ico") + + @router.get("/icon.ico", include_in_schema=False) + async def serve_icon() -> FileResponse: + return FileResponse(ui_dist_path / "icon.ico") + + app.include_router(router) + + app.mount( + "/static", + StaticFiles(directory=ui_dist_path, check_dir=True), + name="ui-static", + ) + else: + logger.warning( + f"UI dist directory not found at {ui_dist_path}, skipping static file serving", + ) + + router = APIRouter() + + @router.get("/", include_in_schema=False) + async def root_fallback() -> dict: + return { + "name": global_settings.name, + "description": global_settings.description, + "version": app.version, + "status": "running", + "ui": "not available", + } + + app.include_router(router)