update design

This commit is contained in:
9qeklajc
2026-02-13 23:39:02 +01:00
parent 2c358276bf
commit 6957d8c0d9
3 changed files with 476 additions and 313 deletions
+17 -19
View File
@@ -155,30 +155,28 @@ export default function DashboardPage() {
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='Select time range' />
</SelectTrigger>
<SelectContent>
<SelectItem value='1'>Last Hour</SelectItem>
<SelectItem value='6'>Last 6 Hours</SelectItem>
<SelectItem value='24'>Last 24 Hours</SelectItem>
<SelectItem value='72'>Last 3 Days</SelectItem>
<SelectItem value='168'>Last Week</SelectItem>
<SelectItem value='720'>Last Month</SelectItem>
<SelectItem value='8760'>Last Year</SelectItem>
</SelectContent>
<SelectContent>
<SelectItem value='1'>Last Hour</SelectItem>
<SelectItem value='6'>Last 6 Hours</SelectItem>
<SelectItem value='24'>Last 24 Hours</SelectItem>
<SelectItem value='72'>Last 3 Days</SelectItem>
<SelectItem value='168'>Last Week</SelectItem>
<SelectItem value='720'>Last Month</SelectItem>
<SelectItem value='8760'>Last Year</SelectItem>
</SelectContent>
</Select>
<Select value={interval} onValueChange={setInterval}>
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='Select interval' />
</SelectTrigger>
<SelectContent>
<SelectItem value='5'>5 Minutes</SelectItem>
<SelectItem value='15'>15 Minutes</SelectItem>
<SelectItem value='30'>30 Minutes</SelectItem>
<SelectItem value='60'>1 Hour</SelectItem>
<SelectItem value='1440'>1 Day</SelectItem>
<SelectItem value='10080'>1 Week</SelectItem>
</SelectContent>
<SelectContent>
<SelectItem value='5'>5 Minutes</SelectItem>
<SelectItem value='15'>15 Minutes</SelectItem>
<SelectItem value='30'>30 Minutes</SelectItem>
<SelectItem value='60'>1 Hour</SelectItem>
<SelectItem value='1440'>1 Day</SelectItem>
<SelectItem value='10080'>1 Week</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleRefresh} variant='outline' size='icon'>
<RefreshCw className='h-4 w-4' />
+314 -294
View File
@@ -54,15 +54,18 @@ import {
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RoutstrProviderCard } from '@/components/providers/RoutstrProviderCard';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
function ProviderBalance({
providerId,
platformUrl,
isRoutstr = false,
}: {
providerId: number;
platformUrl?: string | null;
isRoutstr?: boolean;
}) {
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
const [topupAmount, setTopupAmount] = useState('');
@@ -222,7 +225,9 @@ function ProviderBalance({
let displayValue = 'N/A';
if (typeof balance === 'number') {
displayValue = `$${balance.toFixed(2)}`;
displayValue = isRoutstr
? `${balance.toLocaleString()} sats`
: `$${balance.toFixed(2)}`;
} else if (balance && typeof balance === 'object') {
// Legacy support for object response
const b = balance as Record<string, unknown>;
@@ -767,7 +772,6 @@ export default function ProvidersPage() {
<Input
id='topup_threshold'
type='number'
size='sm'
className='h-9'
placeholder='e.g. 1000'
value={
@@ -798,7 +802,6 @@ export default function ProvidersPage() {
<Input
id='topup_amount_limit'
type='number'
size='sm'
className='h-9'
placeholder='e.g. 5000'
value={
@@ -1027,315 +1030,334 @@ export default function ProvidersPage() {
</Card>
) : (
<div className='grid gap-4'>
{providers.map((provider) => (
<Card key={provider.id}>
<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'>
{provider.provider_type}
</CardTitle>
<Badge
variant={
provider.enabled ? 'default' : 'secondary'
}
className='w-fit sm:ml-2'
>
{provider.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<CardDescription className='mt-1 break-all'>
{provider.base_url}
</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-2'>
{canShowBalance(provider.provider_type) &&
provider.api_key && (
<div className='flex flex-col gap-1'>
<ProviderBalance
providerId={provider.id}
platformUrl={getPlatformUrl(
provider.provider_type
)}
/>
{provider.provider_type === 'routstr' && (
<div className='text-muted-foreground font-mono text-[10px]'>
sk-{provider.api_key.substring(0, 8)}...
</div>
)}
</div>
)}
<Button
variant='outline'
size='sm'
onClick={() => toggleProviderExpansion(provider.id)}
className='w-full sm:w-auto'
>
<Database className='mr-1 h-4 w-4' />
<span className='hidden sm:inline'>Models</span>
{expandedProviders.has(provider.id) ? (
<ChevronUp className='ml-1 h-4 w-4' />
) : (
<ChevronDown className='ml-1 h-4 w-4' />
)}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handleEdit(provider)}
className='w-full sm:w-auto'
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handleDelete(provider.id)}
className='w-full sm:w-auto'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className='space-y-4'>
{providers.map((provider) => {
const modelsContent = expandedProviders.has(provider.id) && (
<div className='mt-4 border-t pt-4'>
{isLoadingModels && viewingModels === provider.id ? (
<div className='space-y-2'>
{provider.api_version && (
<div className='flex items-center justify-between text-sm'>
<span className='text-muted-foreground'>
API Version:
</span>
<span className='font-mono'>
{provider.api_version}
</span>
</div>
)}
<Skeleton className='h-[40px] w-full' />
<Skeleton className='h-[40px] w-full' />
</div>
{expandedProviders.has(provider.id) && (
<div className='mt-4 border-t pt-4'>
{isLoadingModels &&
viewingModels === provider.id ? (
<div className='space-y-2'>
<Skeleton className='h-[40px] w-full' />
<Skeleton className='h-[40px] w-full' />
</div>
) : providerModels &&
viewingModels === provider.id ? (
<Tabs
defaultValue={
providerModels.remote_models.length > 0
? 'provided'
: 'custom'
}
className='w-full'
) : providerModels && viewingModels === provider.id ? (
<Tabs
defaultValue={
providerModels.remote_models.length > 0
? 'provided'
: 'custom'
}
className='w-full'
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger
value='provided'
className='text-xs sm:text-sm'
>
<span className='hidden sm:inline'>
Provided Models
</span>
<span className='sm:hidden'>Provided</span>
<Badge
variant='secondary'
className='ml-1 text-xs sm:ml-2'
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger
value='provided'
className='text-xs sm:text-sm'
>
<span className='hidden sm:inline'>
Provided Models
</span>
<span className='sm:hidden'>Provided</span>
<Badge
variant='secondary'
className='ml-1 text-xs sm:ml-2'
>
{providerModels.remote_models.length}
</Badge>
</TabsTrigger>
<TabsTrigger
value='custom'
className='text-xs sm:text-sm'
>
<span className='hidden sm:inline'>
Custom Models
</span>
<span className='sm:hidden'>Custom</span>
<Badge
variant='secondary'
className='ml-1 text-xs sm:ml-2'
>
{providerModels.db_models.length}
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent
value='custom'
className='mt-4 space-y-2'
{providerModels.remote_models.length}
</Badge>
</TabsTrigger>
<TabsTrigger
value='custom'
className='text-xs sm:text-sm'
>
<span className='hidden sm:inline'>
Custom Models
</span>
<span className='sm:hidden'>Custom</span>
<Badge
variant='secondary'
className='ml-1 text-xs sm:ml-2'
>
{providerModels.db_models.length}
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent
value='custom'
className='mt-4 space-y-2'
>
<div className='flex items-center justify-between'>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground text-sm'>
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() =>
handleBatchOverride(provider.id)
}
>
<div className='flex items-center justify-between'>
{providerModels.db_models.length > 0 && (
<div className='text-muted-foreground text-sm'>
Custom models override or extend the
provider&apos;s catalog.
<Database className='mr-2 h-4 w-4' />
Batch Override
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handleAddModel(provider.id)}
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
</div>
</div>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No custom models configured
</div>
) : (
<div className='space-y-2'>
{providerModels.db_models.map((model) => (
<div
key={model.id}
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='truncate font-mono text-sm font-medium'>
{model.id}
</span>
<Badge
variant={
model.enabled
? 'default'
: 'secondary'
}
className='w-fit text-xs'
>
{model.enabled
? 'Enabled'
: 'Disabled'}
</Badge>
</div>
<div className='text-muted-foreground mt-1 text-xs break-words'>
{model.description || model.name}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
)}
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
handleBatchOverride(provider.id)
handleEditModel(provider.id, model)
}
>
<Database className='mr-2 h-4 w-4' />
Batch Override
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='sm'
variant='ghost'
size='icon'
className='text-destructive hover:text-destructive h-8 w-8'
onClick={() =>
handleAddModel(provider.id)
handleDeleteModel(
provider.id,
model.id
)
}
disabled={deleteModelMutation.isPending}
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No custom models configured
</div>
) : (
<div className='space-y-2'>
{providerModels.db_models.map((model) => (
<div
key={model.id}
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='truncate font-mono text-sm font-medium'>
{model.id}
</span>
<Badge
variant={
model.enabled
? 'default'
: 'secondary'
}
className='w-fit text-xs'
>
{model.enabled
? 'Enabled'
: 'Disabled'}
</Badge>
</div>
<div className='text-muted-foreground mt-1 text-xs break-words'>
{model.description || model.name}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='icon'
className='text-destructive hover:text-destructive h-8 w-8'
onClick={() =>
handleDeleteModel(
provider.id,
model.id
)
}
disabled={
deleteModelMutation.isPending
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent
value='provided'
className='mt-4 space-y-2'
))}
</div>
)}
</TabsContent>
<TabsContent
value='provided'
className='mt-4 space-y-2'
>
<div className='text-muted-foreground mb-4 text-sm'>
These models are provided directly by the upstream
service.
</div>
<div className='space-y-2'>
{providerModels.remote_models.map((model) => (
<div
key={model.id}
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
>
{providerModels.remote_models.length > 0 ? (
<>
<div className='text-muted-foreground mb-3 text-sm'>
Models automatically discovered from the
provider&apos;s catalog.
</div>
<div className='space-y-2'>
{providerModels.remote_models.map(
(model) => (
<div
key={model.id}
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
>
<div className='min-w-0 flex-1'>
<div className='truncate font-mono text-sm font-medium'>
{model.id}
</div>
<div className='text-muted-foreground mt-1 text-xs break-words'>
{model.description ||
model.name}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
handleOverrideModel(
provider.id,
model
)
}
>
<Plus className='mr-1 h-3 w-3' />
Override
</Button>
</div>
</div>
)
)}
</div>
</>
) : (
<div className='text-muted-foreground py-4 text-center text-sm'>
No provided models available
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='truncate font-mono text-sm font-medium'>
{model.id}
</span>
</div>
)}
</TabsContent>
</Tabs>
) : null}
<div className='text-muted-foreground mt-1 text-xs'>
{model.name}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-muted-foreground text-xs whitespace-nowrap'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
<Button
variant='ghost'
size='sm'
className='flex items-center gap-2'
onClick={() =>
handleOverrideModel(provider.id, model)
}
>
<Plus className='h-4 w-4' />
Override
</Button>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
) : null}
</div>
);
if (provider.provider_type === 'routstr') {
return (
<RoutstrProviderCard
key={provider.id}
provider={provider}
expanded={expandedProviders.has(provider.id)}
onToggleExpand={() =>
toggleProviderExpansion(provider.id)
}
onEdit={() => handleEdit(provider)}
onDelete={() => handleDelete(provider.id)}
balanceComponent={
<ProviderBalance
providerId={provider.id}
platformUrl={getPlatformUrl(provider.provider_type)}
isRoutstr={true}
/>
}
>
<CardContent>
<div className='space-y-4'>
<div className='space-y-2'>
{provider.api_version && (
<div className='flex items-center justify-between text-sm'>
<span className='text-muted-foreground'>
API Version:
</span>
<span className='font-mono'>
{provider.api_version}
</span>
</div>
)}
</div>
{modelsContent}
</div>
)}
</div>
</CardContent>
</Card>
))}
</CardContent>
</RoutstrProviderCard>
);
}
return (
<Card key={provider.id}>
<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'>
{provider.provider_type}
</CardTitle>
<Badge
variant={
provider.enabled ? 'default' : 'secondary'
}
className='w-fit sm:ml-2'
>
{provider.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<CardDescription className='mt-1 break-all'>
{provider.base_url}
</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-2'>
{canShowBalance(provider.provider_type) &&
provider.api_key && (
<div className='flex flex-col gap-1'>
<ProviderBalance
providerId={provider.id}
platformUrl={getPlatformUrl(
provider.provider_type
)}
/>
</div>
)}
<Button
variant='outline'
size='sm'
onClick={() =>
toggleProviderExpansion(provider.id)
}
className='w-full sm:w-auto'
>
<Database className='mr-1 h-4 w-4' />
<span className='hidden sm:inline'>Models</span>
{expandedProviders.has(provider.id) ? (
<ChevronUp className='ml-1 h-4 w-4' />
) : (
<ChevronDown className='ml-1 h-4 w-4' />
)}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handleEdit(provider)}
className='w-full sm:w-auto'
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handleDelete(provider.id)}
className='w-full sm:w-auto'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className='space-y-4'>
<div className='space-y-2'>
{provider.api_version && (
<div className='flex items-center justify-between text-sm'>
<span className='text-muted-foreground'>
API Version:
</span>
<span className='font-mono'>
{provider.api_version}
</span>
</div>
)}
</div>
{modelsContent}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
@@ -1418,7 +1440,6 @@ export default function ProvidersPage() {
<Input
id='edit_topup_threshold'
type='number'
size='sm'
className='h-9'
placeholder='e.g. 1000'
value={
@@ -1446,7 +1467,6 @@ export default function ProvidersPage() {
<Input
id='edit_topup_amount_limit'
type='number'
size='sm'
className='h-9'
placeholder='e.g. 5000'
value={
@@ -0,0 +1,145 @@
'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';
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Database,
Pencil,
Trash2,
ChevronDown,
ChevronUp,
RotateCcw,
} from 'lucide-react';
import { UpstreamProvider } from '@/lib/api/services/admin';
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
import { toast } from 'sonner';
interface RoutstrProviderCardProps {
provider: UpstreamProvider;
expanded: boolean;
onToggleExpand: () => void;
onEdit: () => void;
onDelete: () => void;
balanceComponent: React.ReactNode;
children?: React.ReactNode;
}
export function RoutstrProviderCard({
provider,
expanded,
onToggleExpand,
onEdit,
onDelete,
balanceComponent,
children,
}: RoutstrProviderCardProps) {
const queryClient = useQueryClient();
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'] }); // Global wallet balance
} 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-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>
<Badge
variant='outline'
className='bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400'
>
NIP-91
</Badge>
</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' />
)}
</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>
</CardHeader>
{children}
</Card>
);
}