import json import secrets from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse from pydantic import BaseModel from sqlmodel import select from ..payment.models import _row_to_model, list_models from ..proxy import refresh_model_maps, reinitialize_upstreams from ..wallet import ( fetch_all_balances, get_proofs_per_mint_and_unit, get_wallet, send_token, slow_filter_spend_proofs, ) from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session from .logging import get_logger from .settings import SettingsService, settings logger = get_logger(__name__) admin_router = APIRouter(prefix="/admin", include_in_schema=False) admin_sessions: dict[str, int] = {} ADMIN_SESSION_DURATION = 3600 def require_admin_api(request: Request) -> None: auth_header = request.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header.split(" ", 1)[1] expiry = admin_sessions.get(token) if expiry and expiry > int(datetime.now(timezone.utc).timestamp()): return raise HTTPException(status_code=403, detail="Unauthorized") def is_admin_authenticated(request: Request) -> bool: auth_header = request.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header.split(" ", 1)[1] expiry = admin_sessions.get(token) if expiry and expiry > int(datetime.now(timezone.utc).timestamp()): return True return False @admin_router.get( "/partials/balances", dependencies=[Depends(require_admin_api)], response_class=HTMLResponse, ) async def partial_balances(request: Request) -> str: ( balance_details, total_wallet_balance_sats, total_user_balance_sats, owner_balance, ) = await fetch_all_balances() # Provide JSON for client usage # Embed a script tag to update balanceDetails and the UI markup rows = "".join( [ f"""
{detail["mint_url"].replace("https://", "").replace("http://", "")} • {detail["unit"].upper()}
{detail["wallet_balance"] if not detail.get("error") else "error"}
{detail["user_balance"] if not detail.get("error") else "-"}
0 else ""}">{detail["owner_balance"] if not detail.get("error") else "-"}
""" for detail in balance_details if detail.get("wallet_balance", 0) > 0 or detail.get("error") ] ) return f"""

Cashu Wallet Balance

Your Balance (Total) {owner_balance} sats
Total Wallet {total_wallet_balance_sats} sats
User Balance {total_user_balance_sats} sats

Your balance = Total wallet - User balance

Mint / Unit
Wallet
Users
Owner
{rows}
""" @admin_router.get( "/partials/apikeys", dependencies=[Depends(require_admin_api)], response_class=HTMLResponse, ) async def partial_apikeys(request: Request) -> str: async with create_session() as session: result = await session.exec(select(ApiKey)) api_keys = result.all() def fmt_time(ts: int | None) -> str: if ts is None: return "" dt = datetime.fromtimestamp(ts, tz=timezone.utc) return f"{ts} ({dt.strftime('%Y-%m-%d %H:%M:%S')} UTC)" rows = "".join( [ f"{key.hashed_key}{key.balance}{key.total_spent}{key.total_requests}{key.refund_address}{fmt_time(key.key_expiry_time)}" for key in api_keys ] ) return f"""

Temporary Balances

{rows}
Hashed Key Balance (mSats) Total Spent (mSats) Total Requests Refund Address Refund Time
""" @admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)]) async def get_temporary_balances_api(request: Request) -> list[dict[str, object]]: async with create_session() as session: result = await session.exec(select(ApiKey)) api_keys = result.all() return [ { "hashed_key": key.hashed_key, "balance": key.balance, "total_spent": key.total_spent, "total_requests": key.total_requests, "refund_address": key.refund_address, "key_expiry_time": key.key_expiry_time, } for key in api_keys ] @admin_router.get("/api/balances", dependencies=[Depends(require_admin_api)]) async def get_balances_api(request: Request) -> list[dict[str, object]]: balance_details, _tw, _tu, _ow = await fetch_all_balances() return [dict(d) for d in balance_details] @admin_router.get("/api/settings", dependencies=[Depends(require_admin_api)]) async def get_settings(request: Request) -> dict: data = settings.dict() if "upstream_api_key" in data: data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else "" if "admin_password" in data: data["admin_password"] = "[REDACTED]" if data["admin_password"] else "" if "nsec" in data: data["nsec"] = "[REDACTED]" if data["nsec"] else "" return data class SettingsUpdate(BaseModel): __root__: dict[str, object] class PasswordUpdate(BaseModel): current_password: str new_password: str @admin_router.patch("/api/settings", dependencies=[Depends(require_admin_api)]) async def update_settings(request: Request, update: SettingsUpdate) -> dict: # Remove sensitive fields from general settings update settings_data = update.__root__.copy() sensitive_fields = ["admin_password", "upstream_api_key", "nsec"] for field in sensitive_fields: if field in settings_data: del settings_data[field] async with create_session() as session: new_settings = await SettingsService.update(settings_data, session) data = new_settings.dict() if "upstream_api_key" in data: data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else "" if "admin_password" in data: data["admin_password"] = "[REDACTED]" if data["admin_password"] else "" if "nsec" in data: data["nsec"] = "[REDACTED]" if data["nsec"] else "" return data @admin_router.patch("/api/password", dependencies=[Depends(require_admin_api)]) async def update_password(request: Request, password_update: PasswordUpdate) -> dict: current_password = settings.admin_password if not current_password: raise HTTPException(status_code=500, detail="Admin password not configured") if password_update.current_password != current_password: raise HTTPException(status_code=401, detail="Current password is incorrect") # Validate new password new_password = password_update.new_password.strip() if len(new_password) < 6: raise HTTPException( status_code=400, detail="New password must be at least 6 characters" ) # Update password async with create_session() as session: await SettingsService.update({"admin_password": new_password}, session) return {"ok": True, "message": "Password updated successfully"} class SetupRequest(BaseModel): password: str @admin_router.post("/api/setup") async def initial_setup(request: Request, payload: SetupRequest) -> dict[str, object]: if settings.admin_password: raise HTTPException(status_code=409, detail="Admin password already set") pw = (payload.password or "").strip() if len(pw) < 8: raise HTTPException( status_code=400, detail="Password must be at least 8 characters" ) async with create_session() as session: await SettingsService.update({"admin_password": pw}, session) return {"ok": True} class AdminLoginRequest(BaseModel): password: str @admin_router.post("/api/login") async def admin_login( request: Request, payload: AdminLoginRequest ) -> dict[str, object]: admin_pw = settings.admin_password if not admin_pw: raise HTTPException(status_code=500, detail="Admin password not configured") if payload.password != admin_pw: raise HTTPException(status_code=401, detail="Invalid password") token = secrets.token_urlsafe(32) expiry_timestamp = ( int(datetime.now(timezone.utc).timestamp()) + ADMIN_SESSION_DURATION ) admin_sessions[token] = expiry_timestamp expired_tokens = [ t for t, exp in admin_sessions.items() if exp <= int(datetime.now(timezone.utc).timestamp()) ] for t in expired_tokens: del admin_sessions[t] return {"ok": True, "token": token, "expires_in": ADMIN_SESSION_DURATION} @admin_router.post("/api/logout", dependencies=[Depends(require_admin_api)]) async def admin_logout(request: Request) -> dict[str, object]: auth_header = request.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header.split(" ", 1)[1] if token in admin_sessions: del admin_sessions[token] return {"ok": True} class WithdrawRequest(BaseModel): amount: int mint_url: str | None = None unit: str = "sat" def login_form() -> str: return """

🔐 Admin Login

""" def setup_form() -> str: return """

🔧 Initial Admin Setup

Create a secure password for your admin dashboard.

""" def info(content: str) -> str: return f"""

{content}

""" def admin_auth() -> str: admin_pw = settings.admin_password if admin_pw == "": return setup_form() else: return login_form() async def dashboard(request: Request) -> str: return ( f""" """ + """ """ + """

Admin Dashboard

Loading balances…
Withdrawal Token:

Save this token! It represents your withdrawn balance.

Temporary Balances

Loading API keys…
""" ) @admin_router.get("/", response_class=HTMLResponse) async def admin(request: Request) -> RedirectResponse: return RedirectResponse("/") @admin_router.get("/logs/{request_id}", response_class=HTMLResponse) async def view_logs(request: Request, request_id: str) -> str: if not is_admin_authenticated(request): return admin_auth() logger.info(f"Investigating logs for request_id: {request_id}") # Search for log entries with this request_id log_entries = [] logs_dir = Path("logs") if logs_dir.exists(): # Get all log files sorted by modification time (most recent first) log_files = sorted( logs_dir.glob("*.log"), key=lambda x: x.stat().st_mtime, reverse=True ) for log_file in log_files[:7]: # Check last 7 days of logs try: with open(log_file, "r") as f: for line in f: if request_id in line: try: # Parse JSON log entry log_data = json.loads(line.strip()) log_entries.append(log_data) except json.JSONDecodeError: # If not JSON, include raw line log_entries.append({"raw": line.strip()}) except Exception as e: logger.error(f"Error reading log file {log_file}: {e}") # Sort entries by timestamp if available log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=False) # Format log entries for display formatted_logs = [] for entry in log_entries: if "raw" in entry: formatted_logs.append(f'
{entry["raw"]}
') else: # Format JSON log entry timestamp = entry.get("asctime", "Unknown time") level = entry.get("levelname", "INFO") message = entry.get("message", "") pathname = entry.get("pathname", "") lineno = entry.get("lineno", "") # Extract additional fields extra_fields = { k: v for k, v in entry.items() if k not in [ "asctime", "levelname", "message", "pathname", "lineno", "name", "version", "request_id", ] } level_class = level.lower() formatted_entry = f"""
{timestamp} [{level}] {pathname}:{lineno}
{message}
""" if extra_fields: formatted_entry += '
' for key, value in extra_fields.items(): formatted_entry += f'
{key}: {json.dumps(value) if isinstance(value, (dict, list)) else value}
' formatted_entry += "
" formatted_entry += "
" formatted_logs.append(formatted_entry) return ( f""" """ + f""" ← Back to Dashboard

Log Investigation

Request ID: {request_id}
{"".join(formatted_logs) if formatted_logs else '
No log entries found for this Request ID
'}

Found {len(log_entries)} log entries • Searched last 7 days of logs

""" ) @admin_router.post("/withdraw", dependencies=[Depends(require_admin_api)]) async def withdraw( request: Request, withdraw_request: WithdrawRequest ) -> dict[str, str]: # Get wallet and check balance from .settings import settings as global_settings wallet = await get_wallet( withdraw_request.mint_url or global_settings.primary_mint, withdraw_request.unit ) proofs = get_proofs_per_mint_and_unit( wallet, withdraw_request.mint_url or global_settings.primary_mint, withdraw_request.unit, not_reserved=True, ) proofs = await slow_filter_spend_proofs(proofs, wallet) current_balance = sum(proof.amount for proof in proofs) if withdraw_request.amount <= 0: raise HTTPException( status_code=400, detail="Withdrawal amount must be positive" ) if withdraw_request.amount > current_balance: raise HTTPException(status_code=400, detail="Insufficient wallet balance") token = await send_token( withdraw_request.amount, withdraw_request.unit, withdraw_request.mint_url ) return {"token": token} DASHBOARD_MODELS_JS: str = """ """ def models_page() -> str: return ( f""" {DASHBOARD_MODELS_JS} """ + """ ← Back to Dashboard

Models

Models Table

ID
Loading…
""" ) class ModelCreate(BaseModel): id: str name: str description: str created: int context_length: int architecture: dict[str, object] pricing: dict[str, object] per_request_limits: dict[str, object] | None = None top_provider: dict[str, object] | None = None upstream_provider_id: int | None = None enabled: bool = True class ModelUpdate(BaseModel): id: str name: str description: str created: int context_length: int architecture: dict[str, object] pricing: dict[str, object] per_request_limits: dict[str, object] | None = None top_provider: dict[str, object] | None = None upstream_provider_id: int | None = None enabled: bool = True @admin_router.get("/models", response_class=HTMLResponse) async def admin_models(request: Request) -> str: if is_admin_authenticated(request): return models_page() return admin_auth() UPSTREAM_PROVIDERS_JS: str = """ """ def upstream_providers_page() -> str: return ( f""" {UPSTREAM_PROVIDERS_JS} """ + """ ← Back to Dashboard

Upstream Providers

Providers

Type Base URL Status Actions
Loading…
""" ) @admin_router.get("/upstream-providers", response_class=HTMLResponse) async def admin_upstream_providers(request: Request) -> str: if is_admin_authenticated(request): return upstream_providers_page() return admin_auth() @admin_router.post( "/api/upstream-providers/{provider_id}/models", dependencies=[Depends(require_admin_api)], ) async def create_provider_model( provider_id: int, payload: ModelCreate ) -> dict[str, object]: async with create_session() as session: provider = await session.get(UpstreamProviderRow, provider_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") exists = await session.get(ModelRow, (payload.id, provider_id)) if exists: raise HTTPException( status_code=409, detail="Model with this ID already exists for this provider", ) row = ModelRow( id=payload.id, name=payload.name, description=payload.description, created=int(payload.created), context_length=int(payload.context_length), architecture=json.dumps(payload.architecture), pricing=json.dumps(payload.pricing), sats_pricing=None, per_request_limits=( json.dumps(payload.per_request_limits) if payload.per_request_limits is not None else None ), top_provider=( json.dumps(payload.top_provider) if payload.top_provider else None ), upstream_provider_id=provider_id, enabled=payload.enabled, ) session.add(row) await session.commit() await session.refresh(row) await refresh_model_maps() return _row_to_model( row, apply_provider_fee=True, provider_fee=provider.provider_fee ).dict() # type: ignore @admin_router.get( "/api/upstream-providers/{provider_id}/models/{model_id:path}", dependencies=[Depends(require_admin_api)], ) async def get_provider_model(provider_id: int, model_id: str) -> dict[str, object]: async with create_session() as session: provider = await session.get(UpstreamProviderRow, provider_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") row = await session.get(ModelRow, (model_id, provider_id)) if not row: raise HTTPException( status_code=404, detail="Model not found for this provider" ) return _row_to_model( row, apply_provider_fee=True, provider_fee=provider.provider_fee ).dict() # type: ignore @admin_router.patch( "/api/upstream-providers/{provider_id}/models/{model_id:path}", dependencies=[Depends(require_admin_api)], ) async def update_provider_model( provider_id: int, model_id: str, payload: ModelUpdate ) -> dict[str, object]: if payload.id != model_id: raise HTTPException(status_code=400, detail="Path id does not match payload id") async with create_session() as session: provider = await session.get(UpstreamProviderRow, provider_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") row = await session.get(ModelRow, (model_id, provider_id)) if not row: raise HTTPException( status_code=404, detail="Model not found for this provider" ) row.name = payload.name row.description = payload.description row.created = int(payload.created) row.context_length = int(payload.context_length) row.architecture = json.dumps(payload.architecture) row.pricing = json.dumps(payload.pricing) row.sats_pricing = None row.per_request_limits = ( json.dumps(payload.per_request_limits) if payload.per_request_limits is not None else None ) row.top_provider = ( json.dumps(payload.top_provider) if payload.top_provider else None ) was_disabled = not row.enabled row.enabled = payload.enabled session.add(row) await session.commit() await session.refresh(row) if was_disabled and payload.enabled: from ..payment.models import _cleanup_enabled_models_once try: await _cleanup_enabled_models_once() except Exception as e: logger.warning( f"Failed to run model cleanup after enabling: {e}", extra={"model_id": model_id, "error": str(e)}, ) await refresh_model_maps() return _row_to_model( row, apply_provider_fee=True, provider_fee=provider.provider_fee ).dict() # type: ignore @admin_router.put( "/api/upstream-providers/{provider_id}/models/{model_id:path}", dependencies=[Depends(require_admin_api)], ) async def update_provider_model_put( provider_id: int, model_id: str, payload: ModelUpdate ) -> dict[str, object]: return await update_provider_model(provider_id, model_id, payload) @admin_router.delete( "/api/upstream-providers/{provider_id}/models/{model_id:path}", dependencies=[Depends(require_admin_api)], ) async def delete_provider_model(provider_id: int, model_id: str) -> dict[str, object]: async with create_session() as session: row = await session.get(ModelRow, (model_id, provider_id)) if not row: raise HTTPException( status_code=404, detail="Model not found for this provider" ) await session.delete(row) await session.commit() await refresh_model_maps() return {"ok": True, "deleted_id": model_id} @admin_router.delete( "/api/upstream-providers/{provider_id}/models", dependencies=[Depends(require_admin_api)], ) async def delete_all_provider_models(provider_id: int) -> dict[str, object]: async with create_session() as session: result = await session.exec( select(ModelRow).where(ModelRow.upstream_provider_id == provider_id) ) # type: ignore rows = result.all() for row in rows: await session.delete(row) # type: ignore await session.commit() await refresh_model_maps() return {"ok": True, "deleted": len(rows)} class UpstreamProviderCreate(BaseModel): provider_type: str base_url: str api_key: str api_version: str | None = None enabled: bool = True provider_fee: float = 1.01 class UpstreamProviderUpdate(BaseModel): provider_type: str | None = None base_url: str | None = None api_key: str | None = None api_version: str | None = None enabled: bool | None = None provider_fee: float | None = None @admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)]) async def get_upstream_providers() -> list[dict[str, object]]: async with create_session() as session: result = await session.exec(select(UpstreamProviderRow)) providers = result.all() return [ { "id": p.id, "provider_type": p.provider_type, "base_url": p.base_url, "api_key": "[REDACTED]" if p.api_key else "", "api_version": p.api_version, "enabled": p.enabled, "provider_fee": p.provider_fee, } for p in providers ] @admin_router.post("/api/upstream-providers", dependencies=[Depends(require_admin_api)]) async def create_upstream_provider( payload: UpstreamProviderCreate, ) -> dict[str, object]: async with create_session() as session: result = await session.exec( select(UpstreamProviderRow).where( UpstreamProviderRow.base_url == payload.base_url ) ) if result.first(): raise HTTPException( status_code=409, detail="Provider with this base URL already exists" ) provider = UpstreamProviderRow( provider_type=payload.provider_type, base_url=payload.base_url, api_key=payload.api_key, api_version=payload.api_version, enabled=payload.enabled, provider_fee=payload.provider_fee, ) session.add(provider) await session.commit() await session.refresh(provider) await reinitialize_upstreams() await refresh_model_maps() return { "id": provider.id, "provider_type": provider.provider_type, "base_url": provider.base_url, "api_key": "[REDACTED]", "api_version": provider.api_version, "enabled": provider.enabled, "provider_fee": provider.provider_fee, } @admin_router.get( "/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)] ) async def get_upstream_provider(provider_id: int) -> dict[str, object]: async with create_session() as session: provider = await session.get(UpstreamProviderRow, provider_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") return { "id": provider.id, "provider_type": provider.provider_type, "base_url": provider.base_url, "api_key": "[REDACTED]" if provider.api_key else "", "api_version": provider.api_version, "enabled": provider.enabled, "provider_fee": provider.provider_fee, } @admin_router.patch( "/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)] ) async def update_upstream_provider( provider_id: int, payload: UpstreamProviderUpdate ) -> dict[str, object]: async with create_session() as session: provider = await session.get(UpstreamProviderRow, provider_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") if payload.provider_type is not None: provider.provider_type = payload.provider_type if payload.base_url is not None: provider.base_url = payload.base_url if payload.api_key is not None: provider.api_key = payload.api_key if payload.api_version is not None: provider.api_version = payload.api_version if payload.enabled is not None: provider.enabled = payload.enabled if payload.provider_fee is not None: provider.provider_fee = payload.provider_fee session.add(provider) await session.commit() await session.refresh(provider) await reinitialize_upstreams() await refresh_model_maps() return { "id": provider.id, "provider_type": provider.provider_type, "base_url": provider.base_url, "api_key": "[REDACTED]", "api_version": provider.api_version, "enabled": provider.enabled, "provider_fee": provider.provider_fee, } @admin_router.delete( "/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)] ) async def delete_upstream_provider(provider_id: int) -> dict[str, object]: async with create_session() as session: provider = await session.get(UpstreamProviderRow, provider_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") await session.delete(provider) await session.commit() await reinitialize_upstreams() await refresh_model_maps() return {"ok": True, "deleted_id": provider_id} @admin_router.get("/api/provider-types", dependencies=[Depends(require_admin_api)]) async def get_provider_types() -> list[dict[str, object]]: """Get metadata about available provider types including default URLs and whether they're fixed.""" provider_types = [ { "id": "openrouter", "name": "OpenRouter", "default_base_url": "https://openrouter.ai/api/v1", "fixed_base_url": True, }, { "id": "openai", "name": "OpenAI", "default_base_url": "https://api.openai.com/v1", "fixed_base_url": True, }, { "id": "anthropic", "name": "Anthropic", "default_base_url": "https://api.anthropic.com/v1", "fixed_base_url": True, }, { "id": "azure", "name": "Azure OpenAI", "default_base_url": "", "fixed_base_url": False, }, { "id": "ollama", "name": "Ollama", "default_base_url": "http://localhost:11434", "fixed_base_url": False, }, { "id": "generic", "name": "Generic", "default_base_url": "", "fixed_base_url": False, }, ] return provider_types @admin_router.get( "/api/upstream-providers/{provider_id}/models", dependencies=[Depends(require_admin_api)], ) async def get_provider_models(provider_id: int) -> dict[str, object]: from ..upstream import _instantiate_provider async with create_session() as session: provider = await session.get(UpstreamProviderRow, provider_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") db_models = await list_models( session=session, upstream_id=provider_id, include_disabled=True ) upstream_models = [] upstream_instance = _instantiate_provider(provider) if upstream_instance: try: raw_models = await upstream_instance.fetch_models() upstream_models = [ upstream_instance._apply_provider_fee_to_model(m) for m in raw_models ] except Exception as e: logger.error( f"Failed to fetch models from {provider.provider_type}: {e}" ) db_model_ids = {model.id for model in db_models} filtered_remote_models = [ m for m in upstream_models if m.name not in db_model_ids ] return { "provider": { "id": provider.id, "provider_type": provider.provider_type, "base_url": provider.base_url, }, "db_models": [m.dict() for m in db_models], "remote_models": [m.dict() for m in filtered_remote_models], } @admin_router.get( "/api/openrouter-presets", dependencies=[Depends(require_admin_api)], ) async def get_openrouter_presets() -> list[dict[str, object]]: from ..payment.models import async_fetch_openrouter_models models_data = await async_fetch_openrouter_models() return models_data DASHBOARD_CSS: str = """ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f7fa; color: #2c3e50; line-height: 1.6; padding: 2rem; } h1, h2 { margin-bottom: 1rem; color: #1a202c; } h1 { font-size: 2rem; } h2 { font-size: 1.5rem; margin-top: 2rem; } p { margin-bottom: 0.5rem; color: #4a5568; } table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 1rem; } th { background: #4a5568; color: white; font-weight: 600; padding: 12px; text-align: left; } td { padding: 12px; border-bottom: 1px solid #e2e8f0; } tr:hover { background: #f7fafc; } button { padding: 10px 20px; cursor: pointer; background: #4299e1; color: white; border: none; border-radius: 6px; font-weight: 600; margin-right: 10px; transition: all 0.2s; } button:hover { background: #3182ce; transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1); } button:disabled { background: #a0aec0; cursor: not-allowed; transform: none; } .refresh-btn { background: #48bb78; } .refresh-btn:hover { background: #38a169; } .investigate-btn { background: #4299e1; } .balance-card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem; } .balance-item { display: flex; justify-content: space-between; margin-bottom: 1rem; } .balance-label { color: #718096; } .balance-value { font-size: 1.5rem; font-weight: 700; color: #2d3748; } .balance-primary { color: #48bb78; } .currency-grid { margin-top: 1rem; font-size: 0.9rem; } .currency-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; align-items: center; } .currency-row:last-child { border-bottom: none; } .currency-header { font-weight: 600; color: #4a5568; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; } .mint-name { color: #2d3748; font-size: 0.85rem; word-break: break-all; } .balance-num { text-align: right; font-family: monospace; } .owner-positive { color: #22c55e; } .error-row { color: #dc2626; font-style: italic; } #token-result { margin-top: 20px; padding: 20px; background: #e6fffa; border: 1px solid #38b2ac; border-radius: 8px; display: none; } #token-text { font-family: 'Monaco', monospace; font-size: 13px; background: #2d3748; color: #68d391; padding: 15px; border-radius: 6px; margin: 10px 0; word-break: break-all; } .copy-btn { background: #38a169; padding: 6px 12px; font-size: 14px; } .copy-btn:hover { background: #2f855a; } .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); } .modal-content { background: white; margin: 5% auto; padding: 0.75rem 1rem 2.25rem; width: 90%; max-width: 720px; max-height: 85vh; overflow-y: auto; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; } @keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .close { color: #a0aec0; float: right; font-size: 28px; font-weight: bold; cursor: pointer; margin: -10px -10px 0 0; } .close:hover { color: #2d3748; } input[type="number"], input[type="text"], input[type="password"], select { width: 100%; padding: 10px; margin: 10px 0; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 16px; transition: border 0.2s; } input[type="number"]:focus, input[type="text"]:focus, input[type="password"]:focus, select:focus { outline: none; border-color: #4299e1; } .warning { color: #e53e3e; font-weight: 600; margin: 10px 0; padding: 10px; background: #fff5f5; border-radius: 6px; } """ LOGS_CSS: str = """ body { font-family: Arial, sans-serif; margin: 20px; background-color: #f5f5f5; } h1 { color: #333; } .back-btn { padding: 8px 16px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; margin-bottom: 20px; } .back-btn:hover { background-color: #0056b3; } .log-container { background-color: white; border: 1px solid #ddd; border-radius: 8px; padding: 20px; max-height: 80vh; overflow-y: auto; } .log-entry { margin-bottom: 15px; padding: 10px; border: 1px solid #e0e0e0; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12px; background-color: #f9f9f9; } .log-entry.log-error { background-color: #fee; border-color: #fcc; } .log-entry.log-warning { background-color: #ffc; border-color: #ff9; } .log-entry.log-debug, .log-entry.log-trace { background-color: #f0f0f0; border-color: #ccc; } .log-header { margin-bottom: 5px; color: #666; } .log-timestamp { color: #0066cc; } .log-level { font-weight: bold; } .log-message { margin: 5px 0; color: #333; } .log-extra { margin-top: 5px; padding-top: 5px; border-top: 1px solid #e0e0e0; } .log-field { margin: 2px 0; color: #666; word-break: break-all; } .no-logs { text-align: center; color: #666; padding: 40px; } .request-id-display { background-color: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; font-family: monospace; } """