mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-12 04:03:21 +00:00
update ui
This commit is contained in:
@@ -132,11 +132,10 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider):
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from the upstream Routstr node."""
|
||||
url = f"{self.base_url}/v1/models"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(url, headers=headers, timeout=15.0)
|
||||
response = await client.get(url, headers={}, timeout=15.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
models = data.get("data", [])
|
||||
|
||||
+23
-131
@@ -32,8 +32,6 @@ import {
|
||||
Database,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
@@ -58,10 +56,9 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { RoutstrProviderCard } from '@/components/providers/RoutstrProviderCard';
|
||||
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
||||
import { SimpleLightningTopup } from '@/components/providers/SimpleLightningTopup';
|
||||
import { SimpleCashuTopup } from '@/components/providers/SimpleCashuTopup';
|
||||
import { CashuPaymentWorkflow } from '@/components/landing/cashu-payment-workflow';
|
||||
import { LightningPaymentWorkflow } from '@/components/landing/lightning-payment-workflow';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -740,29 +737,6 @@ export default function ProvidersPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='refund_address' className='text-xs'>
|
||||
Global Refund Address (LNURL/Address)
|
||||
</Label>
|
||||
<Input
|
||||
id='refund_address'
|
||||
className='h-8 text-xs'
|
||||
placeholder='lightning@address.com'
|
||||
value={
|
||||
formData.provider_settings?.refund_address || ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
provider_settings: {
|
||||
...formData.provider_settings,
|
||||
refund_address: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -874,88 +848,17 @@ export default function ProvidersPage() {
|
||||
1.01 means +1% e.g. currency exchange, card fees, etc.
|
||||
</p>
|
||||
</div>
|
||||
{formData.provider_type === 'routstr' &&
|
||||
editingProvider && (
|
||||
<div className='bg-muted/30 mt-4 space-y-6 rounded-lg border p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-sm font-semibold'>
|
||||
Upstream Node Wallet Management
|
||||
</Label>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
External Node
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/30 mt-4 space-y-6 rounded-lg border p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-sm font-semibold'>
|
||||
Upstream Node Wallet Management
|
||||
</Label>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
External Node
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<section className='space-y-2'>
|
||||
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
|
||||
Lightning
|
||||
</Label>
|
||||
<LightningPaymentWorkflow
|
||||
baseUrl={formData.base_url || ''}
|
||||
onApiKeyCreated={(newApiKey) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
api_key: newApiKey,
|
||||
}));
|
||||
toast.success(
|
||||
'New API key created and saved'
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
|
||||
Cashu
|
||||
</Label>
|
||||
<CashuPaymentWorkflow
|
||||
baseUrl={formData.base_url || ''}
|
||||
apiKey={formData.api_key || ''}
|
||||
onApiKeyCreated={(newApiKey) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
api_key: newApiKey,
|
||||
}));
|
||||
toast.success(
|
||||
'New API key created and saved'
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
|
||||
Cashu
|
||||
</Label>
|
||||
<CashuPaymentWorkflow
|
||||
baseUrl={formData.base_url || ''}
|
||||
apiKey={formData.api_key || ''}
|
||||
onApiKeyCreated={(newApiKey) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
api_key: newApiKey,
|
||||
});
|
||||
toast.success('New API key created and saved');
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{formData.provider_type === 'routstr' && (
|
||||
<RoutstrCreateKeySection
|
||||
baseUrl={formData.base_url || ''}
|
||||
onApiKeyCreated={(newApiKey) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
api_key: newApiKey,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -1215,7 +1118,6 @@ export default function ProvidersPage() {
|
||||
}
|
||||
onEdit={() => handleEdit(provider)}
|
||||
onDelete={() => handleDelete(provider.id)}
|
||||
onUpdateKey={() => handleEdit(provider)}
|
||||
balanceComponent={
|
||||
<ProviderBalance
|
||||
providerId={provider.id}
|
||||
@@ -1536,27 +1438,6 @@ export default function ProvidersPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='edit_refund_address' className='text-xs'>
|
||||
Global Refund Address (LNURL/Address)
|
||||
</Label>
|
||||
<Input
|
||||
id='edit_refund_address'
|
||||
className='h-8 text-xs'
|
||||
placeholder='lightning@address.com'
|
||||
value={formData.provider_settings?.refund_address || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
provider_settings: {
|
||||
...formData.provider_settings,
|
||||
refund_address: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1655,6 +1536,17 @@ export default function ProvidersPage() {
|
||||
1.01 means +1% e.g. currency exchange, card fees, etc.
|
||||
</p>
|
||||
</div>
|
||||
{formData.provider_type === 'routstr' && (
|
||||
<RoutstrCreateKeySection
|
||||
baseUrl={formData.base_url || ''}
|
||||
onApiKeyCreated={(newApiKey) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
api_key: newApiKey,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface RoutstrCreateKeySectionProps {
|
||||
baseUrl: string;
|
||||
onApiKeyCreated: (apiKey: string) => void;
|
||||
}
|
||||
|
||||
async function generateQR(text: string): Promise<string> {
|
||||
try {
|
||||
return await QRCode.toDataURL(text, {
|
||||
type: 'image/png',
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: { dark: '#000000', light: '#FFFFFF' },
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function RoutstrCreateKeySection({
|
||||
baseUrl,
|
||||
onApiKeyCreated,
|
||||
}: RoutstrCreateKeySectionProps) {
|
||||
// Lightning state
|
||||
const [lnAmount, setLnAmount] = useState('');
|
||||
const [lnInvoice, setLnInvoice] = useState<{
|
||||
bolt11: string;
|
||||
invoice_id: string;
|
||||
} | null>(null);
|
||||
const [lnQrCode, setLnQrCode] = useState('');
|
||||
const [isCreatingLn, setIsCreatingLn] = useState(false);
|
||||
const [isWaitingLn, setIsWaitingLn] = useState(false);
|
||||
|
||||
// Cashu state
|
||||
const [cashuToken, setCashuToken] = useState('');
|
||||
const [isCreatingCashu, setIsCreatingCashu] = useState(false);
|
||||
|
||||
if (!baseUrl) {
|
||||
return (
|
||||
<div className='bg-muted/30 rounded-lg border p-4'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Enter the upstream node Base URL above to enable key creation.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cleanUrl = baseUrl.replace(/\/+$/, '');
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard');
|
||||
} catch {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
const pollInvoiceStatus = (invoiceId: string) => {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${cleanUrl}/v1/balance/lightning/invoice/${invoiceId}/status`
|
||||
);
|
||||
if (!resp.ok) throw new Error('Failed to check status');
|
||||
|
||||
const status = await resp.json();
|
||||
|
||||
if (status.status === 'paid' && status.api_key) {
|
||||
onApiKeyCreated(status.api_key);
|
||||
setLnInvoice(null);
|
||||
setLnQrCode('');
|
||||
setIsWaitingLn(false);
|
||||
setLnAmount('');
|
||||
toast.success('Payment received! API key created.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === 'expired' || status.status === 'cancelled') {
|
||||
toast.error('Invoice expired or cancelled');
|
||||
setIsWaitingLn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000);
|
||||
} else {
|
||||
toast.error('Payment timeout');
|
||||
setIsWaitingLn(false);
|
||||
}
|
||||
} catch {
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000);
|
||||
} else {
|
||||
setIsWaitingLn(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
};
|
||||
|
||||
const handleCreateLightning = async () => {
|
||||
const amount = parseInt(lnAmount);
|
||||
if (!amount || amount <= 0) {
|
||||
toast.error('Enter a valid amount in sats');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingLn(true);
|
||||
try {
|
||||
const resp = await fetch(`${cleanUrl}/v1/balance/lightning/invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount_sats: amount, purpose: 'create' }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errorText = await resp.text();
|
||||
throw new Error(errorText || 'Failed to create invoice');
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
setLnInvoice({ bolt11: data.bolt11, invoice_id: data.invoice_id });
|
||||
|
||||
const qr = await generateQR(data.bolt11);
|
||||
setLnQrCode(qr);
|
||||
setIsWaitingLn(true);
|
||||
|
||||
pollInvoiceStatus(data.invoice_id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to create invoice');
|
||||
} finally {
|
||||
setIsCreatingLn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCashu = async () => {
|
||||
if (!cashuToken.trim()) {
|
||||
toast.error('Paste a Cashu token');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingCashu(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
initial_balance_token: cashuToken.trim(),
|
||||
});
|
||||
const resp = await fetch(
|
||||
`${cleanUrl}/v1/balance/create?${params.toString()}`,
|
||||
{ method: 'GET', headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
const errorText = await resp.text();
|
||||
throw new Error(errorText || 'Failed to create API key');
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
onApiKeyCreated(data.api_key);
|
||||
setCashuToken('');
|
||||
toast.success('API key created');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to create key');
|
||||
} finally {
|
||||
setIsCreatingCashu(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-muted/30 space-y-4 rounded-lg border p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-sm font-semibold'>Create API Key</Label>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
External Node
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Create an API key on the upstream Routstr node by paying with Lightning
|
||||
or Cashu.
|
||||
</p>
|
||||
|
||||
<Tabs defaultValue='lightning' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='lightning' className='gap-1 text-xs'>
|
||||
<Zap className='h-3 w-3' />
|
||||
Lightning
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='cashu' className='gap-1 text-xs'>
|
||||
<KeyRound className='h-3 w-3' />
|
||||
Cashu
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='lightning' className='mt-3 space-y-3'>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='Amount in sats'
|
||||
value={lnAmount}
|
||||
onChange={(e) => setLnAmount(e.target.value)}
|
||||
className='h-9'
|
||||
disabled={isWaitingLn}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateLightning}
|
||||
disabled={isCreatingLn || isWaitingLn}
|
||||
size='sm'
|
||||
>
|
||||
{isCreatingLn ? 'Creating...' : 'Get Invoice'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{lnInvoice && (
|
||||
<div className='space-y-2 border-t pt-2'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-xs'>
|
||||
<span>Pay this invoice to create your key</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6'
|
||||
onClick={() => handleCopy(lnInvoice.bolt11)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
{lnQrCode && (
|
||||
<div className='flex justify-center py-2'>
|
||||
<Image
|
||||
src={lnQrCode}
|
||||
alt='Lightning Invoice QR Code'
|
||||
className='h-48 w-48'
|
||||
width={192}
|
||||
height={192}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='bg-muted rounded border p-2 font-mono text-[10px] break-all'>
|
||||
{lnInvoice.bolt11}
|
||||
</div>
|
||||
{isWaitingLn && (
|
||||
<div className='flex animate-pulse items-center gap-2 text-xs text-orange-600'>
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
Waiting for payment...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='cashu' className='mt-3 space-y-3'>
|
||||
<Textarea
|
||||
placeholder='Paste Cashu token (cashuA1...)'
|
||||
value={cashuToken}
|
||||
onChange={(e) => setCashuToken(e.target.value)}
|
||||
rows={3}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateCashu}
|
||||
disabled={isCreatingCashu}
|
||||
size='sm'
|
||||
className='w-full'
|
||||
>
|
||||
{isCreatingCashu ? 'Creating...' : 'Create API Key'}
|
||||
</Button>
|
||||
<p className='text-muted-foreground text-[10px]'>
|
||||
Redeems the token instantly and returns an API key.
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -9,6 +10,14 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Database,
|
||||
Pencil,
|
||||
@@ -17,10 +26,15 @@ import {
|
||||
ChevronUp,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
Key,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { UpstreamProvider } from '@/lib/api/services/admin';
|
||||
import {
|
||||
AdminService,
|
||||
UpstreamProvider,
|
||||
UpdateUpstreamProvider,
|
||||
} from '@/lib/api/services/admin';
|
||||
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
|
||||
import { RoutstrCreateKeySection } from './RoutstrCreateKeySection';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface RoutstrProviderCardProps {
|
||||
@@ -40,13 +54,14 @@ export function RoutstrProviderCard({
|
||||
onToggleExpand,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdateKey,
|
||||
balanceComponent,
|
||||
children,
|
||||
}: RoutstrProviderCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isKeyDialogOpen, setIsKeyDialogOpen] = useState(false);
|
||||
|
||||
const hasMint = !!provider.provider_settings?.topup_mint_url;
|
||||
const hasApiKey = !!provider.api_key;
|
||||
|
||||
const refundMutation = useMutation({
|
||||
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
|
||||
@@ -58,7 +73,7 @@ export function RoutstrProviderCard({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', provider.id],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['balances'] }); // Global wallet balance
|
||||
queryClient.invalidateQueries({ queryKey: ['balances'] });
|
||||
} else {
|
||||
toast.error('Refund failed', {
|
||||
description: data.message,
|
||||
@@ -70,85 +85,170 @@ export function RoutstrProviderCard({
|
||||
},
|
||||
});
|
||||
|
||||
const updateKeyMutation = useMutation({
|
||||
mutationFn: (data: { id: number; data: UpdateUpstreamProvider }) =>
|
||||
AdminService.updateUpstreamProvider(data.id, data.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['upstream-providers'] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', provider.id],
|
||||
});
|
||||
setIsKeyDialogOpen(false);
|
||||
toast.success('API key saved to provider');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to save key: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleKeyCreated = (newApiKey: string) => {
|
||||
updateKeyMutation.mutate({
|
||||
id: provider.id,
|
||||
data: { api_key: newApiKey },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col gap-4 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 text-lg'>Routstr Node</CardTitle>
|
||||
<Badge
|
||||
variant={provider.enabled ? 'default' : 'secondary'}
|
||||
className='w-fit sm:ml-2'
|
||||
>
|
||||
{provider.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
{!hasMint && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col gap-4 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 text-lg'>Routstr Node</CardTitle>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex items-center gap-1 border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-400'
|
||||
title='Top-up is not possible because no top-up mint is selected in the provider settings. Please edit settings to select a mint from your node configuration.'
|
||||
variant={provider.enabled ? 'default' : 'secondary'}
|
||||
className='w-fit sm:ml-2'
|
||||
>
|
||||
<AlertTriangle className='h-3 w-3' />
|
||||
Top-up Disabled: No Mint Selected
|
||||
{provider.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
)}
|
||||
{!hasApiKey && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex items-center gap-1 border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400'
|
||||
>
|
||||
<AlertTriangle className='h-3 w-3' />
|
||||
No API Key
|
||||
</Badge>
|
||||
)}
|
||||
{!hasMint && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex items-center gap-1 border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-400'
|
||||
title='Top-up is not possible because no top-up mint is selected in the provider settings. Please edit settings to select a mint from your node configuration.'
|
||||
>
|
||||
<AlertTriangle className='h-3 w-3' />
|
||||
Top-up Disabled: No Mint Selected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className='mt-1 break-all'>
|
||||
{provider.base_url}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<CardDescription className='mt-1 break-all'>
|
||||
{provider.base_url}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<div className='flex flex-col gap-1'>{balanceComponent}</div>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => refundMutation.mutate()}
|
||||
disabled={refundMutation.isPending}
|
||||
className='text-orange-600 hover:text-orange-700 dark:text-orange-400'
|
||||
title='Refund balance to local wallet'
|
||||
>
|
||||
<RotateCcw
|
||||
className={`mr-1 h-4 w-4 ${refundMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
<span className='hidden sm:inline'>Refund</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onToggleExpand}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Database className='mr-1 h-4 w-4' />
|
||||
<span className='hidden sm:inline'>Models</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className='ml-1 h-4 w-4' />
|
||||
) : (
|
||||
<ChevronDown className='ml-1 h-4 w-4' />
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{hasApiKey && (
|
||||
<div className='flex flex-col gap-1'>{balanceComponent}</div>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onEdit}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onDelete}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setIsKeyDialogOpen(true)}
|
||||
className='w-full sm:w-auto'
|
||||
title={
|
||||
hasApiKey
|
||||
? 'Create a new key on the upstream node'
|
||||
: 'Create an API key on the upstream node'
|
||||
}
|
||||
>
|
||||
<KeyRound className='mr-1 h-4 w-4' />
|
||||
<span className='hidden sm:inline'>
|
||||
{hasApiKey ? 'New Key' : 'Create Key'}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{hasApiKey && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => refundMutation.mutate()}
|
||||
disabled={refundMutation.isPending}
|
||||
className='text-orange-600 hover:text-orange-700 dark:text-orange-400'
|
||||
title='Refund balance to local wallet'
|
||||
>
|
||||
<RotateCcw
|
||||
className={`mr-1 h-4 w-4 ${refundMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
<span className='hidden sm:inline'>Refund</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onToggleExpand}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Database className='mr-1 h-4 w-4' />
|
||||
<span className='hidden sm:inline'>Models</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className='ml-1 h-4 w-4' />
|
||||
) : (
|
||||
<ChevronDown className='ml-1 h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onEdit}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onDelete}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{children}
|
||||
</Card>
|
||||
</CardHeader>
|
||||
{children}
|
||||
</Card>
|
||||
|
||||
<Dialog open={isKeyDialogOpen} onOpenChange={setIsKeyDialogOpen}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasApiKey ? 'Create New Key on Upstream Node' : 'Create API Key'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasApiKey
|
||||
? 'Create a new API key on the upstream node. This will replace the current key.'
|
||||
: 'Create an API key on the upstream Routstr node to enable balance, top-up, and refund operations.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RoutstrCreateKeySection
|
||||
baseUrl={provider.base_url}
|
||||
onApiKeyCreated={handleKeyCreated}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setIsKeyDialogOpen(false)}
|
||||
className='w-full'
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user