mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import { AdminService } from '@/lib/api/services/admin';
|
|
import type { PPQAutoTopupState } from '@/lib/api/services/admin';
|
|
import type {
|
|
AdminModel,
|
|
ProviderModels,
|
|
UpstreamProvider,
|
|
} from '@/lib/api/services/admin';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import {
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Database,
|
|
Pencil,
|
|
Trash2,
|
|
Key,
|
|
RotateCcw,
|
|
AlertTriangle,
|
|
Unlock,
|
|
Loader2,
|
|
ShieldCheck,
|
|
} from 'lucide-react';
|
|
import { ProviderBalance } from '@/components/provider-balance';
|
|
import { ProviderModelsPanel } from '@/components/provider-models-panel';
|
|
import { ProviderCertificationDialog } from '@/components/provider-certification-dialog';
|
|
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
|
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
|
|
import { getErrorStatus } from '@/lib/api/client';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
|
|
interface ProviderCardProps {
|
|
provider: UpstreamProvider;
|
|
isExpanded: boolean;
|
|
canShowBalance: boolean;
|
|
platformUrl: string | null;
|
|
isModelsLoading: boolean;
|
|
providerModels: ProviderModels | null;
|
|
isDeletingModel: boolean;
|
|
onToggleExpansion: () => void;
|
|
onEditProvider: () => void;
|
|
onDeleteProvider: () => void;
|
|
onBatchOverride: () => void;
|
|
onAddModel: () => void;
|
|
onEditModel: (model: AdminModel) => void;
|
|
onDeleteModel: (modelId: string) => void;
|
|
onOverrideModel: (model: AdminModel) => void;
|
|
onUpdateApiKey: (newKey: string) => void;
|
|
availableMints: string[];
|
|
}
|
|
|
|
export function ProviderCard({
|
|
provider,
|
|
isExpanded,
|
|
canShowBalance,
|
|
platformUrl,
|
|
isModelsLoading,
|
|
providerModels,
|
|
isDeletingModel,
|
|
onToggleExpansion,
|
|
onEditProvider,
|
|
onDeleteProvider,
|
|
onBatchOverride,
|
|
onAddModel,
|
|
onEditModel,
|
|
onDeleteModel,
|
|
onOverrideModel,
|
|
onUpdateApiKey,
|
|
}: ProviderCardProps) {
|
|
const queryClient = useQueryClient();
|
|
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
|
|
const [isCertifyOpen, setIsCertifyOpen] = useState(false);
|
|
const [isReleaseDialogOpen, setIsReleaseDialogOpen] = useState(false);
|
|
// The claim as the query cache held it when the admin opened the dialog.
|
|
// The mutation sends this token rather than re-reading the query at submit
|
|
// time: a background refetch after the dialog opened must not swap in a
|
|
// state the admin never saw. The server rejects a stale token with a 409,
|
|
// which is the authoritative guard.
|
|
const [reviewedState, setReviewedState] = useState<PPQAutoTopupState | null>(
|
|
null
|
|
);
|
|
const hasDetails = Boolean(provider.api_version) || isExpanded;
|
|
const isRoutstr = provider.provider_type === 'routstr';
|
|
const isPPQ = provider.provider_type === 'ppqai';
|
|
|
|
const { data: ppqAutoTopupState, isError: ppqStateFetchFailed } = useQuery({
|
|
queryKey: ['ppq-auto-topup-state', provider.id],
|
|
queryFn: () => AdminService.getPPQAutoTopupState(provider.id),
|
|
enabled: isPPQ,
|
|
refetchInterval: 30000,
|
|
});
|
|
|
|
// A claim the server will not let us release: a worker is between reserving
|
|
// proofs and hearing back from the mint, and sweeping it would let the next
|
|
// cycle pay a second invoice.
|
|
const isPPQPaymentInFlight =
|
|
Boolean(ppqAutoTopupState?.active) &&
|
|
ppqAutoTopupState?.releasable === false;
|
|
|
|
const openReleaseDialog = () => {
|
|
setReviewedState(ppqAutoTopupState ?? null);
|
|
setIsReleaseDialogOpen(true);
|
|
};
|
|
|
|
const releasePPQMutation = useMutation({
|
|
mutationFn: () =>
|
|
AdminService.releasePPQAutoTopup(
|
|
provider.id,
|
|
reviewedState?.state_token ?? null
|
|
),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['ppq-auto-topup-state', provider.id],
|
|
});
|
|
setIsReleaseDialogOpen(false);
|
|
setReviewedState(null);
|
|
toast.success('PPQ auto top-up claim released');
|
|
},
|
|
onError: (error: Error) => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['ppq-auto-topup-state', provider.id],
|
|
});
|
|
if (getErrorStatus(error) === 409) {
|
|
// The claim changed since it was reviewed; the stale snapshot is
|
|
// useless, so force a fresh review.
|
|
setIsReleaseDialogOpen(false);
|
|
setReviewedState(null);
|
|
toast.error(
|
|
'PPQ claim changed since it was reviewed; reopen to see the new state'
|
|
);
|
|
return;
|
|
}
|
|
// Transient failure: keep the dialog and the reviewed snapshot so the
|
|
// admin can retry without re-navigating.
|
|
toast.error(`Failed to release PPQ claim: ${error.message}`);
|
|
},
|
|
});
|
|
|
|
const refundMutation = useMutation({
|
|
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
|
|
onSuccess: (data) => {
|
|
if (data.ok) {
|
|
toast.success('Refund successful', { description: data.message });
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['provider-balance', provider.id],
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ['balances'] });
|
|
} else {
|
|
toast.error('Refund failed', { description: data.message });
|
|
}
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(`Refund error: ${error.message}`);
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
|
<div className='min-w-0 flex-1'>
|
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
|
|
<CardTitle className='truncate'>
|
|
{provider.provider_type}
|
|
</CardTitle>
|
|
<Badge
|
|
variant={provider.enabled ? 'default' : 'secondary'}
|
|
className='w-fit'
|
|
>
|
|
{provider.enabled ? 'Enabled' : 'Disabled'}
|
|
</Badge>
|
|
{ppqAutoTopupState?.active && (
|
|
<Badge
|
|
variant='outline'
|
|
className={cn(
|
|
'w-fit gap-1',
|
|
isPPQPaymentInFlight
|
|
? 'border-blue-500 text-blue-700 dark:text-blue-400'
|
|
: 'border-amber-500 text-amber-700 dark:text-amber-400'
|
|
)}
|
|
>
|
|
{isPPQPaymentInFlight ? (
|
|
<Loader2 className='h-3 w-3 animate-spin' />
|
|
) : (
|
|
<AlertTriangle className='h-3 w-3' />
|
|
)}
|
|
{isPPQPaymentInFlight
|
|
? 'Paying invoice'
|
|
: 'Auto top-up needs review'}
|
|
</Badge>
|
|
)}
|
|
{isPPQ && ppqStateFetchFailed && (
|
|
<Badge
|
|
variant='outline'
|
|
className='border-destructive text-destructive w-fit gap-1'
|
|
>
|
|
<AlertTriangle className='h-3 w-3' />
|
|
Top-up status unavailable
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<CardDescription className='break-all'>
|
|
{provider.base_url}
|
|
</CardDescription>
|
|
</div>
|
|
|
|
<div className='grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap sm:items-center sm:justify-end'>
|
|
{canShowBalance && provider.api_key && (
|
|
<div
|
|
className={cn(
|
|
'col-span-2 sm:col-auto',
|
|
provider.provider_type === 'routstr' && 'col-span-1'
|
|
)}
|
|
>
|
|
<ProviderBalance
|
|
providerId={provider.id}
|
|
platformUrl={platformUrl}
|
|
isRoutstr={provider.provider_type === 'routstr'}
|
|
nodeUrl={provider.base_url}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{isRoutstr && (
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => setIsKeyModalOpen(true)}
|
|
className={cn(
|
|
'justify-center gap-1.5',
|
|
canShowBalance && provider.api_key
|
|
? 'col-span-1'
|
|
: 'col-span-2 sm:col-auto'
|
|
)}
|
|
>
|
|
<Key className='h-4 w-4' />
|
|
<span>New Key</span>
|
|
</Button>
|
|
)}
|
|
|
|
{isPPQ && ppqAutoTopupState?.active && !isPPQPaymentInFlight && (
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={openReleaseDialog}
|
|
className='justify-center gap-1.5 border-amber-500 text-amber-700 dark:text-amber-400'
|
|
title='Release only after manually verifying the Lightning payment outcome'
|
|
>
|
|
<Unlock className='h-4 w-4' />
|
|
<span>Release top-up</span>
|
|
</Button>
|
|
)}
|
|
|
|
{isRoutstr && provider.api_key && (
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => refundMutation.mutate()}
|
|
disabled={refundMutation.isPending}
|
|
className='justify-center gap-1.5 text-orange-600 hover:text-orange-700 dark:text-orange-400'
|
|
title='Refund balance to local wallet'
|
|
>
|
|
<RotateCcw
|
|
className={cn(
|
|
'h-4 w-4',
|
|
refundMutation.isPending && 'animate-spin'
|
|
)}
|
|
/>
|
|
<span>Refund</span>
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={onToggleExpansion}
|
|
className='col-span-2 justify-between sm:col-auto sm:justify-center'
|
|
>
|
|
<span className='inline-flex items-center gap-1.5'>
|
|
<Database className='h-4 w-4' />
|
|
<span>Models</span>
|
|
</span>
|
|
{isExpanded ? (
|
|
<ChevronUp className='h-4 w-4' />
|
|
) : (
|
|
<ChevronDown className='h-4 w-4' />
|
|
)}
|
|
</Button>
|
|
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => setIsCertifyOpen(true)}
|
|
className='justify-center gap-1.5'
|
|
title='Probe the upstream and verify usage, pricing, caching and margin'
|
|
>
|
|
<ShieldCheck className='h-4 w-4' />
|
|
<span>Certify</span>
|
|
</Button>
|
|
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={onEditProvider}
|
|
className='justify-center gap-1.5'
|
|
>
|
|
<Pencil className='h-4 w-4' />
|
|
<span>Edit</span>
|
|
</Button>
|
|
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={onDeleteProvider}
|
|
className='text-destructive hover:text-destructive justify-center gap-1.5'
|
|
>
|
|
<Trash2 className='h-4 w-4' />
|
|
<span>Delete</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<AlertDialog
|
|
open={isReleaseDialogOpen}
|
|
onOpenChange={setIsReleaseDialogOpen}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Release PPQ auto top-up?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Only continue after checking PPQ and the Cashu mint and confirming
|
|
the previous Lightning payment cannot later settle. Releasing an
|
|
ambiguous payment can allow a duplicate top-up.
|
|
{reviewedState?.invoice_id
|
|
? ` Invoice: ${reviewedState.invoice_id}`
|
|
: ''}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={(e) => {
|
|
// Radix closes the dialog on action click by default; the
|
|
// mutation handlers decide whether it closes (kept open on
|
|
// transient errors so the admin can retry).
|
|
e.preventDefault();
|
|
releasePPQMutation.mutate();
|
|
}}
|
|
disabled={releasePPQMutation.isPending}
|
|
>
|
|
{releasePPQMutation.isPending
|
|
? 'Releasing...'
|
|
: 'I verified it is safe to retry'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<ProviderCertificationDialog
|
|
provider={provider}
|
|
open={isCertifyOpen}
|
|
onOpenChange={setIsCertifyOpen}
|
|
/>
|
|
|
|
<Dialog open={isKeyModalOpen} onOpenChange={setIsKeyModalOpen}>
|
|
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[500px]'>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{provider.api_key
|
|
? 'Create New Key on Upstream Node'
|
|
: 'Create API Key'}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{provider.api_key
|
|
? 'Create a new API key on the upstream node. The remaining balance on the current key will be automatically refunded to your local wallet before it is replaced.'
|
|
: 'Create an API key on the upstream Routstr node to enable balance, top-up, and refund operations.'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className='py-4'>
|
|
<RoutstrCreateKeySection
|
|
baseUrl={provider.base_url || ''}
|
|
onApiKeyCreated={async (newApiKey) => {
|
|
if (provider.api_key) {
|
|
try {
|
|
const result = await RoutstrProviderService.refundBalance(
|
|
provider.id
|
|
);
|
|
if (result.ok) {
|
|
toast.success('Old key refunded', {
|
|
description: result.message,
|
|
});
|
|
} else {
|
|
toast.warning('Refund skipped', {
|
|
description: result.message,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast.warning(
|
|
`Could not refund old key: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
onUpdateApiKey(newApiKey);
|
|
setIsKeyModalOpen(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{hasDetails ? (
|
|
<CardContent>
|
|
<div className='space-y-3'>
|
|
{provider.api_version && (
|
|
<div className='flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between'>
|
|
<span className='text-muted-foreground'>API Version:</span>
|
|
<span className='font-mono break-all'>
|
|
{provider.api_version}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{isExpanded && (
|
|
<div className='mt-3 border-t pt-3'>
|
|
<ProviderModelsPanel
|
|
isLoading={isModelsLoading}
|
|
providerModels={providerModels}
|
|
onBatchOverride={onBatchOverride}
|
|
onAddModel={onAddModel}
|
|
onEditModel={onEditModel}
|
|
onDeleteModel={onDeleteModel}
|
|
onOverrideModel={onOverrideModel}
|
|
isDeletingModel={isDeletingModel}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
}
|