From aaa80d47bc45c3269959d6b5f50a403fcd34e009 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 1 Jun 2026 23:27:26 +0200 Subject: [PATCH] add api key creation date --- ...f1a2b3c4d5e6_add_created_at_to_api_keys.py | 25 + routstr/core/admin.py | 97 +++- routstr/core/db.py | 8 + .../test_temporary_balances_api.py | 210 +++++++++ ui/components/temporary-balances.tsx | 442 +++++++++--------- ui/lib/api/services/admin.ts | 26 +- 6 files changed, 580 insertions(+), 228 deletions(-) create mode 100644 migrations/versions/f1a2b3c4d5e6_add_created_at_to_api_keys.py create mode 100644 tests/integration/test_temporary_balances_api.py diff --git a/migrations/versions/f1a2b3c4d5e6_add_created_at_to_api_keys.py b/migrations/versions/f1a2b3c4d5e6_add_created_at_to_api_keys.py new file mode 100644 index 00000000..c2a38f57 --- /dev/null +++ b/migrations/versions/f1a2b3c4d5e6_add_created_at_to_api_keys.py @@ -0,0 +1,25 @@ +"""add created_at to api_keys + +Revision ID: f1a2b3c4d5e6 +Revises: cli_tokens_001 +Create Date: 2026-06-01 00:00:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f1a2b3c4d5e6" +down_revision = "cli_tokens_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Nullable on purpose: existing keys keep NULL (unknown creation time) and + # sort last; new keys get populated by the model's default_factory. + op.add_column("api_keys", sa.Column("created_at", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("api_keys", "created_at") diff --git a/routstr/core/admin.py b/routstr/core/admin.py index a00dfe88..3fffc8c7 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -68,26 +68,89 @@ async def require_admin_api(request: Request) -> None: @admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)]) -async def get_temporary_balances_api(request: Request) -> list[dict[str, object]]: +async def get_temporary_balances_api( + request: Request, + search: str | None = None, + limit: int = 50, + offset: int = 0, +) -> dict[str, object]: + from sqlalchemy import case + from sqlmodel import col, func + + filters = [] + if search: + pattern = f"%{search}%" + filters.append( + col(ApiKey.hashed_key).like(pattern) + | col(ApiKey.refund_address).like(pattern) + ) + async with create_session() as session: - result = await session.exec(select(ApiKey)) + base = select(ApiKey).where(*filters) + + count_result = await session.exec( + select(func.count()).select_from(base.subquery()) + ) + total = count_result.one() + + # Aggregate totals across the whole (search-filtered) set, not just the + # current page. Balance counts only parent (non-child) keys to avoid + # double-counting, since child keys draw from their parent's balance. + totals_result = await session.exec( + select( + func.coalesce( + func.sum( + case( + (col(ApiKey.parent_key_hash).is_(None), ApiKey.balance), + else_=0, + ) + ), + 0, + ), + func.coalesce(func.sum(ApiKey.total_spent), 0), + func.coalesce(func.sum(ApiKey.total_requests), 0), + ).where(*filters) + ) + total_balance, total_spent, total_requests = totals_result.one() + + # Latest created first; keys with no created_at (legacy rows) sort last. + # Use an explicit CASE rather than relying on dialect NULL-ordering so + # the behaviour is identical on SQLite and Postgres. + stmt = ( + base.order_by( + case((col(ApiKey.created_at).is_(None), 1), else_=0), + col(ApiKey.created_at).desc(), + ) + .offset(offset) + .limit(limit) + ) + result = await session.exec(stmt) 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, - "parent_key_hash": key.parent_key_hash, - "balance_limit": key.balance_limit, - "balance_limit_reset": key.balance_limit_reset, - "validity_date": key.validity_date, - } - for key in api_keys - ] + return { + "balances": [ + { + "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, + "parent_key_hash": key.parent_key_hash, + "balance_limit": key.balance_limit, + "balance_limit_reset": key.balance_limit_reset, + "validity_date": key.validity_date, + "created_at": key.created_at, + } + for key in api_keys + ], + "total": total, + "totals": { + "total_balance": total_balance, + "total_spent": total_spent, + "total_requests": total_requests, + }, + } class ApiKeyUpdate(BaseModel): diff --git a/routstr/core/db.py b/routstr/core/db.py index 1a253479..182dca36 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -45,6 +45,14 @@ class ApiKey(SQLModel, table=True): # type: ignore default=0, description="Total spent in millisatoshis (msats)" ) total_requests: int = Field(default=0) + created_at: int | None = Field( + default_factory=lambda: int(time.time()), + nullable=True, + description=( + "Unix timestamp when the key was created. Nullable: keys created " + "before this column existed have no value and sort last." + ), + ) refund_mint_url: str | None = Field( default=None, description="URL of the mint used to create the cashu-token", diff --git a/tests/integration/test_temporary_balances_api.py b/tests/integration/test_temporary_balances_api.py new file mode 100644 index 00000000..feee0113 --- /dev/null +++ b/tests/integration/test_temporary_balances_api.py @@ -0,0 +1,210 @@ +from datetime import datetime, timedelta, timezone + +import httpx +import pytest +from sqlmodel import col, update +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.core.admin import admin_sessions +from routstr.core.db import ApiKey + + +def _admin_headers() -> dict[str, str]: + token = "test-admin-token" + admin_sessions[token] = int( + (datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp() + ) + return {"Authorization": f"Bearer {token}"} + + +async def _add_key( + session: AsyncSession, + hashed_key: str, + *, + balance: int = 0, + total_spent: int = 0, + total_requests: int = 0, + created_at: int | None = None, + parent_key_hash: str | None = None, + refund_address: str | None = None, +) -> ApiKey: + key = ApiKey( + hashed_key=hashed_key, + balance=balance, + total_spent=total_spent, + total_requests=total_requests, + parent_key_hash=parent_key_hash, + refund_address=refund_address, + ) + key.created_at = created_at + session.add(key) + await session.commit() + + # The model's default_factory is translated into a SQLAlchemy column + # default that fires on INSERT whenever the value is None, so a true NULL + # (a legacy row created before the column existed) can only be produced by + # an explicit UPDATE after insert. + if created_at is None: + await session.exec( + update(ApiKey) # type: ignore[call-overload] + .where(col(ApiKey.hashed_key) == hashed_key) + .values(created_at=None) + ) + await session.commit() + return key + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_temporary_balances_envelope_and_created_at( + integration_client: httpx.AsyncClient, + integration_session: AsyncSession, +) -> None: + await _add_key(integration_session, "key_a", balance=1000, created_at=1000) + + response = await integration_client.get( + "/admin/api/temporary-balances", headers=_admin_headers() + ) + + assert response.status_code == 200 + body = response.json() + assert set(body.keys()) == {"balances", "total", "totals"} + assert body["total"] == 1 + assert body["balances"][0]["hashed_key"] == "key_a" + assert body["balances"][0]["created_at"] == 1000 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_temporary_balances_sorted_latest_first_nulls_last( + integration_client: httpx.AsyncClient, + integration_session: AsyncSession, +) -> None: + await _add_key(integration_session, "older", created_at=1000) + await _add_key(integration_session, "newer", created_at=2000) + await _add_key(integration_session, "legacy", created_at=None) + + response = await integration_client.get( + "/admin/api/temporary-balances", headers=_admin_headers() + ) + + assert response.status_code == 200 + order = [b["hashed_key"] for b in response.json()["balances"]] + # Newest created first, NULL created_at (legacy) sorts last. + assert order == ["newer", "older", "legacy"] + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_temporary_balances_pagination( + integration_client: httpx.AsyncClient, + integration_session: AsyncSession, +) -> None: + for i in range(5): + await _add_key(integration_session, f"key_{i}", created_at=1000 + i) + + headers = _admin_headers() + page1 = ( + await integration_client.get( + "/admin/api/temporary-balances?limit=2&offset=0", headers=headers + ) + ).json() + page2 = ( + await integration_client.get( + "/admin/api/temporary-balances?limit=2&offset=2", headers=headers + ) + ).json() + + assert page1["total"] == 5 + assert page2["total"] == 5 + assert [b["hashed_key"] for b in page1["balances"]] == ["key_4", "key_3"] + assert [b["hashed_key"] for b in page2["balances"]] == ["key_2", "key_1"] + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_temporary_balances_totals_exclude_child_balance( + integration_client: httpx.AsyncClient, + integration_session: AsyncSession, +) -> None: + await _add_key( + integration_session, + "parent", + balance=5000, + total_spent=100, + total_requests=3, + created_at=1000, + ) + # Child draws from parent's balance, so its balance must NOT be summed, + # but its spent/requests still count. + await _add_key( + integration_session, + "child", + balance=0, + total_spent=200, + total_requests=7, + created_at=1001, + parent_key_hash="parent", + ) + + response = await integration_client.get( + "/admin/api/temporary-balances", headers=_admin_headers() + ) + + totals = response.json()["totals"] + assert totals["total_balance"] == 5000 + assert totals["total_spent"] == 300 + assert totals["total_requests"] == 10 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_temporary_balances_search_filters_total_and_totals( + integration_client: httpx.AsyncClient, + integration_session: AsyncSession, +) -> None: + await _add_key( + integration_session, + "alpha", + balance=1000, + created_at=1000, + refund_address="alice@ln.tld", + ) + await _add_key( + integration_session, + "beta", + balance=2000, + created_at=1001, + refund_address="bob@ln.tld", + ) + + headers = _admin_headers() + + # Match by hashed_key. + by_key = ( + await integration_client.get( + "/admin/api/temporary-balances?search=alpha", headers=headers + ) + ).json() + assert by_key["total"] == 1 + assert by_key["balances"][0]["hashed_key"] == "alpha" + # totals reflect only the filtered set. + assert by_key["totals"]["total_balance"] == 1000 + + # Match by refund_address. + by_addr = ( + await integration_client.get( + "/admin/api/temporary-balances?search=bob@ln.tld", headers=headers + ) + ).json() + assert by_addr["total"] == 1 + assert by_addr["balances"][0]["hashed_key"] == "beta" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_temporary_balances_requires_admin( + integration_client: httpx.AsyncClient, +) -> None: + response = await integration_client.get("/admin/api/temporary-balances") + assert response.status_code in (401, 403) diff --git a/ui/components/temporary-balances.tsx b/ui/components/temporary-balances.tsx index 3ab53354..f306bcc8 100644 --- a/ui/components/temporary-balances.tsx +++ b/ui/components/temporary-balances.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { RefreshCw, AlertCircle, @@ -9,8 +9,10 @@ import { Clock, DollarSign, Activity, + ChevronLeft, + ChevronRight, } from 'lucide-react'; -import { AdminService, TemporaryBalance } from '@/lib/api/services/admin'; +import { AdminService } from '@/lib/api/services/admin'; import { Card, CardContent, @@ -41,52 +43,12 @@ import { import { cn } from '@/lib/utils'; import type { DisplayUnit } from '@/lib/types/units'; import { formatFromMsat } from '@/lib/currency'; +import { format } from 'date-fns'; -function getTotals(balances: TemporaryBalance[]) { - let totalBalance = 0; - let totalSpent = 0; - let totalRequests = 0; +const PAGE_SIZE = 50; - balances.forEach((balance) => { - if (!balance.parent_key_hash) { - totalBalance += balance.balance || 0; - } - totalSpent += balance.total_spent || 0; - totalRequests += balance.total_requests || 0; - }); - - return { totalBalance, totalSpent, totalRequests }; -} - -function buildHierarchicalData( - allBalances: TemporaryBalance[], - filteredBalances: TemporaryBalance[] -) { - const parents = filteredBalances.filter((item) => !item.parent_key_hash); - const result: Array = []; - - parents.forEach((parent) => { - result.push(parent); - - const children = allBalances.filter( - (item) => item.parent_key_hash === parent.hashed_key - ); - - children.forEach((child) => { - result.push({ ...child, isChild: true }); - }); - }); - - const orphans = filteredBalances.filter( - (item) => - item.parent_key_hash && - !result.some((r) => r.hashed_key === item.hashed_key) - ); - - result.push(...orphans.map((item) => ({ ...item, isChild: true }))); - - return result; -} +const formatCreatedAt = (createdAt: number | null | undefined) => + createdAt ? format(createdAt * 1000, 'yyyy-MM-dd HH:mm:ss') : '—'; export function TemporaryBalances({ refreshInterval = 10000, @@ -98,38 +60,55 @@ export function TemporaryBalances({ usdPerSat: number | null; }) { const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [page, setPage] = useState(0); + + // Debounce the search input so we don't refetch on every keystroke. + useEffect(() => { + const handle = setTimeout(() => setDebouncedSearch(searchTerm), 300); + return () => clearTimeout(handle); + }, [searchTerm]); + + // Reset to the first page whenever the active search changes. + useEffect(() => { + setPage(0); + }, [debouncedSearch]); + + const searchParam = debouncedSearch || undefined; const { data, isLoading, isError, error, isFetching, refetch } = useQuery({ - queryKey: ['temporary-balances'], - queryFn: async () => AdminService.getTemporaryBalances(), + queryKey: ['temporary-balances', searchParam, page], + queryFn: async () => + AdminService.getTemporaryBalances( + searchParam, + PAGE_SIZE, + page * PAGE_SIZE + ), refetchInterval: refreshInterval, + placeholderData: keepPreviousData, }); const formatBalance = (msat: number) => formatFromMsat(msat, displayUnit, usdPerSat); - const filteredData = data - ? data.filter( - (item) => - item.hashed_key.toLowerCase().includes(searchTerm.toLowerCase()) || - item.refund_address?.toLowerCase().includes(searchTerm.toLowerCase()) - ) - : []; - - const totals = data - ? getTotals(data) - : { totalBalance: 0, totalSpent: 0, totalRequests: 0 }; - - const rows = data ? buildHierarchicalData(data, filteredData) : []; + const rows = data?.balances ?? []; + const total = data?.total ?? 0; + const totals = data?.totals ?? { + total_balance: 0, + total_spent: 0, + total_requests: 0, + }; + const totalPages = Math.ceil(total / PAGE_SIZE); return (
- Temporary Balances + API Keys - API keys with their current balances and usage statistics + API keys with their current balances and usage statistics, newest + first
@@ -156,7 +135,7 @@ export function TemporaryBalances({ (isFetching || isLoading) && 'animate-spin' )} /> - Refresh temporary balances + Refresh API keys
@@ -190,7 +169,7 @@ export function TemporaryBalances({ - Error loading temporary balances: {(error as Error).message} + Error loading API keys: {(error as Error).message} ) : ( @@ -207,7 +186,7 @@ export function TemporaryBalances({

- {formatBalance(totals.totalBalance)} + {formatBalance(totals.total_balance)}

@@ -222,7 +201,7 @@ export function TemporaryBalances({

- {formatBalance(totals.totalSpent)} + {formatBalance(totals.total_spent)}

@@ -237,12 +216,44 @@ export function TemporaryBalances({

- {totals.totalRequests.toLocaleString()} + {totals.total_requests.toLocaleString()}

+ {totalPages > 1 && ( +
+ + {page * PAGE_SIZE + 1}– + {Math.min((page + 1) * PAGE_SIZE, total)} of {total} + +
+ + + {page + 1} / {totalPages} + + +
+
+ )} + {rows.length > 0 ? ( <>
@@ -257,6 +268,7 @@ export function TemporaryBalances({ Total Requests + Created Refund Address Expiry Time @@ -264,178 +276,192 @@ export function TemporaryBalances({ - {rows.map((balance, index) => ( - - -
- {balance.isChild && ( - - Child - - )} - {balance.hashed_key} -
-
- - {balance.isChild ? ( - - (Parent) - - ) : ( - formatBalance(balance.balance) + {rows.map((balance, index) => { + const isChild = Boolean(balance.parent_key_hash); + return ( + - - {formatBalance(balance.total_spent)} - - - {balance.total_requests.toLocaleString()} - - - {balance.refund_address || '-'} - - - {balance.key_expiry_time ? ( -
- - - {new Date( - balance.key_expiry_time * 1000 - ).toLocaleDateString()} - + > + +
+ {isChild && ( + + Child + + )} + {balance.hashed_key}
- ) : ( - '-' - )} -
- - ))} + + + {isChild ? ( + + (Parent) + + ) : ( + formatBalance(balance.balance) + )} + + + {formatBalance(balance.total_spent)} + + + {balance.total_requests.toLocaleString()} + + + {formatCreatedAt(balance.created_at)} + + + {balance.refund_address || '-'} + + + {balance.key_expiry_time ? ( +
+ + + {new Date( + balance.key_expiry_time * 1000 + ).toLocaleDateString()} + +
+ ) : ( + '-' + )} +
+ + ); + })}
- {rows.map((balance, index) => ( - - -
- - {balance.hashed_key} - - {balance.isChild && ( - - Child - - )} -
-
- -
-

- Balance -

-

- {balance.isChild - ? '(Uses Parent)' - : formatBalance(balance.balance)} -

-
-
-

Spent

-

- {formatBalance(balance.total_spent)} -

-
-
-

- Requests -

-

- {balance.total_requests.toLocaleString()} -

-
-
-

- Expires -

-

- {balance.key_expiry_time ? ( - - - {new Date( - balance.key_expiry_time * 1000 - ).toLocaleDateString()} - - ) : ( - '-' + {rows.map((balance, index) => { + const isChild = Boolean(balance.parent_key_hash); + return ( + + +

+ + {balance.hashed_key} + + {isChild && ( + + Child + )} -

-
- {balance.refund_address && ( -
+
+ + +

- Refund Address + Balance

-

- {balance.refund_address} +

+ {isChild + ? '(Uses Parent)' + : formatBalance(balance.balance)}

- )} -
- - ))} +
+

+ Spent +

+

+ {formatBalance(balance.total_spent)} +

+
+
+

+ Requests +

+

+ {balance.total_requests.toLocaleString()} +

+
+
+

+ Created +

+

+ {formatCreatedAt(balance.created_at)} +

+
+
+

+ Expires +

+

+ {balance.key_expiry_time ? ( + + + {new Date( + balance.key_expiry_time * 1000 + ).toLocaleDateString()} + + ) : ( + '-' + )} +

+
+ {balance.refund_address && ( +
+

+ Refund Address +

+

+ {balance.refund_address} +

+
+ )} + + + ); + })}
) : ( - {searchTerm ? ( + {debouncedSearch ? ( ) : ( )} - {searchTerm - ? 'No temporary balances match your search' - : 'No temporary balances found'} + {debouncedSearch + ? 'No API keys match your search' + : 'No API keys found'} - {searchTerm + {debouncedSearch ? 'Try a different key hash or refund address.' - : 'Temporary balances will appear here once API keys are used.'} + : 'API keys will appear here once they are created.'} )} - {data && data.length > 0 && ( + {total > 0 && (

- Showing {filteredData.length} of {data.length} temporary - balances + Showing {rows.length} of {total} API keys

)}
diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index 82b1c5e8..dc0a9244 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -831,9 +831,18 @@ export class AdminService { return await apiClient.get<{ dates: string[] }>('/admin/api/logs/dates'); } - static async getTemporaryBalances(): Promise { - return await apiClient.get( - '/admin/api/temporary-balances' + static async getTemporaryBalances( + search?: string, + limit: number = 50, + offset: number = 0 + ): Promise { + const params = new URLSearchParams(); + if (search) params.append('search', search); + params.append('limit', limit.toString()); + params.append('offset', offset.toString()); + + return await apiClient.get( + `/admin/api/temporary-balances?${params.toString()}` ); } @@ -1034,10 +1043,21 @@ export const TemporaryBalanceSchema = z.object({ refund_address: z.string().nullable(), key_expiry_time: z.number().nullable(), parent_key_hash: z.string().nullable().optional(), + created_at: z.number().nullable().optional(), }); export type TemporaryBalance = z.infer; +export interface TemporaryBalancesResponse { + balances: TemporaryBalance[]; + total: number; + totals: { + total_balance: number; + total_spent: number; + total_requests: number; + }; +} + export interface UsageMetricData { timestamp: string; total_requests: number;