mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
chore: drop non-analytics branch drift
This commit is contained in:
1
logs/.gitkeep
Normal file
1
logs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
@@ -27,55 +26,20 @@ logger = get_logger(__name__)
|
||||
admin_router = APIRouter(prefix="/admin", include_in_schema=False)
|
||||
|
||||
admin_sessions: dict[str, int] = {}
|
||||
ADMIN_SESSION_DURATION = 12 * 60 * 60
|
||||
ADMIN_SESSION_DURATION = 3600
|
||||
# Usage analytics remain queryable up to 12 months.
|
||||
MAX_USAGE_ANALYTICS_HOURS = 365 * 24
|
||||
|
||||
|
||||
def _current_timestamp() -> int:
|
||||
return int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
|
||||
def _cleanup_expired_admin_sessions(now_timestamp: int | None = None) -> None:
|
||||
current_timestamp = (
|
||||
now_timestamp if now_timestamp is not None else _current_timestamp()
|
||||
)
|
||||
expired_tokens = [
|
||||
token
|
||||
for token, expiry_timestamp in admin_sessions.items()
|
||||
if expiry_timestamp <= current_timestamp
|
||||
]
|
||||
for token in expired_tokens:
|
||||
admin_sessions.pop(token, None)
|
||||
|
||||
|
||||
def _raise_unauthorized(detail: str) -> NoReturn:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=detail,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def require_admin_api(request: Request) -> None:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
_raise_unauthorized("Missing bearer token")
|
||||
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
|
||||
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
_raise_unauthorized("Missing bearer token")
|
||||
|
||||
now_timestamp = _current_timestamp()
|
||||
expiry_timestamp = admin_sessions.get(token)
|
||||
if expiry_timestamp is None:
|
||||
_raise_unauthorized("Invalid session token")
|
||||
|
||||
if expiry_timestamp <= now_timestamp:
|
||||
admin_sessions.pop(token, None)
|
||||
_raise_unauthorized("Session expired")
|
||||
|
||||
_cleanup_expired_admin_sessions(now_timestamp)
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
|
||||
|
||||
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
|
||||
@@ -244,10 +208,18 @@ async def admin_login(
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
expiry_timestamp = _current_timestamp() + ADMIN_SESSION_DURATION
|
||||
expiry_timestamp = (
|
||||
int(datetime.now(timezone.utc).timestamp()) + ADMIN_SESSION_DURATION
|
||||
)
|
||||
admin_sessions[token] = expiry_timestamp
|
||||
|
||||
_cleanup_expired_admin_sessions()
|
||||
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}
|
||||
|
||||
@@ -574,6 +546,7 @@ class UpstreamProviderCreate(BaseModel):
|
||||
api_version: str | None = None
|
||||
enabled: bool = True
|
||||
provider_fee: float = 1.01
|
||||
provider_settings: dict | None = None
|
||||
|
||||
|
||||
class UpstreamProviderUpdate(BaseModel):
|
||||
@@ -583,6 +556,7 @@ class UpstreamProviderUpdate(BaseModel):
|
||||
api_version: str | None = None
|
||||
enabled: bool | None = None
|
||||
provider_fee: float | None = None
|
||||
provider_settings: dict | None = None
|
||||
|
||||
|
||||
@admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
||||
@@ -599,6 +573,9 @@ async def get_upstream_providers() -> list[dict[str, object]]:
|
||||
"api_version": p.api_version,
|
||||
"enabled": p.enabled,
|
||||
"provider_fee": p.provider_fee,
|
||||
"provider_settings": json.loads(p.provider_settings)
|
||||
if p.provider_settings
|
||||
else None,
|
||||
}
|
||||
for p in providers
|
||||
]
|
||||
@@ -628,6 +605,9 @@ async def create_upstream_provider(
|
||||
api_version=payload.api_version,
|
||||
enabled=payload.enabled,
|
||||
provider_fee=payload.provider_fee,
|
||||
provider_settings=json.dumps(payload.provider_settings)
|
||||
if payload.provider_settings
|
||||
else None,
|
||||
)
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
@@ -643,6 +623,7 @@ async def create_upstream_provider(
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": payload.provider_settings,
|
||||
}
|
||||
|
||||
|
||||
@@ -662,6 +643,9 @@ async def get_upstream_provider(provider_id: int) -> dict[str, object]:
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": json.loads(provider.provider_settings)
|
||||
if provider.provider_settings
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -688,6 +672,8 @@ async def update_upstream_provider(
|
||||
provider.enabled = payload.enabled
|
||||
if payload.provider_fee is not None:
|
||||
provider.provider_fee = payload.provider_fee
|
||||
if payload.provider_settings is not None:
|
||||
provider.provider_settings = json.dumps(payload.provider_settings)
|
||||
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
@@ -703,6 +689,9 @@ async def update_upstream_provider(
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": json.loads(provider.provider_settings)
|
||||
if provider.provider_settings
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -822,8 +811,45 @@ class TopupRequest(BaseModel):
|
||||
amount: int
|
||||
|
||||
|
||||
class CashuTopupRequest(BaseModel):
|
||||
cashu_token: str
|
||||
class TopupTokenRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/topup-token",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def topup_provider_with_token(
|
||||
provider_id: int, payload: TopupTokenRequest
|
||||
) -> dict:
|
||||
"""Redeem a Cashu token for an upstream 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")
|
||||
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
headers = {}
|
||||
if provider.api_key:
|
||||
headers["Authorization"] = f"Bearer {provider.api_key}"
|
||||
resp = await client.post(
|
||||
f"{clean_url}/v1/balance/topup",
|
||||
json={"cashu_token": payload.token},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
return {"ok": True, "message": "Token redeemed successfully"}
|
||||
else:
|
||||
logger.error(f"Upstream token topup failed: {resp.text}")
|
||||
try:
|
||||
error_detail = resp.json()
|
||||
except Exception:
|
||||
error_detail = resp.text
|
||||
return {"ok": False, "message": f"Upstream error: {error_detail}"}
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
@@ -852,7 +878,49 @@ async def initiate_provider_topup(
|
||||
f"Initiating top-up for provider {provider_id}",
|
||||
extra={"amount": payload.amount},
|
||||
)
|
||||
|
||||
# For Routstr providers, we might be doing a Lightning top-up or a direct token transfer
|
||||
if provider.provider_type == "routstr":
|
||||
# UI sends sats for Routstr topup
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
# Proxy the request to upstream Routstr
|
||||
# Use the actual API key from the database
|
||||
resp = await client.post(
|
||||
f"{clean_url}/v1/balance/lightning/invoice",
|
||||
json={
|
||||
"amount_sats": int(payload.amount),
|
||||
"purpose": "topup",
|
||||
"api_key": provider.api_key,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {},
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"topup_data": {
|
||||
"payment_request": data.get("bolt11"),
|
||||
"invoice_id": data.get("invoice_id"),
|
||||
"status": "pending",
|
||||
},
|
||||
}
|
||||
else:
|
||||
logger.error(f"Upstream topup request failed: {resp.text}")
|
||||
# Check if it's JSON error
|
||||
try:
|
||||
error_detail = resp.json()
|
||||
except Exception:
|
||||
error_detail = resp.text
|
||||
raise HTTPException(
|
||||
status_code=resp.status_code, detail=error_detail
|
||||
)
|
||||
|
||||
topup_data = await upstream_instance.initiate_topup(payload.amount)
|
||||
|
||||
logger.info(
|
||||
"Top-up initiated successfully",
|
||||
extra={
|
||||
@@ -889,61 +957,6 @@ async def initiate_provider_topup(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/topup/token",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def topup_provider_with_token(
|
||||
provider_id: int, payload: CashuTopupRequest
|
||||
) -> dict[str, object]:
|
||||
"""Top up the upstream provider account with a Cashu token."""
|
||||
from ..upstream.helpers 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")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
cashu_token = payload.cashu_token.strip()
|
||||
if not cashu_token:
|
||||
raise HTTPException(status_code=400, detail="Cashu token is required")
|
||||
|
||||
if not hasattr(upstream_instance, "topup"):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Provider does not support token top-up"
|
||||
)
|
||||
|
||||
try:
|
||||
topup_data = await upstream_instance.topup(cashu_token)
|
||||
if isinstance(topup_data, dict) and topup_data.get("error"):
|
||||
raise HTTPException(status_code=400, detail=str(topup_data["error"]))
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"topup_data": topup_data if isinstance(topup_data, dict) else {},
|
||||
"message": "Token redeemed successfully",
|
||||
}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support token top-up: {str(e)}",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to top up provider {provider_id} with token: {e}",
|
||||
extra={"error_type": type(e).__name__},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
@@ -958,6 +971,23 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
# For Routstr providers, proxy the status check
|
||||
if provider.provider_type == "routstr":
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
resp = await client.get(
|
||||
f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status",
|
||||
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
status_data = resp.json()
|
||||
return {"ok": True, "paid": status_data.get("status") == "paid"}
|
||||
else:
|
||||
logger.error(f"Upstream status check failed: {resp.text}")
|
||||
return {"ok": False, "paid": False}
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
@@ -985,7 +1015,7 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
"""Get the current account balance for the upstream provider."""
|
||||
"""Get the current balance for an upstream provider account."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
@@ -993,6 +1023,30 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
# For Routstr providers, proxy the balance check
|
||||
if provider.provider_type == "routstr":
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
headers = {}
|
||||
if provider.api_key:
|
||||
headers["Authorization"] = f"Bearer {provider.api_key}"
|
||||
resp = await client.get(
|
||||
f"{clean_url}/v1/balance/info",
|
||||
headers=headers,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# Return balance in sats
|
||||
balance = data.get("balance", 0)
|
||||
if isinstance(balance, (int, float)):
|
||||
return {"ok": True, "balance_data": balance // 1000}
|
||||
return {"ok": True, "balance_data": balance}
|
||||
else:
|
||||
logger.error(f"Failed to fetch Routstr balance: {resp.text}")
|
||||
return {"ok": False, "balance_data": None}
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
@@ -1205,3 +1259,71 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
|
||||
continue
|
||||
|
||||
return {"dates": dates}
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/routstr/refund",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def refund_routstr_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
"""Refund balance from an upstream Routstr provider back to the local wallet."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
from ..upstream.routstr import RoutstrUpstreamProvider
|
||||
|
||||
async with create_session() as session:
|
||||
provider_row = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider_row:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
if provider_row.provider_type != "routstr":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Refund only supported for Routstr providers"
|
||||
)
|
||||
|
||||
provider = _instantiate_provider(provider_row)
|
||||
if not isinstance(provider, RoutstrUpstreamProvider):
|
||||
raise HTTPException(status_code=400, detail="Invalid provider instance")
|
||||
|
||||
try:
|
||||
# Request refund from upstream
|
||||
data = await provider.refund_balance()
|
||||
if "error" in data:
|
||||
# If the upstream returned an OpenAI-style error (like the model unknown error)
|
||||
# it means the request likely didn't even reach the refund endpoint handler
|
||||
# but was intercepted by the proxy layer.
|
||||
error_info = data.get("error", {})
|
||||
message = (
|
||||
error_info.get("message")
|
||||
if isinstance(error_info, dict)
|
||||
else str(error_info)
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Upstream refund failed: {message}",
|
||||
}
|
||||
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
return {"ok": False, "message": "Upstream did not return a token"}
|
||||
|
||||
# Receive token into local wallet
|
||||
from ..wallet import recieve_token
|
||||
|
||||
try:
|
||||
# Use current wallet to receive
|
||||
await recieve_token(token)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "Successfully received refund from upstream provider",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to receive refund token: {e}")
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Failed to receive refund token: {str(e)}",
|
||||
"token": token,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Refund failed for provider {provider_id}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from typing import TypedDict
|
||||
|
||||
from cashu.core.base import Proof, Token
|
||||
@@ -159,14 +158,6 @@ async def credit_balance(
|
||||
|
||||
|
||||
_wallets: dict[str, Wallet] = {}
|
||||
_balances_cache_ttl_seconds = 300.0
|
||||
_balances_cache: dict[
|
||||
tuple[str, ...], tuple[float, tuple[list["BalanceDetail"], int, int, int]]
|
||||
] = {}
|
||||
_balances_refresh_tasks: dict[
|
||||
tuple[str, ...], asyncio.Task[tuple[list["BalanceDetail"], int, int, int]]
|
||||
] = {}
|
||||
_balances_cache_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet:
|
||||
@@ -235,12 +226,6 @@ async def fetch_all_balances(
|
||||
"""
|
||||
if units is None:
|
||||
units = ["sat", "msat"]
|
||||
units_key = tuple(units)
|
||||
|
||||
now = time.time()
|
||||
cached = _balances_cache.get(units_key)
|
||||
if cached and cached[0] > now:
|
||||
return cached[1]
|
||||
|
||||
async def fetch_balance(
|
||||
session: db.AsyncSession, mint_url: str, unit: str
|
||||
@@ -276,7 +261,6 @@ async def fetch_all_balances(
|
||||
}
|
||||
return error_result
|
||||
|
||||
async def compute_balances() -> tuple[list[BalanceDetail], int, int, int]:
|
||||
# Create tasks for all mint/unit combinations
|
||||
async with db.create_session() as session:
|
||||
tasks = [
|
||||
@@ -311,6 +295,7 @@ async def fetch_all_balances(
|
||||
total_user_balance_sats += user_balance_sats
|
||||
|
||||
owner_balance = total_wallet_balance_sats - total_user_balance_sats
|
||||
|
||||
return (
|
||||
balance_details,
|
||||
total_wallet_balance_sats,
|
||||
@@ -318,30 +303,6 @@ async def fetch_all_balances(
|
||||
owner_balance,
|
||||
)
|
||||
|
||||
async with _balances_cache_lock:
|
||||
now = time.time()
|
||||
cached = _balances_cache.get(units_key)
|
||||
if cached and cached[0] > now:
|
||||
return cached[1]
|
||||
|
||||
refresh_task = _balances_refresh_tasks.get(units_key)
|
||||
if refresh_task is None or refresh_task.done():
|
||||
refresh_task = asyncio.create_task(compute_balances())
|
||||
_balances_refresh_tasks[units_key] = refresh_task
|
||||
|
||||
result = await refresh_task
|
||||
|
||||
async with _balances_cache_lock:
|
||||
_balances_cache[units_key] = (
|
||||
time.time() + _balances_cache_ttl_seconds,
|
||||
result,
|
||||
)
|
||||
current_task = _balances_refresh_tasks.get(units_key)
|
||||
if current_task is refresh_task and refresh_task.done():
|
||||
_balances_refresh_tasks.pop(units_key, None)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def periodic_payout() -> None:
|
||||
if not settings.receive_ln_address:
|
||||
|
||||
@@ -264,12 +264,12 @@ async def test_models_endpoint_accept_headers(integration_client: AsyncClient) -
|
||||
async def test_admin_endpoint_unauthenticated(
|
||||
integration_client: AsyncClient, db_snapshot: Any
|
||||
) -> None:
|
||||
"""Test admin API requires authentication."""
|
||||
"""Test GET /admin/ endpoint redirects to /"""
|
||||
await db_snapshot.capture()
|
||||
|
||||
response = await integration_client.get("/admin/api/settings")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 403
|
||||
|
||||
diff = await db_snapshot.diff()
|
||||
assert len(diff["api_keys"]["added"]) == 0
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import { SuppressHydrationWarning } from '@/components/suppress-hydration-warning';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Routstr',
|
||||
@@ -22,7 +23,9 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${GeistSans.variable} ${GeistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
<SuppressHydrationWarning>
|
||||
<Providers>{children}</Providers>
|
||||
</SuppressHydrationWarning>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -451,6 +451,7 @@ export default function ProvidersPage() {
|
||||
data: { api_key: newKey },
|
||||
});
|
||||
}}
|
||||
availableMints={availableMints}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,9 @@ function normalizeBaseUrl(url: string): string {
|
||||
}
|
||||
|
||||
export function CheatSheet(): JSX.Element {
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState(() =>
|
||||
typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl()
|
||||
);
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
const [walletInfo, setWalletInfo] = useState<WalletSnapshot | null>(null);
|
||||
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(
|
||||
|
||||
@@ -50,6 +50,7 @@ interface ProviderCardProps {
|
||||
onDeleteModel: (modelId: string) => void;
|
||||
onOverrideModel: (model: AdminModel) => void;
|
||||
onUpdateApiKey: (newKey: string) => void;
|
||||
availableMints: string[];
|
||||
}
|
||||
|
||||
export function ProviderCard({
|
||||
@@ -69,6 +70,7 @@ export function ProviderCard({
|
||||
onDeleteModel,
|
||||
onOverrideModel,
|
||||
onUpdateApiKey,
|
||||
availableMints,
|
||||
}: ProviderCardProps) {
|
||||
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
|
||||
const hasDetails = Boolean(provider.api_version) || isExpanded;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface RoutstrCreateKeySectionProps {
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart';
|
||||
import { ModelRevenueData } from '@/lib/api/services/admin';
|
||||
import { convertToMsat, formatFromMsat } from '@/lib/currency';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
|
||||
interface RevenueByModelTableProps {
|
||||
models: ModelRevenueData[];
|
||||
displayUnit: DisplayUnit;
|
||||
usdPerSat: number | null;
|
||||
}
|
||||
|
||||
function truncateModelName(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
export function RevenueByModelTable({
|
||||
models,
|
||||
displayUnit,
|
||||
usdPerSat,
|
||||
}: RevenueByModelTableProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const revenueDisplayUnit: DisplayUnit = useMemo(() => {
|
||||
if (displayUnit === 'usd' && usdPerSat === null) {
|
||||
return 'sat';
|
||||
}
|
||||
return displayUnit;
|
||||
}, [displayUnit, usdPerSat]);
|
||||
const unitLabel = revenueDisplayUnit === 'usd' ? 'USD' : revenueDisplayUnit;
|
||||
|
||||
const compactNumber = useMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const convertSatsToDisplay = useCallback(
|
||||
(sats: number): number => {
|
||||
if (revenueDisplayUnit === 'msat') {
|
||||
return sats * 1000;
|
||||
}
|
||||
if (revenueDisplayUnit === 'usd') {
|
||||
return sats * (usdPerSat ?? 0);
|
||||
}
|
||||
return sats;
|
||||
},
|
||||
[revenueDisplayUnit, usdPerSat]
|
||||
);
|
||||
|
||||
const formatAmount = (sats: number) =>
|
||||
formatFromMsat(convertToMsat(sats, 'sat'), revenueDisplayUnit, usdPerSat);
|
||||
|
||||
const formatCompactAmount = (value: number): string => {
|
||||
const compact = compactNumber.format(value);
|
||||
if (revenueDisplayUnit === 'usd') {
|
||||
return `$${compact}`;
|
||||
}
|
||||
return `${compact} ${unitLabel}`;
|
||||
};
|
||||
|
||||
const totalCollectedRevenue = models.reduce(
|
||||
(sum, model) => sum + model.revenue_sats,
|
||||
0
|
||||
);
|
||||
const totalOperationalNet = models.reduce(
|
||||
(sum, model) => sum + model.net_revenue_sats,
|
||||
0
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
[...models]
|
||||
.sort((a, b) => b.revenue_sats - a.revenue_sats)
|
||||
.slice(0, 12)
|
||||
.map((model) => ({
|
||||
model: model.model,
|
||||
modelLabel: truncateModelName(model.model, isMobile ? 16 : 28),
|
||||
revenueDisplay: convertSatsToDisplay(model.revenue_sats),
|
||||
})),
|
||||
[models, isMobile, convertSatsToDisplay]
|
||||
);
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
revenueDisplay: {
|
||||
label: 'Revenue',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
};
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue by Model</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='text-muted-foreground text-sm'>
|
||||
No model data available
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue by Model</CardTitle>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Total Collected Revenue:{' '}
|
||||
<span className='text-foreground font-mono font-medium'>
|
||||
{formatAmount(totalCollectedRevenue)}
|
||||
</span>
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Operational Net:{' '}
|
||||
<span className='text-foreground font-mono'>
|
||||
{formatAmount(totalOperationalNet)}
|
||||
</span>
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer className='h-[360px] w-full' config={chartConfig}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout='vertical'
|
||||
margin={{
|
||||
top: 8,
|
||||
right: isMobile ? 8 : 28,
|
||||
left: isMobile ? 8 : 28,
|
||||
bottom: 8,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid horizontal={false} className='stroke-muted/30' />
|
||||
<XAxis
|
||||
type='number'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) =>
|
||||
compactNumber.format(
|
||||
typeof value === 'number' ? value : Number(value || 0)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
type='category'
|
||||
dataKey='modelLabel'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={isMobile ? 110 : 220}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(label) => String(label)}
|
||||
formatter={(value, name) => {
|
||||
const numericValue =
|
||||
typeof value === 'number' ? value : Number(value || 0);
|
||||
return (
|
||||
<div className='grid w-full grid-cols-[minmax(0,1fr)_auto] gap-x-4'>
|
||||
<span className='text-muted-foreground truncate pr-1'>
|
||||
{name}
|
||||
</span>
|
||||
<span className='text-foreground text-right font-mono font-medium tabular-nums'>
|
||||
{Number.isFinite(numericValue)
|
||||
? formatCompactAmount(numericValue)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey='revenueDisplay'
|
||||
name={`Revenue (${unitLabel})`}
|
||||
fill='var(--color-revenueDisplay)'
|
||||
radius={[0, 6, 6, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export const UpstreamProviderSchema = z.object({
|
||||
api_version: z.string().nullable().optional(),
|
||||
enabled: z.boolean(),
|
||||
provider_fee: z.number().optional(),
|
||||
provider_settings: z.record(z.string(), z.unknown()).optional(),
|
||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||
});
|
||||
|
||||
export const CreateUpstreamProviderSchema = z.object({
|
||||
@@ -30,7 +30,7 @@ export const CreateUpstreamProviderSchema = z.object({
|
||||
api_version: z.string().nullable().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
provider_fee: z.number().optional(),
|
||||
provider_settings: z.record(z.string(), z.unknown()).optional(),
|
||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||
});
|
||||
|
||||
export const UpdateUpstreamProviderSchema = z.object({
|
||||
@@ -40,7 +40,7 @@ export const UpdateUpstreamProviderSchema = z.object({
|
||||
api_version: z.string().nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
provider_fee: z.number().optional(),
|
||||
provider_settings: z.record(z.string(), z.unknown()).optional(),
|
||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||
});
|
||||
|
||||
export const AdminModelPricingSchema = z.object({
|
||||
@@ -912,26 +912,17 @@ export class AdminService {
|
||||
ok: boolean;
|
||||
topup_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup`, {
|
||||
amount: amount,
|
||||
});
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup`, { amount });
|
||||
}
|
||||
|
||||
static async topupProviderWithToken(
|
||||
providerId: number,
|
||||
cashuToken: string
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
topup_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}> {
|
||||
return await apiClient.post<{
|
||||
ok: boolean;
|
||||
topup_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup/token`, {
|
||||
cashu_token: cashuToken,
|
||||
});
|
||||
token: string
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
return await apiClient.post<{ ok: boolean; message?: string }>(
|
||||
`/admin/api/upstream-providers/${providerId}/topup-token`,
|
||||
{ token }
|
||||
);
|
||||
}
|
||||
|
||||
static async checkTopupStatus(
|
||||
|
||||
Reference in New Issue
Block a user