Files
routstr-core/ui/components/temporary-balances.tsx
thefux 47807ae92e Remove child key feature and balance limits completely
Removes the child-key (shared parent balance) feature and the
balance-limit machinery from the entire stack:

Backend:
- Drop POST /v1/balance/child-key and /v1/balance/child-key/reset
- Remove parent-key billing indirection (get_billing_key); every key
  is charged directly
- Remove balance_limit/balance_limit_reset enforcement, reset
  policies, and the periodic limit-reset background task
- Remove child-key guards on refund/history endpoints
- Remove child_key_cost setting and /v1/info child_key_cost_msats
- Remove balance_limit fields from LightningInvoice model and
  invoice-creation API
- Admin balances API returns plain sums (no parent/child split)

Migration (e5a6b7c8d9f0):
- Nulls parent_key_hash on children (they become standalone keys;
  parents keep 100% of their balance, so no funds are lost)
- Drops parent_key_hash + balance_limit columns from api_keys and
  balance_limit columns from lightning_invoices
- Verified: upgrade, downgrade, and fund preservation round-trip

UI:
- Remove child-key creator, child key panels, balance-limit inputs
- Key options now offer validity date only
- Temporary balances table renders all keys uniformly

Tests: child-key suites removed; remaining suites converted to
single-key semantics (441 integration + 1313 unit tests pass).
Docs updated accordingly.
2026-09-03 11:33:39 +00:00

472 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import {
RefreshCw,
AlertCircle,
Key,
Clock,
DollarSign,
Activity,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { AdminService } from '@/lib/api/services/admin';
import { getApiErrorMessage } from '@/lib/api/errors';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { cn } from '@/lib/utils';
import type { DisplayUnit } from '@/lib/types/units';
import { formatFromMsat } from '@/lib/currency';
import { format } from 'date-fns';
const PAGE_SIZE = 50;
const formatCreatedAt = (createdAt: number | null | undefined) =>
createdAt ? format(createdAt * 1000, 'yyyy-MM-dd HH:mm:ss') : '—';
export function TemporaryBalances({
refreshInterval = 10000,
displayUnit,
usdPerSat,
}: {
refreshInterval?: number;
displayUnit: DisplayUnit;
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', 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 rows = data?.balances ?? [];
const total = data?.total ?? 0;
const totals = data?.totals ?? {
total_balance: 0,
total_reserved_balance: 0,
total_available_balance: 0,
total_spent: 0,
total_requests: 0,
};
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
<Card>
<CardHeader className='pb-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1.5'>
<CardTitle>API Keys</CardTitle>
<CardDescription className='max-w-2xl'>
API keys with their current balances and usage statistics, newest
first
</CardDescription>
</div>
<div className='flex w-full gap-2 sm:w-auto sm:pt-0.5'>
<div className='min-w-0 flex-1 sm:w-72 sm:flex-none'>
<Input
type='text'
placeholder='Search by key or address...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
name='temporary_balance_search'
autoComplete='off'
/>
</div>
<Button
variant='ghost'
size='icon'
onClick={() => refetch()}
disabled={isLoading || isFetching}
>
<RefreshCw
className={cn(
'h-4 w-4',
(isFetching || isLoading) && 'animate-spin'
)}
/>
<span className='sr-only'>Refresh API keys</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className='space-y-4'>
<div className='grid gap-3 md:grid-cols-3'>
{Array.from({ length: 3 }).map((_, index) => (
<Card key={`temp-stat-skeleton-${index}`}>
<CardHeader className='space-y-2 pb-1'>
<Skeleton className='h-3.5 w-24' />
<Skeleton className='h-3 w-8' />
</CardHeader>
<CardContent className='pt-0'>
<Skeleton className='h-7 w-24' />
</CardContent>
</Card>
))}
</div>
<div className='space-y-2'>
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={`temp-row-skeleton-${index}`}
className='h-11 w-full'
/>
))}
</div>
</div>
) : isError ? (
<Alert variant='destructive'>
<AlertCircle className='h-5 w-5' />
<AlertDescription>
Error loading API keys:{' '}
{getApiErrorMessage(error, 'Request failed')}
</AlertDescription>
</Alert>
) : (
<div className='space-y-6'>
<div className='grid gap-3 md:grid-cols-3'>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Total Available
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<DollarSign className='size-4 text-green-600 dark:text-green-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatBalance(totals.total_available_balance)}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{formatBalance(totals.total_balance)} raw ·{' '}
{formatBalance(totals.total_reserved_balance)} reserved
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Total Spent
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<Activity className='size-4 text-blue-600 dark:text-blue-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatBalance(totals.total_spent)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Total Requests
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<Key className='size-4 text-purple-600 dark:text-purple-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{totals.total_requests.toLocaleString()}
</p>
</CardContent>
</Card>
</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 ? (
<>
<div className='hidden md:block'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Hashed Key</TableHead>
<TableHead className='text-right'>Available</TableHead>
<TableHead className='text-right'>
Total Spent
</TableHead>
<TableHead className='text-right'>
Total Requests
</TableHead>
<TableHead>Created</TableHead>
<TableHead>Refund Address</TableHead>
<TableHead className='text-right'>
Expiry Time
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((balance, index) => {
return (
<TableRow
key={`${balance.hashed_key}-${index}`}
className={cn(
balance.available_balance === 0 && 'opacity-60'
)}
>
<TableCell className='max-w-[16rem] font-mono text-xs break-all whitespace-normal'>
<span>{balance.hashed_key}</span>
</TableCell>
<TableCell className='text-right font-mono'>
<div>
<div>
{formatBalance(
balance.available_balance ?? balance.balance
)}
</div>
<div className='text-muted-foreground text-xs'>
{formatBalance(balance.balance)} raw
</div>
<div className='text-muted-foreground text-xs'>
{formatBalance(balance.reserved_balance)}{' '}
reserved
</div>
</div>
</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>
</Table>
</div>
<div className='space-y-2 md:hidden'>
{rows.map((balance, index) => {
return (
<Card
key={`${balance.hashed_key}-mobile-${index}`}
className={cn(
balance.available_balance === 0 && 'opacity-80'
)}
>
<CardHeader className='p-4 pb-2'>
<div className='flex items-center justify-between gap-2'>
<CardDescription className='font-mono text-xs break-all'>
{balance.hashed_key}
</CardDescription>
</div>
</CardHeader>
<CardContent className='grid grid-cols-2 gap-3 p-4 pt-0'>
<div>
<p className='text-muted-foreground text-xs'>
Available
</p>
<p className='font-mono text-sm'>
{formatBalance(
balance.available_balance ?? balance.balance
)}
</p>
<div className='text-muted-foreground text-xs'>
<p>{formatBalance(balance.balance)} raw</p>
<p>
{formatBalance(balance.reserved_balance)}{' '}
reserved
</p>
</div>
</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'>
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>
</>
) : (
<Empty className='py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
{debouncedSearch ? (
<AlertCircle className='h-4 w-4' />
) : (
<Key className='h-4 w-4' />
)}
</EmptyMedia>
<EmptyTitle>
{debouncedSearch
? 'No API keys match your search'
: 'No API keys found'}
</EmptyTitle>
<EmptyDescription>
{debouncedSearch
? 'Try a different key hash or refund address.'
: 'API keys will appear here once they are created.'}
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{total > 0 && (
<p className='text-muted-foreground text-xs'>
Showing {rows.length} of {total} API keys
</p>
)}
</div>
)}
</CardContent>
</Card>
);
}