mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
add api key creation date
This commit is contained in:
@@ -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")
|
||||||
+80
-17
@@ -68,26 +68,89 @@ async def require_admin_api(request: Request) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
|
@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:
|
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()
|
api_keys = result.all()
|
||||||
|
|
||||||
return [
|
return {
|
||||||
{
|
"balances": [
|
||||||
"hashed_key": key.hashed_key,
|
{
|
||||||
"balance": key.balance,
|
"hashed_key": key.hashed_key,
|
||||||
"total_spent": key.total_spent,
|
"balance": key.balance,
|
||||||
"total_requests": key.total_requests,
|
"total_spent": key.total_spent,
|
||||||
"refund_address": key.refund_address,
|
"total_requests": key.total_requests,
|
||||||
"key_expiry_time": key.key_expiry_time,
|
"refund_address": key.refund_address,
|
||||||
"parent_key_hash": key.parent_key_hash,
|
"key_expiry_time": key.key_expiry_time,
|
||||||
"balance_limit": key.balance_limit,
|
"parent_key_hash": key.parent_key_hash,
|
||||||
"balance_limit_reset": key.balance_limit_reset,
|
"balance_limit": key.balance_limit,
|
||||||
"validity_date": key.validity_date,
|
"balance_limit_reset": key.balance_limit_reset,
|
||||||
}
|
"validity_date": key.validity_date,
|
||||||
for key in api_keys
|
"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):
|
class ApiKeyUpdate(BaseModel):
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
|||||||
default=0, description="Total spent in millisatoshis (msats)"
|
default=0, description="Total spent in millisatoshis (msats)"
|
||||||
)
|
)
|
||||||
total_requests: int = Field(default=0)
|
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(
|
refund_mint_url: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="URL of the mint used to create the cashu-token",
|
description="URL of the mint used to create the cashu-token",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Activity,
|
Activity,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AdminService, TemporaryBalance } from '@/lib/api/services/admin';
|
import { AdminService } from '@/lib/api/services/admin';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -41,52 +43,12 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { DisplayUnit } from '@/lib/types/units';
|
import type { DisplayUnit } from '@/lib/types/units';
|
||||||
import { formatFromMsat } from '@/lib/currency';
|
import { formatFromMsat } from '@/lib/currency';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
function getTotals(balances: TemporaryBalance[]) {
|
const PAGE_SIZE = 50;
|
||||||
let totalBalance = 0;
|
|
||||||
let totalSpent = 0;
|
|
||||||
let totalRequests = 0;
|
|
||||||
|
|
||||||
balances.forEach((balance) => {
|
const formatCreatedAt = (createdAt: number | null | undefined) =>
|
||||||
if (!balance.parent_key_hash) {
|
createdAt ? format(createdAt * 1000, 'yyyy-MM-dd HH:mm:ss') : '—';
|
||||||
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<TemporaryBalance & { isChild?: boolean }> = [];
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemporaryBalances({
|
export function TemporaryBalances({
|
||||||
refreshInterval = 10000,
|
refreshInterval = 10000,
|
||||||
@@ -98,38 +60,55 @@ export function TemporaryBalances({
|
|||||||
usdPerSat: number | null;
|
usdPerSat: number | null;
|
||||||
}) {
|
}) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
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({
|
const { data, isLoading, isError, error, isFetching, refetch } = useQuery({
|
||||||
queryKey: ['temporary-balances'],
|
queryKey: ['temporary-balances', searchParam, page],
|
||||||
queryFn: async () => AdminService.getTemporaryBalances(),
|
queryFn: async () =>
|
||||||
|
AdminService.getTemporaryBalances(
|
||||||
|
searchParam,
|
||||||
|
PAGE_SIZE,
|
||||||
|
page * PAGE_SIZE
|
||||||
|
),
|
||||||
refetchInterval: refreshInterval,
|
refetchInterval: refreshInterval,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatBalance = (msat: number) =>
|
const formatBalance = (msat: number) =>
|
||||||
formatFromMsat(msat, displayUnit, usdPerSat);
|
formatFromMsat(msat, displayUnit, usdPerSat);
|
||||||
|
|
||||||
const filteredData = data
|
const rows = data?.balances ?? [];
|
||||||
? data.filter(
|
const total = data?.total ?? 0;
|
||||||
(item) =>
|
const totals = data?.totals ?? {
|
||||||
item.hashed_key.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
total_balance: 0,
|
||||||
item.refund_address?.toLowerCase().includes(searchTerm.toLowerCase())
|
total_spent: 0,
|
||||||
)
|
total_requests: 0,
|
||||||
: [];
|
};
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
const totals = data
|
|
||||||
? getTotals(data)
|
|
||||||
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
|
|
||||||
|
|
||||||
const rows = data ? buildHierarchicalData(data, filteredData) : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className='pb-4'>
|
<CardHeader className='pb-4'>
|
||||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||||
<div className='space-y-1.5'>
|
<div className='space-y-1.5'>
|
||||||
<CardTitle>Temporary Balances</CardTitle>
|
<CardTitle>API Keys</CardTitle>
|
||||||
<CardDescription className='max-w-2xl'>
|
<CardDescription className='max-w-2xl'>
|
||||||
API keys with their current balances and usage statistics
|
API keys with their current balances and usage statistics, newest
|
||||||
|
first
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -156,7 +135,7 @@ export function TemporaryBalances({
|
|||||||
(isFetching || isLoading) && 'animate-spin'
|
(isFetching || isLoading) && 'animate-spin'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className='sr-only'>Refresh temporary balances</span>
|
<span className='sr-only'>Refresh API keys</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +169,7 @@ export function TemporaryBalances({
|
|||||||
<Alert variant='destructive'>
|
<Alert variant='destructive'>
|
||||||
<AlertCircle className='h-5 w-5' />
|
<AlertCircle className='h-5 w-5' />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Error loading temporary balances: {(error as Error).message}
|
Error loading API keys: {(error as Error).message}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
@@ -207,7 +186,7 @@ export function TemporaryBalances({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='pt-0'>
|
<CardContent className='pt-0'>
|
||||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||||
{formatBalance(totals.totalBalance)}
|
{formatBalance(totals.total_balance)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -222,7 +201,7 @@ export function TemporaryBalances({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='pt-0'>
|
<CardContent className='pt-0'>
|
||||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||||
{formatBalance(totals.totalSpent)}
|
{formatBalance(totals.total_spent)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -237,12 +216,44 @@ export function TemporaryBalances({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='pt-0'>
|
<CardContent className='pt-0'>
|
||||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||||
{totals.totalRequests.toLocaleString()}
|
{totals.total_requests.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className='flex flex-col gap-2 border-b pb-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
|
<span className='text-muted-foreground text-xs sm:text-sm'>
|
||||||
|
{page * PAGE_SIZE + 1}–
|
||||||
|
{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
||||||
|
</span>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className='h-4 w-4' />
|
||||||
|
<span className='hidden sm:inline'>Previous</span>
|
||||||
|
</Button>
|
||||||
|
<span className='text-xs sm:text-sm'>
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
>
|
||||||
|
<span className='hidden sm:inline'>Next</span>
|
||||||
|
<ChevronRight className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{rows.length > 0 ? (
|
{rows.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className='hidden md:block'>
|
<div className='hidden md:block'>
|
||||||
@@ -257,6 +268,7 @@ export function TemporaryBalances({
|
|||||||
<TableHead className='text-right'>
|
<TableHead className='text-right'>
|
||||||
Total Requests
|
Total Requests
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
<TableHead>Refund Address</TableHead>
|
<TableHead>Refund Address</TableHead>
|
||||||
<TableHead className='text-right'>
|
<TableHead className='text-right'>
|
||||||
Expiry Time
|
Expiry Time
|
||||||
@@ -264,178 +276,192 @@ export function TemporaryBalances({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((balance, index) => (
|
{rows.map((balance, index) => {
|
||||||
<TableRow
|
const isChild = Boolean(balance.parent_key_hash);
|
||||||
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-${index}`}
|
return (
|
||||||
className={cn(
|
<TableRow
|
||||||
balance.balance === 0 &&
|
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-${index}`}
|
||||||
!balance.isChild &&
|
className={cn(
|
||||||
'opacity-60',
|
balance.balance === 0 && !isChild && 'opacity-60',
|
||||||
balance.isChild && 'bg-muted/30'
|
isChild && 'bg-muted/30'
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TableCell className='max-w-[16rem] font-mono text-xs break-all whitespace-normal'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
{balance.isChild && (
|
|
||||||
<Badge
|
|
||||||
variant='outline'
|
|
||||||
className='h-4 px-1 text-[10px] uppercase'
|
|
||||||
>
|
|
||||||
Child
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<span>{balance.hashed_key}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-right font-mono'>
|
|
||||||
{balance.isChild ? (
|
|
||||||
<span className='text-muted-foreground italic'>
|
|
||||||
(Parent)
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
formatBalance(balance.balance)
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
>
|
||||||
<TableCell className='text-right font-mono'>
|
<TableCell className='max-w-[16rem] font-mono text-xs break-all whitespace-normal'>
|
||||||
{formatBalance(balance.total_spent)}
|
<div className='flex items-center gap-2'>
|
||||||
</TableCell>
|
{isChild && (
|
||||||
<TableCell className='text-right font-mono'>
|
<Badge
|
||||||
{balance.total_requests.toLocaleString()}
|
variant='outline'
|
||||||
</TableCell>
|
className='h-4 px-1 text-[10px] uppercase'
|
||||||
<TableCell className='max-w-[14rem] font-mono text-xs break-all whitespace-normal'>
|
>
|
||||||
{balance.refund_address || '-'}
|
Child
|
||||||
</TableCell>
|
</Badge>
|
||||||
<TableCell className='text-right font-mono text-xs'>
|
)}
|
||||||
{balance.key_expiry_time ? (
|
<span>{balance.hashed_key}</span>
|
||||||
<div className='inline-flex items-center justify-end gap-1'>
|
|
||||||
<Clock className='h-3 w-3' />
|
|
||||||
<span>
|
|
||||||
{new Date(
|
|
||||||
balance.key_expiry_time * 1000
|
|
||||||
).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</TableCell>
|
||||||
'-'
|
<TableCell className='text-right font-mono'>
|
||||||
)}
|
{isChild ? (
|
||||||
</TableCell>
|
<span className='text-muted-foreground italic'>
|
||||||
</TableRow>
|
(Parent)
|
||||||
))}
|
</span>
|
||||||
|
) : (
|
||||||
|
formatBalance(balance.balance)
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='text-right font-mono'>
|
||||||
|
{formatBalance(balance.total_spent)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='text-right font-mono'>
|
||||||
|
{balance.total_requests.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='font-mono text-xs whitespace-nowrap'>
|
||||||
|
{formatCreatedAt(balance.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='max-w-[14rem] font-mono text-xs break-all whitespace-normal'>
|
||||||
|
{balance.refund_address || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='text-right font-mono text-xs'>
|
||||||
|
{balance.key_expiry_time ? (
|
||||||
|
<div className='inline-flex items-center justify-end gap-1'>
|
||||||
|
<Clock className='h-3 w-3' />
|
||||||
|
<span>
|
||||||
|
{new Date(
|
||||||
|
balance.key_expiry_time * 1000
|
||||||
|
).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2 md:hidden'>
|
<div className='space-y-2 md:hidden'>
|
||||||
{rows.map((balance, index) => (
|
{rows.map((balance, index) => {
|
||||||
<Card
|
const isChild = Boolean(balance.parent_key_hash);
|
||||||
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-mobile-${index}`}
|
return (
|
||||||
className={cn(
|
<Card
|
||||||
balance.balance === 0 &&
|
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-mobile-${index}`}
|
||||||
!balance.isChild &&
|
className={cn(
|
||||||
'opacity-80',
|
balance.balance === 0 && !isChild && 'opacity-80',
|
||||||
balance.isChild && 'bg-muted/30'
|
isChild && 'bg-muted/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className='p-4 pb-2'>
|
<CardHeader className='p-4 pb-2'>
|
||||||
<div className='flex items-center justify-between gap-2'>
|
<div className='flex items-center justify-between gap-2'>
|
||||||
<CardDescription className='font-mono text-xs break-all'>
|
<CardDescription className='font-mono text-xs break-all'>
|
||||||
{balance.hashed_key}
|
{balance.hashed_key}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
{balance.isChild && (
|
{isChild && (
|
||||||
<Badge
|
<Badge
|
||||||
variant='outline'
|
variant='outline'
|
||||||
className='h-4 px-1.5 text-[10px] uppercase'
|
className='h-4 px-1.5 text-[10px] uppercase'
|
||||||
>
|
>
|
||||||
Child
|
Child
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className='grid grid-cols-2 gap-3 p-4 pt-0'>
|
|
||||||
<div>
|
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
Balance
|
|
||||||
</p>
|
|
||||||
<p className='font-mono text-sm'>
|
|
||||||
{balance.isChild
|
|
||||||
? '(Uses Parent)'
|
|
||||||
: formatBalance(balance.balance)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className='text-muted-foreground text-xs'>Spent</p>
|
|
||||||
<p className='font-mono text-sm'>
|
|
||||||
{formatBalance(balance.total_spent)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
Requests
|
|
||||||
</p>
|
|
||||||
<p className='font-mono text-sm'>
|
|
||||||
{balance.total_requests.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
Expires
|
|
||||||
</p>
|
|
||||||
<p className='font-mono text-xs'>
|
|
||||||
{balance.key_expiry_time ? (
|
|
||||||
<span className='inline-flex items-center gap-1'>
|
|
||||||
<Clock className='h-3 w-3' />
|
|
||||||
{new Date(
|
|
||||||
balance.key_expiry_time * 1000
|
|
||||||
).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
{balance.refund_address && (
|
<CardContent className='grid grid-cols-2 gap-3 p-4 pt-0'>
|
||||||
<div className='col-span-2'>
|
<div>
|
||||||
<p className='text-muted-foreground text-xs'>
|
<p className='text-muted-foreground text-xs'>
|
||||||
Refund Address
|
Balance
|
||||||
</p>
|
</p>
|
||||||
<p className='font-mono text-xs break-all'>
|
<p className='font-mono text-sm'>
|
||||||
{balance.refund_address}
|
{isChild
|
||||||
|
? '(Uses Parent)'
|
||||||
|
: formatBalance(balance.balance)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
</CardContent>
|
<p className='text-muted-foreground text-xs'>
|
||||||
</Card>
|
Spent
|
||||||
))}
|
</p>
|
||||||
|
<p className='font-mono text-sm'>
|
||||||
|
{formatBalance(balance.total_spent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Requests
|
||||||
|
</p>
|
||||||
|
<p className='font-mono text-sm'>
|
||||||
|
{balance.total_requests.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Created
|
||||||
|
</p>
|
||||||
|
<p className='font-mono text-xs'>
|
||||||
|
{formatCreatedAt(balance.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Expires
|
||||||
|
</p>
|
||||||
|
<p className='font-mono text-xs'>
|
||||||
|
{balance.key_expiry_time ? (
|
||||||
|
<span className='inline-flex items-center gap-1'>
|
||||||
|
<Clock className='h-3 w-3' />
|
||||||
|
{new Date(
|
||||||
|
balance.key_expiry_time * 1000
|
||||||
|
).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{balance.refund_address && (
|
||||||
|
<div className='col-span-2'>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Refund Address
|
||||||
|
</p>
|
||||||
|
<p className='font-mono text-xs break-all'>
|
||||||
|
{balance.refund_address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Empty className='py-8'>
|
<Empty className='py-8'>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant='icon'>
|
<EmptyMedia variant='icon'>
|
||||||
{searchTerm ? (
|
{debouncedSearch ? (
|
||||||
<AlertCircle className='h-4 w-4' />
|
<AlertCircle className='h-4 w-4' />
|
||||||
) : (
|
) : (
|
||||||
<Key className='h-4 w-4' />
|
<Key className='h-4 w-4' />
|
||||||
)}
|
)}
|
||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>
|
<EmptyTitle>
|
||||||
{searchTerm
|
{debouncedSearch
|
||||||
? 'No temporary balances match your search'
|
? 'No API keys match your search'
|
||||||
: 'No temporary balances found'}
|
: 'No API keys found'}
|
||||||
</EmptyTitle>
|
</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription>
|
||||||
{searchTerm
|
{debouncedSearch
|
||||||
? 'Try a different key hash or refund address.'
|
? '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.'}
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data && data.length > 0 && (
|
{total > 0 && (
|
||||||
<p className='text-muted-foreground text-xs'>
|
<p className='text-muted-foreground text-xs'>
|
||||||
Showing {filteredData.length} of {data.length} temporary
|
Showing {rows.length} of {total} API keys
|
||||||
balances
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -831,9 +831,18 @@ export class AdminService {
|
|||||||
return await apiClient.get<{ dates: string[] }>('/admin/api/logs/dates');
|
return await apiClient.get<{ dates: string[] }>('/admin/api/logs/dates');
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getTemporaryBalances(): Promise<TemporaryBalance[]> {
|
static async getTemporaryBalances(
|
||||||
return await apiClient.get<TemporaryBalance[]>(
|
search?: string,
|
||||||
'/admin/api/temporary-balances'
|
limit: number = 50,
|
||||||
|
offset: number = 0
|
||||||
|
): Promise<TemporaryBalancesResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.append('search', search);
|
||||||
|
params.append('limit', limit.toString());
|
||||||
|
params.append('offset', offset.toString());
|
||||||
|
|
||||||
|
return await apiClient.get<TemporaryBalancesResponse>(
|
||||||
|
`/admin/api/temporary-balances?${params.toString()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,10 +1043,21 @@ export const TemporaryBalanceSchema = z.object({
|
|||||||
refund_address: z.string().nullable(),
|
refund_address: z.string().nullable(),
|
||||||
key_expiry_time: z.number().nullable(),
|
key_expiry_time: z.number().nullable(),
|
||||||
parent_key_hash: z.string().nullable().optional(),
|
parent_key_hash: z.string().nullable().optional(),
|
||||||
|
created_at: z.number().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
||||||
|
|
||||||
|
export interface TemporaryBalancesResponse {
|
||||||
|
balances: TemporaryBalance[];
|
||||||
|
total: number;
|
||||||
|
totals: {
|
||||||
|
total_balance: number;
|
||||||
|
total_spent: number;
|
||||||
|
total_requests: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsageMetricData {
|
export interface UsageMetricData {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
total_requests: number;
|
total_requests: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user