mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-30 15:48:37 +00:00
447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import {
|
|
RefreshCw,
|
|
AlertCircle,
|
|
Key,
|
|
Clock,
|
|
DollarSign,
|
|
Activity,
|
|
} from 'lucide-react';
|
|
import { AdminService, TemporaryBalance } from '@/lib/api/services/admin';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
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';
|
|
|
|
function getTotals(balances: TemporaryBalance[]) {
|
|
let totalBalance = 0;
|
|
let totalSpent = 0;
|
|
let totalRequests = 0;
|
|
|
|
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<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({
|
|
refreshInterval = 10000,
|
|
displayUnit,
|
|
usdPerSat,
|
|
}: {
|
|
refreshInterval?: number;
|
|
displayUnit: DisplayUnit;
|
|
usdPerSat: number | null;
|
|
}) {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
const { data, isLoading, isError, error, isFetching, refetch } = useQuery({
|
|
queryKey: ['temporary-balances'],
|
|
queryFn: async () => AdminService.getTemporaryBalances(),
|
|
refetchInterval: refreshInterval,
|
|
});
|
|
|
|
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) : [];
|
|
|
|
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>Temporary Balances</CardTitle>
|
|
<CardDescription className='max-w-2xl'>
|
|
API keys with their current balances and usage statistics
|
|
</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 temporary balances</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 temporary balances: {(error as Error).message}
|
|
</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 Balance
|
|
</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.totalBalance)}
|
|
</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.totalSpent)}
|
|
</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.totalRequests.toLocaleString()}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{rows.length > 0 ? (
|
|
<>
|
|
<div className='hidden md:block'>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Hashed Key</TableHead>
|
|
<TableHead className='text-right'>Balance</TableHead>
|
|
<TableHead className='text-right'>
|
|
Total Spent
|
|
</TableHead>
|
|
<TableHead className='text-right'>
|
|
Total Requests
|
|
</TableHead>
|
|
<TableHead>Refund Address</TableHead>
|
|
<TableHead className='text-right'>
|
|
Expiry Time
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rows.map((balance, index) => (
|
|
<TableRow
|
|
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-${index}`}
|
|
className={cn(
|
|
balance.balance === 0 &&
|
|
!balance.isChild &&
|
|
'opacity-60',
|
|
balance.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'>
|
|
{formatBalance(balance.total_spent)}
|
|
</TableCell>
|
|
<TableCell className='text-right font-mono'>
|
|
{balance.total_requests.toLocaleString()}
|
|
</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) => (
|
|
<Card
|
|
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-mobile-${index}`}
|
|
className={cn(
|
|
balance.balance === 0 &&
|
|
!balance.isChild &&
|
|
'opacity-80',
|
|
balance.isChild && 'bg-muted/30'
|
|
)}
|
|
>
|
|
<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>
|
|
{balance.isChild && (
|
|
<Badge
|
|
variant='outline'
|
|
className='h-4 px-1.5 text-[10px] uppercase'
|
|
>
|
|
Child
|
|
</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>
|
|
{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'>
|
|
{searchTerm ? (
|
|
<AlertCircle className='h-4 w-4' />
|
|
) : (
|
|
<Key className='h-4 w-4' />
|
|
)}
|
|
</EmptyMedia>
|
|
<EmptyTitle>
|
|
{searchTerm
|
|
? 'No temporary balances match your search'
|
|
: 'No temporary balances found'}
|
|
</EmptyTitle>
|
|
<EmptyDescription>
|
|
{searchTerm
|
|
? 'Try a different key hash or refund address.'
|
|
: 'Temporary balances will appear here once API keys are used.'}
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
)}
|
|
|
|
{data && data.length > 0 && (
|
|
<p className='text-muted-foreground text-xs'>
|
|
Showing {filteredData.length} of {data.length} temporary
|
|
balances
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|