Compare commits

...

5 Commits

Author SHA1 Message Date
Shroominic
b8c34afef8 improve logging 2025-12-11 10:50:50 +08:00
shroominic
2aee75e7d5 Merge pull request #255 from Routstr/rm-shaky
Rm shaky test
2025-12-11 10:12:33 +08:00
Shroominic
4d67af51ab rm shaky test 2025-12-11 10:08:45 +08:00
shroominic
2f841dfcbc Merge pull request #254 from Routstr/format
make format required & fix ui formating
2025-12-11 10:01:38 +08:00
9qeklajc
7ca69063bd make format required & fix ui formating 2025-12-10 22:51:12 +01:00
27 changed files with 394 additions and 312 deletions

View File

@@ -62,14 +62,18 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
node-version: "18"
cache: "npm"
cache-dependency-path: ui/package-lock.json
- name: Install UI dependencies
working-directory: ./ui
run: npm ci
- name: Run UI format check
working-directory: ./ui
run: npm run format-check
- name: Run UI linting
working-directory: ./ui
run: npm run lint

View File

@@ -150,18 +150,6 @@ def should_prefer_model(
# Prefer lower adjusted cost
should_replace = candidate_adjusted < current_adjusted
# Log provider changes when candidate wins
if should_replace:
candidate_provider_name = getattr(
candidate_provider, "provider_type", "unknown"
)
current_provider_name = getattr(current_provider, "provider_type", "unknown")
logger.debug(
f"Model selection for alias '{alias}': choosing {candidate_provider_name} "
f"(cost: ${candidate_adjusted:.6f}) over {current_provider_name} "
f"(cost: ${current_adjusted:.6f})"
)
return should_replace

View File

@@ -126,8 +126,6 @@ def run_migrations() -> None:
import pathlib
try:
logger.info("Starting database migrations")
# Get the path to the alembic.ini file
project_root = pathlib.Path(__file__).resolve().parents[2]
alembic_ini_path = project_root / "alembic.ini"
@@ -144,7 +142,6 @@ def run_migrations() -> None:
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Run migrations to the latest revision
logger.info("Running migrations to latest revision")
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully")

View File

@@ -338,6 +338,11 @@ def setup_logging() -> None:
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"openai": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"httpcore": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
@@ -360,6 +365,11 @@ def setup_logging() -> None:
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
"alembic": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
},
"root": {
"level": log_level,

View File

@@ -54,9 +54,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
try:
# Run database migrations on startup
# This ensures the database schema is always up-to-date in production
# Migrations are idempotent - running them multiple times is safe
logger.info("Running database migrations")
run_migrations()
# Initialize database connection pools
@@ -104,6 +101,9 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
yield
except asyncio.CancelledError:
# Expected during shutdown
pass
except Exception as e:
logger.error(
"Application startup failed",

View File

@@ -110,10 +110,6 @@ async def _update_prices() -> None:
return
BTC_USD_PRICE = btc_price
SATS_USD_PRICE = btc_price / 100_000_000
logger.info(
"Updated BTC/USD price",
extra={"btc_usd": btc_price, "sats_usd": SATS_USD_PRICE},
)
def btc_usd_price() -> float:

View File

@@ -1726,7 +1726,6 @@ class BaseUpstreamProvider:
Returns:
List of Model objects with pricing
"""
logger.debug(f"Fetching models for {self.provider_type or self.base_url}")
try:
or_models, provider_models_response = await asyncio.gather(
@@ -1753,18 +1752,9 @@ class BaseUpstreamProvider:
else:
not_found_models.append(model_id)
logger.info(
"Fetched models for provider",
extra={
"provider": self.provider_type or self.base_url,
"found_count": len(found_models),
"not_found_count": len(not_found_models),
},
)
if not_found_models:
logger.debug(
"Models not found in OpenRouter",
f"({len(not_found_models)}/{len(provider_model_ids)}) unmatched models for {self.provider_type or self.base_url}",
extra={"not_found_models": not_found_models},
)
@@ -1832,10 +1822,7 @@ class BaseUpstreamProvider:
self._models_cache = models_with_fees
self._models_by_id = {m.id: m for m in self._models_cache}
logger.info(
f"Refreshed models cache for {self.provider_type or self.base_url}",
extra={"model_count": len(models)},
)
except Exception as e:
logger.error(
f"Failed to refresh models cache for {self.provider_type or self.base_url}",

View File

@@ -193,7 +193,7 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
if provider:
await provider.refresh_models_cache()
upstreams.append(provider)
logger.info(
logger.debug(
f"Initialized {provider_row.provider_type} provider",
extra={
"base_url": provider_row.base_url,

View File

@@ -102,11 +102,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
url = f"{self.base_url}/models"
headers = {"Authorization": f"Bearer {self.api_key}"}
logger.debug(
"Fetching models from PPQ.AI",
extra={"url": url, "has_api_key": bool(self.api_key)},
)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
@@ -114,10 +109,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
data = response.json()
models_data = data.get("data", [])
logger.info(
"Fetched models from PPQ.AI",
extra={"model_count": len(models_data)},
)
or_models = [
Model(**model) # type: ignore
@@ -198,15 +189,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
return models
except httpx.HTTPStatusError as e:
logger.error(
"HTTP error fetching models from PPQ.AI",
extra={
"status_code": e.response.status_code,
"error": str(e),
},
)
return []
except Exception as e:
logger.error(
"Error fetching models from PPQ.AI",

View File

@@ -142,46 +142,6 @@ class TestPerformanceBaseline:
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
)
@pytest.mark.asyncio
async def test_database_query_performance(
self, integration_session: Any, db_snapshot: Any
) -> None:
"""Test database operation performance"""
from sqlmodel import select
from routstr.core.db import ApiKey
# Create test data
for i in range(100):
key = ApiKey(
hashed_key=f"test_key_{i}",
balance=1000000,
total_spent=0,
total_requests=0,
)
integration_session.add(key)
await integration_session.commit()
# Test query performance
query_times = []
for _ in range(100):
start = time.time()
result = await integration_session.execute(
select(ApiKey).where(ApiKey.balance > 0) # type: ignore[arg-type]
)
_ = result.all()
duration = (time.time() - start) * 1000
query_times.append(duration)
# All queries should complete < 100ms
assert max(query_times) < 100, (
f"Max query time {max(query_times)}ms exceeds 100ms limit"
)
print("\nDatabase query performance:")
print(f" Mean: {statistics.mean(query_times):.2f}ms")
print(f" Max: {max(query_times):.2f}ms")
@pytest.mark.integration
@pytest.mark.slow

View File

@@ -29,9 +29,7 @@ export default function BalancesPage() {
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='text-3xl font-bold tracking-tight'>
Balances
</h1>
<h1 className='text-3xl font-bold tracking-tight'>Balances</h1>
<p className='text-muted-foreground mt-2'>
Monitor and manage wallet balances
</p>

View File

@@ -78,8 +78,8 @@ export default function AdminLoginPage(): ReactElement {
};
return (
<div className='flex min-h-screen items-center justify-center bg-background px-4 py-12 text-foreground'>
<Card className='w-full max-w-md border border-border/60 bg-card/90 shadow-2xl shadow-black/30 backdrop-blur'>
<div className='bg-background text-foreground flex min-h-screen items-center justify-center px-4 py-12'>
<Card className='border-border/60 bg-card/90 w-full max-w-md border shadow-2xl shadow-black/30 backdrop-blur'>
<CardHeader className='space-y-1'>
<CardTitle className='text-center text-2xl font-bold'>
Admin Login

View File

@@ -97,7 +97,7 @@ export function LogDetailsDialog({
<div>
<h4 className='mb-2 text-sm font-medium'>Message</h4>
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
<pre className='font-mono text-sm whitespace-pre break-all'>
<pre className='font-mono text-sm break-all whitespace-pre'>
{log.message}
</pre>
</div>
@@ -116,7 +116,12 @@ export function LogDetailsDialog({
<Button
variant='outline'
size='sm'
onClick={() => copyToClipboard(String(log[field as keyof LogEntry] || ''), field)}
onClick={() =>
copyToClipboard(
String(log[field as keyof LogEntry] || ''),
field
)
}
className='h-6 flex-shrink-0 px-2'
>
{copiedField === field ? (
@@ -175,7 +180,9 @@ export function LogDetailsDialog({
<Button
variant='outline'
size='sm'
onClick={() => copyToClipboard(JSON.stringify(log, null, 2), 'json')}
onClick={() =>
copyToClipboard(JSON.stringify(log, null, 2), 'json')
}
className='h-6 px-2'
>
{copiedField === 'json' ? (

View File

@@ -205,32 +205,45 @@ export default function ModelsPage() {
</div>
</div>
{totalModels === 0 && (
<Alert className="mb-4">
<Alert className='mb-4'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<div className='space-y-2'>
<p className='font-medium'>
No models found for this provider
</p>
<div className='text-sm space-y-1'>
<p className='font-medium'>Common issues:</p>
<ul className='list-disc list-inside space-y-1 ml-2'>
<div className='space-y-1 text-sm'>
<p className='font-medium'>
Common issues:
</p>
<ul className='ml-2 list-inside list-disc space-y-1'>
<li>
<strong>API credentials:</strong> Check if the API key is correct and has the right permissions
<strong>API credentials:</strong>{' '}
Check if the API key is correct
and has the right permissions
</li>
<li>
<strong>Base URL:</strong> Verify the base URL is correct for your provider
<strong>Base URL:</strong> Verify
the base URL is correct for your
provider
</li>
<li>
<strong>Network access:</strong> Ensure the server can reach the provider&apos;s API endpoint
<strong>Network access:</strong>{' '}
Ensure the server can reach the
provider&apos;s API endpoint
</li>
<li>
<strong>Provider status:</strong> The upstream provider might be temporarily unavailable
<strong>Provider status:</strong>{' '}
The upstream provider might be
temporarily unavailable
</li>
</ul>
{groupData?.group_url && (
<p className='mt-2 text-xs text-muted-foreground'>
Current endpoint: <code className='bg-muted px-1 rounded'>{groupData.group_url}</code>
<p className='text-muted-foreground mt-2 text-xs'>
Current endpoint:{' '}
<code className='bg-muted rounded px-1'>
{groupData.group_url}
</code>
</p>
)}
</div>

View File

@@ -53,7 +53,7 @@ export default function DashboardPage() {
window.removeEventListener('storage', syncAuthState);
};
}, []);
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
queryFn: fetchBtcUsdPrice,
@@ -131,8 +131,13 @@ export default function DashboardPage() {
<SiteHeader />
<div className='container max-w-7xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8'>
<h1 className='text-3xl font-bold tracking-tight mb-6'>Dashboard</h1>
<DashboardBalanceSummary displayUnit={displayUnit} usdPerSat={usdPerSat} />
<h1 className='mb-6 text-3xl font-bold tracking-tight'>
Dashboard
</h1>
<DashboardBalanceSummary
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
<div className='mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'>
@@ -140,8 +145,9 @@ export default function DashboardPage() {
<h2 className='text-xl font-semibold tracking-tight'>
Usage Analytics
</h2>
<p className='text-muted-foreground text-sm mt-1'>
Monitor requests, errors, and revenue over the last {timeRange} hours
<p className='text-muted-foreground mt-1 text-sm'>
Monitor requests, errors, and revenue over the last {timeRange}{' '}
hours
</p>
</div>
<div className='flex items-center gap-4'>
@@ -176,27 +182,31 @@ export default function DashboardPage() {
<div className='space-y-6'>
{summaryLoading ? (
<div className='text-center py-8'>Loading summary...</div>
<div className='py-8 text-center'>Loading summary...</div>
) : summaryData ? (
<UsageSummaryCards summary={summaryData} />
) : null}
<div className='grid gap-6 lg:grid-cols-2'>
{metricsLoading ? (
<div className='text-center py-8 col-span-2'>
<div className='col-span-2 py-8 text-center'>
Loading metrics...
</div>
) : metricsData && metricsData.metrics.length > 0 ? (
<>
<div className="col-span-full">
<div className='col-span-full'>
<UsageMetricsChart
data={metricsData.metrics.map((m) => ({
...m,
revenue_sats: m.revenue_msats / 1000,
refunds_sats: m.refunds_msats / 1000,
net_revenue_sats:
(m.revenue_msats - m.refunds_msats) / 1000,
})) as Array<Record<string, unknown> & { timestamp: string }>}
data={
metricsData.metrics.map((m) => ({
...m,
revenue_sats: m.revenue_msats / 1000,
refunds_sats: m.refunds_msats / 1000,
net_revenue_sats:
(m.revenue_msats - m.refunds_msats) / 1000,
})) as Array<
Record<string, unknown> & { timestamp: string }
>
}
title='Revenue Over Time (sats)'
dataKeys={[
{
@@ -218,7 +228,11 @@ export default function DashboardPage() {
/>
</div>
<UsageMetricsChart
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
title='Request Volume'
dataKeys={[
{
@@ -239,7 +253,11 @@ export default function DashboardPage() {
]}
/>
<UsageMetricsChart
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
title='Error Tracking'
dataKeys={[
{
@@ -260,7 +278,11 @@ export default function DashboardPage() {
]}
/>
<UsageMetricsChart
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
data={
metricsData.metrics as Array<
Record<string, unknown> & { timestamp: string }
>
}
title='Payment Activity'
dataKeys={[
{
@@ -270,7 +292,7 @@ export default function DashboardPage() {
},
]}
/>
<div className="space-y-6">
<div className='space-y-6'>
{summaryData && summaryData.unique_models.length > 0 && (
<Card>
<CardHeader>
@@ -306,7 +328,9 @@ export default function DashboardPage() {
key={type}
className='flex items-center justify-between'
>
<span className='text-sm font-medium'>{type}</span>
<span className='text-sm font-medium'>
{type}
</span>
<span className='text-muted-foreground text-sm'>
{count}
</span>
@@ -335,16 +359,18 @@ export default function DashboardPage() {
</div>
{revenueByModelLoading ? (
<div className='text-center py-8'>Loading revenue by model...</div>
<div className='py-8 text-center'>
Loading revenue by model...
</div>
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
<RevenueByModelTable
<RevenueByModelTable
models={revenueByModelData.models}
totalRevenue={revenueByModelData.total_revenue_sats}
/>
) : null}
{errorLoading ? (
<div className='text-center py-8'>Loading errors...</div>
<div className='py-8 text-center'>Loading errors...</div>
) : errorData ? (
<ErrorDetailsTable errors={errorData.errors} />
) : null}

View File

@@ -76,7 +76,11 @@ function ProviderBalance({
);
const queryClient = useQueryClient();
const { data: balanceData, isLoading, error } = useQuery({
const {
data: balanceData,
isLoading,
error,
} = useQuery({
queryKey: ['provider-balance', providerId],
queryFn: () => AdminService.getProviderBalance(providerId),
refetchInterval: 30000,
@@ -108,7 +112,10 @@ function ProviderBalance({
mutationFn: async (amount: number) => {
console.log('Calling top-up API with:', { providerId, amount });
try {
const result = await AdminService.initiateProviderTopup(providerId, amount);
const result = await AdminService.initiateProviderTopup(
providerId,
amount
);
console.log('API returned:', result);
return result;
} catch (err) {
@@ -120,11 +127,8 @@ function ProviderBalance({
console.log('Top-up response:', data);
console.log('Type of data:', typeof data);
console.log('Keys in data:', Object.keys(data || {}));
if (
data?.topup_data?.payment_request &&
data?.topup_data?.invoice_id
) {
if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) {
setInvoiceData({
payment_request: data.topup_data.payment_request as string,
invoice_id: data.topup_data.invoice_id as string,
@@ -172,19 +176,23 @@ function ProviderBalance({
// The backend throws 500/400 if not implemented.
// A better approach is to check if we have a platform URL and maybe redirect there
// if we know it's not supported.
// BUT, we don't know for sure if it's supported without checking metadata or trying.
// Let's rely on the "can_topup" metadata if available, but currently we only have "can_show_balance".
// Simple heuristic: If platformUrl exists and we suspect no direct topup, redirect?
// Actually, let's try to open the dialog, but if it's OpenRouter/OpenAI, maybe we just redirect?
// The user specifically mentioned "like in openrouter".
if (platformUrl && (platformUrl.includes('openrouter.ai') || platformUrl.includes('openai.com'))) {
window.open(platformUrl, '_blank');
return;
if (
platformUrl &&
(platformUrl.includes('openrouter.ai') ||
platformUrl.includes('openai.com'))
) {
window.open(platformUrl, '_blank');
return;
}
setIsTopupDialogOpen(true);
};
@@ -266,9 +274,7 @@ function ProviderBalance({
<path d='M5 13l4 4L19 7'></path>
</svg>
</div>
<p className='text-center font-semibold'>
Top-up successful!
</p>
<p className='text-center font-semibold'>Top-up successful!</p>
</div>
) : invoiceData ? (
<div className='flex flex-col items-center gap-4 py-4'>
@@ -482,7 +488,9 @@ export default function ProvidersPage() {
...formData,
api_key: String(response.account_data.api_key),
});
toast.success('Account created successfully! API key has been filled in.');
toast.success(
'Account created successfully! API key has been filled in.'
);
} else {
toast.success('Account created, but no API key returned.');
}
@@ -778,8 +786,7 @@ export default function ProvidersPage() {
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card
fees, etc.
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
</div>
@@ -856,9 +863,11 @@ export default function ProvidersPage() {
<div className='flex flex-wrap items-center gap-2'>
{canShowBalance(provider.provider_type) &&
provider.api_key && (
<ProviderBalance
providerId={provider.id}
platformUrl={getPlatformUrl(provider.provider_type)}
<ProviderBalance
providerId={provider.id}
platformUrl={getPlatformUrl(
provider.provider_type
)}
/>
)}
<Button
@@ -925,9 +934,16 @@ export default function ProvidersPage() {
{providerModels.db_models.length === 0 ? (
<div className='flex flex-col items-center justify-center gap-2 py-4'>
<div className='text-muted-foreground text-sm'>
No models configured. Add custom models to use this provider.
No models configured. Add custom models
to use this provider.
</div>
<Button variant='outline' size='sm' onClick={() => handleAddModel(provider.id)}>
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add Custom Model
</Button>
@@ -970,7 +986,12 @@ export default function ProvidersPage() {
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleEditModel(provider.id, model)}
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
@@ -1027,10 +1048,17 @@ export default function ProvidersPage() {
<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.
Custom models override or extend the
provider&apos;s catalog.
</div>
)}
<Button variant='outline' size='sm' onClick={() => handleAddModel(provider.id)}>
<Button
variant='outline'
size='sm'
onClick={() =>
handleAddModel(provider.id)
}
>
<Plus className='mr-2 h-4 w-4' />
Add
</Button>
@@ -1079,7 +1107,12 @@ export default function ProvidersPage() {
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleEditModel(provider.id, model)}
onClick={() =>
handleEditModel(
provider.id,
model
)
}
>
<Pencil className='h-4 w-4' />
</Button>
@@ -1126,7 +1159,12 @@ export default function ProvidersPage() {
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => handleOverrideModel(provider.id, model)}
onClick={() =>
handleOverrideModel(
provider.id,
model
)
}
>
<Plus className='mr-1 h-3 w-3' />
Override
@@ -1245,7 +1283,7 @@ export default function ProvidersPage() {
</div>
)}
<div className='flex items-center space-x-2'>
<Switch
<Switch
id='edit_enabled'
checked={formData.enabled}
onCheckedChange={(checked) =>
@@ -1277,8 +1315,7 @@ export default function ProvidersPage() {
)}
/>
<p className='text-muted-foreground text-xs'>
1.01 means +1% e.g. currency exchange, card
fees, etc.
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
</div>
@@ -1303,7 +1340,9 @@ export default function ProvidersPage() {
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() => setModelDialogState((prev) => ({ ...prev, isOpen: false }))}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ['provider-models', modelDialogState.providerId],

View File

@@ -102,7 +102,8 @@ export function AddProviderModelDialog({
}: AddProviderModelDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPresetOpen, setIsPresetOpen] = useState(false);
const [selectedPresetLabel, setSelectedPresetLabel] = useState('Select a preset');
const [selectedPresetLabel, setSelectedPresetLabel] =
useState('Select a preset');
const form = useForm<FormData>({
resolver: zodResolver(FormSchema) as never,
@@ -149,7 +150,10 @@ export function AddProviderModelDialog({
if (initialData) {
const architecture = initialData.architecture as Record<string, unknown>;
const pricing = initialData.pricing as Record<string, number>;
const topProvider = initialData.top_provider as Record<string, unknown> | null;
const topProvider = initialData.top_provider as Record<
string,
unknown
> | null;
form.reset({
id: initialData.id,
@@ -250,7 +254,9 @@ export function AddProviderModelDialog({
form.setValue('context_length', model.context_length);
form.setValue(
'modality',
typeof architecture?.modality === 'string' ? architecture.modality : 'text'
typeof architecture?.modality === 'string'
? architecture.modality
: 'text'
);
form.setValue(
'input_modalities_raw',
@@ -318,7 +324,10 @@ export function AddProviderModelDialog({
setIsSubmitting(true);
try {
let perRequestLimits: Record<string, unknown> | null = null;
if (data.per_request_limits_raw && data.per_request_limits_raw.trim().length) {
if (
data.per_request_limits_raw &&
data.per_request_limits_raw.trim().length
) {
try {
perRequestLimits = JSON.parse(data.per_request_limits_raw);
} catch {
@@ -336,7 +345,9 @@ export function AddProviderModelDialog({
context_length: data.context_length,
architecture: {
modality: data.modality,
input_modalities: listFromString(data.input_modalities_raw || data.modality),
input_modalities: listFromString(
data.input_modalities_raw || data.modality
),
output_modalities: listFromString(
data.output_modalities_raw || data.modality
),
@@ -366,10 +377,9 @@ export function AddProviderModelDialog({
is_moderated: data.top_provider_is_moderated,
}
: null,
upstream_provider_id:
data.upstream_provider_id?.trim().length
? data.upstream_provider_id.trim()
: providerId,
upstream_provider_id: data.upstream_provider_id?.trim().length
? data.upstream_provider_id.trim()
: providerId,
canonical_slug: data.canonical_slug?.trim() || null,
alias_ids: listFromString(data.alias_ids_raw || ''),
enabled: data.enabled,
@@ -416,7 +426,7 @@ export function AddProviderModelDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{!isEdit && !isOverride && (
<div className='rounded-md border bg-muted/30 p-3'>
<div className='bg-muted/30 rounded-md border p-3'>
<div className='mb-2 text-sm font-medium'>Presets</div>
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
<Popover open={isPresetOpen} onOpenChange={setIsPresetOpen}>
@@ -425,23 +435,25 @@ export function AddProviderModelDialog({
variant='outline'
role='combobox'
aria-expanded={isPresetOpen}
className='w-full justify-between text-left text-sm overflow-hidden'
className='w-full justify-between overflow-hidden text-left text-sm'
>
<span className='truncate'>
{isLoadingPresets ? 'Loading presets...' : selectedPresetLabel}
{isLoadingPresets
? 'Loading presets...'
: selectedPresetLabel}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className='w-80 max-w-sm p-0'
align='start'
sideOffset={4}
<PopoverContent
className='w-80 max-w-sm p-0'
align='start'
sideOffset={4}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command shouldFilter={true}>
<CommandInput placeholder='Search presets...' />
<CommandList
<CommandList
className='max-h-64 overflow-y-auto overscroll-contain'
onWheel={(e) => e.stopPropagation()}
>
@@ -475,8 +487,9 @@ export function AddProviderModelDialog({
</PopoverContent>
</Popover>
</div>
<div className='text-muted-foreground text-xs mt-1'>
Prefill fields from a preset model definition, then adjust as needed.
<div className='text-muted-foreground mt-1 text-xs'>
Prefill fields from a preset model definition, then adjust as
needed.
</div>
</div>
)}
@@ -496,7 +509,9 @@ export function AddProviderModelDialog({
disabled={isOverride || isEdit}
/>
</FormControl>
<FormDescription>Unique identifier for the model</FormDescription>
<FormDescription>
Unique identifier for the model
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -524,7 +539,11 @@ export function AddProviderModelDialog({
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder='Brief description...' {...field} rows={2} />
<Textarea
placeholder='Brief description...'
{...field}
rows={2}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -575,10 +594,7 @@ export function AddProviderModelDialog({
<FormItem>
<FormLabel>Input Modalities</FormLabel>
<FormControl>
<Input
placeholder='text, image, file'
{...field}
/>
<Input placeholder='text, image, file' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
@@ -593,10 +609,7 @@ export function AddProviderModelDialog({
<FormItem>
<FormLabel>Output Modalities</FormLabel>
<FormControl>
<Input
placeholder='text, image'
{...field}
/>
<Input placeholder='text, image' {...field} />
</FormControl>
<FormDescription>Comma-separated list</FormDescription>
<FormMessage />
@@ -643,7 +656,10 @@ export function AddProviderModelDialog({
<FormItem>
<FormLabel>Canonical Slug</FormLabel>
<FormControl>
<Input placeholder='google/gemini-3-pro-preview-20251117' {...field} />
<Input
placeholder='google/gemini-3-pro-preview-20251117'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -697,15 +713,19 @@ export function AddProviderModelDialog({
rows={4}
/>
</FormControl>
<FormDescription>JSON object; leave empty for none</FormDescription>
<FormDescription>
JSON object; leave empty for none
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='space-y-4 rounded-md border bg-muted/20 p-4'>
<h4 className='text-sm font-medium'>Pricing (USD per 1M tokens)</h4>
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
<h4 className='text-sm font-medium'>
Pricing (USD per 1M tokens)
</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
@@ -827,7 +847,7 @@ export function AddProviderModelDialog({
</div>
</div>
<div className='space-y-4 rounded-md border bg-muted/20 p-4'>
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
<h4 className='text-sm font-medium'>Top Provider (optional)</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3'>
<FormField
@@ -870,11 +890,18 @@ export function AddProviderModelDialog({
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>Is Moderated</FormLabel>
<FormDescription>Whether provider enforces moderation</FormDescription>
<FormLabel className='text-base'>
Is Moderated
</FormLabel>
<FormDescription>
Whether provider enforces moderation
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@@ -892,7 +919,10 @@ export function AddProviderModelDialog({
<FormDescription>Enable this model for use</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@@ -908,8 +938,14 @@ export function AddProviderModelDialog({
Cancel
</Button>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isEdit ? 'Save Changes' : isOverride ? 'Create Override' : 'Create Model'}
{isSubmitting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{isEdit
? 'Save Changes'
: isOverride
? 'Create Override'
: 'Create Model'}
</Button>
</DialogFooter>
</form>
@@ -918,4 +954,3 @@ export function AddProviderModelDialog({
</Dialog>
);
}

View File

@@ -2,11 +2,12 @@
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { type Model, type GroupSettings } from '@/lib/api/schemas/models';
import {
type Model,
type GroupSettings,
} from '@/lib/api/schemas/models';
import { AdminService, type AdminModelGroup, type AdminModel } from '@/lib/api/services/admin';
AdminService,
type AdminModelGroup,
type AdminModel,
} from '@/lib/api/services/admin';
type ModelGroup = AdminModelGroup;
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
import { EditGroupForm } from '@/components/EditGroupForm';
@@ -514,7 +515,7 @@ export function ModelSelector({
return;
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
@@ -557,7 +558,7 @@ export function ModelSelector({
return;
}
const providerId = parseInt(model.provider_id);
// Construct AdminModel from Model
const adminModel: AdminModel = {
id: model.id,
@@ -901,10 +902,10 @@ export function ModelSelector({
)}
{/* Model Management Actions */}
{groupData && (
<Button
onClick={() => handleAddModelClick(parseInt(groupData.id))}
<Button
onClick={() => handleAddModelClick(parseInt(groupData.id))}
className='gap-2'
variant="outline"
variant='outline'
>
<CheckSquare className='h-4 w-4' />
Add Custom Model
@@ -1306,7 +1307,9 @@ export function ModelSelector({
<AddProviderModelDialog
providerId={modelDialogState.providerId}
isOpen={modelDialogState.isOpen}
onClose={() => setModelDialogState((prev) => ({ ...prev, isOpen: false }))}
onClose={() =>
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
}
onSuccess={handleModelUpdate}
initialData={modelDialogState.initialData}
mode={modelDialogState.mode}

View File

@@ -48,20 +48,26 @@ export function CurrencyToggle() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-9 w-9 px-0 gap-2 w-auto px-3 font-normal">
<Coins className="h-4 w-4" />
<span className="hidden sm:inline-block">{getLabel(displayUnit)}</span>
<span className="sm:hidden uppercase">{displayUnit}</span>
<Button
variant='ghost'
size='sm'
className='h-9 w-9 w-auto gap-2 px-0 px-3 font-normal'
>
<Coins className='h-4 w-4' />
<span className='hidden sm:inline-block'>
{getLabel(displayUnit)}
</span>
<span className='uppercase sm:hidden'>{displayUnit}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => setDisplayUnit('msat')}>
Millisatoshis (mSAT)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDisplayUnit('sat')}>
Satoshis (sat)
</DropdownMenuItem>
<DropdownMenuItem
<DropdownMenuItem
onClick={() => setDisplayUnit('usd')}
disabled={!usdPerSat}
>
@@ -71,4 +77,3 @@ export function CurrencyToggle() {
</DropdownMenu>
);
}

View File

@@ -96,4 +96,3 @@ export function DashboardBalanceSummary({
</div>
);
}

View File

@@ -24,7 +24,7 @@ export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
<CardTitle>Recent Errors</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground text-center py-8'>
<p className='text-muted-foreground py-8 text-center'>
No errors found in the selected time period
</p>
</CardContent>

View File

@@ -116,7 +116,9 @@ export function CheatSheet(): JSX.Element {
const [topupToken, setTopupToken] = useState('');
const [apiKeyInput, setApiKeyInput] = useState('');
const [walletInfo, setWalletInfo] = useState<WalletSnapshot | null>(null);
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(null);
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(
null
);
const [isCreatingKey, setIsCreatingKey] = useState(false);
const [isTopupLoading, setIsTopupLoading] = useState(false);
const [isRefunding, setIsRefunding] = useState(false);
@@ -332,22 +334,21 @@ export function CheatSheet(): JSX.Element {
const showCreateDetails =
hasInteractedCreate || initialToken.trim().length > 0;
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
const showTopupDetails =
hasInteractedTopup || topupToken.trim().length > 0;
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
const refundToken = refundReceipt?.token ?? null;
const canTopup = Boolean(activeApiKey);
return (
<div className='min-h-screen bg-gradient-to-b from-background via-background to-muted'>
<div className='from-background via-background to-muted min-h-screen bg-gradient-to-b'>
<main className='mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8'>
<section className='relative space-y-3 text-center md:text-left'>
<div className='absolute right-0 top-0 hidden md:block'>
<div className='absolute top-0 right-0 hidden md:block'>
<Button asChild size='sm' className='px-3 text-xs'>
<a href='/login'>Admin</a>
</Button>
</div>
<div className='inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] uppercase tracking-wider text-muted-foreground'>
<Bolt className='h-4 w-4 text-primary' />
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider uppercase'>
<Bolt className='text-primary h-4 w-4' />
Routstr cheat sheet
</div>
<h1 className='text-3xl font-semibold tracking-tight sm:text-4xl'>
@@ -365,10 +366,10 @@ export function CheatSheet(): JSX.Element {
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='flex items-center gap-2 text-lg'>
<ShieldCheck className='h-4 w-4 text-primary' />
<ShieldCheck className='text-primary h-4 w-4' />
Node identity
</CardTitle>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
/v1/info snapshot
</p>
</div>
@@ -404,7 +405,7 @@ export function CheatSheet(): JSX.Element {
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
Version
</dt>
<dd className='text-base font-medium'>
@@ -412,7 +413,7 @@ export function CheatSheet(): JSX.Element {
</dd>
</div>
<div>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
HTTP
</dt>
<dd className='text-base font-medium break-all'>
@@ -421,7 +422,7 @@ export function CheatSheet(): JSX.Element {
</div>
{nodeInfo.onion_url && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
Onion
</dt>
<dd className='text-base font-medium break-all'>
@@ -431,10 +432,10 @@ export function CheatSheet(): JSX.Element {
)}
{nodeInfo.npub && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
<dt className='text-muted-foreground text-xs tracking-wide uppercase'>
npub
</dt>
<dd className='flex items-center gap-2 break-all text-sm font-mono'>
<dd className='flex items-center gap-2 font-mono text-sm break-all'>
{nodeInfo.npub}
<Button
variant='ghost'
@@ -449,7 +450,7 @@ export function CheatSheet(): JSX.Element {
)}
</dl>
<div className='space-y-2'>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
Cashu mints
</p>
<div className='flex flex-wrap gap-2'>
@@ -478,10 +479,10 @@ export function CheatSheet(): JSX.Element {
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-lg'>
<Terminal className='h-4 w-4 text-primary' />
<Terminal className='text-primary h-4 w-4' />
Quick docs
</CardTitle>
<span className='text-xs uppercase tracking-wide text-muted-foreground'>
<span className='text-muted-foreground text-xs tracking-wide uppercase'>
curl-ready
</span>
</CardHeader>
@@ -502,8 +503,10 @@ export function CheatSheet(): JSX.Element {
<Copy className='h-4 w-4' />
</Button>
</div>
<div className='rounded-lg bg-muted p-4 font-mono text-sm leading-6'>
<pre className='whitespace-pre-wrap break-all'>{curlSnippet}</pre>
<div className='bg-muted rounded-lg p-4 font-mono text-sm leading-6'>
<pre className='break-all whitespace-pre-wrap'>
{curlSnippet}
</pre>
</div>
<div className='flex gap-2'>
<Button
@@ -532,16 +535,16 @@ export function CheatSheet(): JSX.Element {
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<KeyRound className='h-5 w-5 text-primary' />
<KeyRound className='text-primary h-5 w-5' />
API key workflow
</CardTitle>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
<p className='text-muted-foreground text-xs tracking-wide uppercase'>
Sections expand as soon as you interact
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>1 · Create key</span>
{showCreateDetails && (
<span className='text-primary'>Cashu token detected</span>
@@ -564,7 +567,7 @@ export function CheatSheet(): JSX.Element {
>
{isCreatingKey ? 'Creating…' : 'Create API key'}
</Button>
<span className='text-xs text-muted-foreground'>
<span className='text-muted-foreground text-xs'>
Redeems instantly and returns <code>sk-</code> key.
</span>
</div>
@@ -574,7 +577,7 @@ export function CheatSheet(): JSX.Element {
<Separator />
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>2 · Manage key</span>
{walletInfo && (
<span className='text-primary'>
@@ -618,7 +621,7 @@ export function CheatSheet(): JSX.Element {
{showManageDetails && (
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Spendable
</p>
<p className='text-xl font-semibold'>
@@ -633,7 +636,7 @@ export function CheatSheet(): JSX.Element {
)}
</div>
<div className='rounded-lg border p-3'>
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Reserved
</p>
<p className='text-xl font-semibold'>
@@ -654,10 +657,12 @@ export function CheatSheet(): JSX.Element {
<Separator />
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>3 · Top up</span>
{showTopupDetails && (
<span className={canTopup ? 'text-primary' : 'text-destructive'}>
<span
className={canTopup ? 'text-primary' : 'text-destructive'}
>
{canTopup ? 'Ready to redeem' : 'Paste API key first'}
</span>
)}
@@ -681,14 +686,14 @@ export function CheatSheet(): JSX.Element {
>
{isTopupLoading ? 'Topping up…' : 'Top up this key'}
</Button>
<span className='text-xs text-muted-foreground'>
{canTopup
? (
<>
Adds balance to the same <code>sk-</code> token.
</>
)
: 'Enter your sk- key above to unlock top ups.'}
<span className='text-muted-foreground text-xs'>
{canTopup ? (
<>
Adds balance to the same <code>sk-</code> token.
</>
) : (
'Enter your sk- key above to unlock top ups.'
)}
</span>
</div>
)}
@@ -697,7 +702,7 @@ export function CheatSheet(): JSX.Element {
<Separator />
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>4 · Refund</span>
{refundReceipt && <span className='text-primary'>Done</span>}
</header>
@@ -710,13 +715,13 @@ export function CheatSheet(): JSX.Element {
>
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
</Button>
<span className='text-xs text-muted-foreground'>
<span className='text-muted-foreground text-xs'>
Burns the key and returns a fresh Cashu token.
</span>
</div>
{refundToken && (
<div className='space-y-2 rounded-lg border bg-muted/30 p-4'>
<div className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<div className='bg-muted/30 space-y-2 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider uppercase'>
<span>Cashu refund token</span>
<Button
variant='outline'
@@ -743,4 +748,3 @@ export function CheatSheet(): JSX.Element {
</div>
);
}

View File

@@ -41,8 +41,11 @@ export function RevenueByModelTable({
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
<p className="text-sm text-muted-foreground">
Total Revenue: <span className="font-mono font-medium text-foreground">{formatAmount(totalRevenue)}</span>
<p className='text-muted-foreground text-sm'>
Total Revenue:{' '}
<span className='text-foreground font-mono font-medium'>
{formatAmount(totalRevenue)}
</span>
</p>
</CardHeader>
<CardContent>
@@ -50,48 +53,62 @@ export function RevenueByModelTable({
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Successful</TableHead>
<TableHead className="text-right">Failed</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="w-[100px]">Share</TableHead>
<TableHead className="text-right">Refunds</TableHead>
<TableHead className="text-right">Net Revenue</TableHead>
<TableHead className="text-right">Avg/Request</TableHead>
<TableHead className='text-right'>Requests</TableHead>
<TableHead className='text-right'>Successful</TableHead>
<TableHead className='text-right'>Failed</TableHead>
<TableHead className='text-right'>Revenue</TableHead>
<TableHead className='w-[100px]'>Share</TableHead>
<TableHead className='text-right'>Refunds</TableHead>
<TableHead className='text-right'>Net Revenue</TableHead>
<TableHead className='text-right'>Avg/Request</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground">
<TableCell
colSpan={9}
className='text-muted-foreground text-center'
>
No model data available
</TableCell>
</TableRow>
) : (
models.map((model) => {
const share = totalRevenue > 0 ? (model.revenue_sats / totalRevenue) * 100 : 0;
const share =
totalRevenue > 0
? (model.revenue_sats / totalRevenue) * 100
: 0;
return (
<TableRow key={model.model}>
<TableCell className="font-medium">{model.model}</TableCell>
<TableCell className="text-right font-mono">{model.requests}</TableCell>
<TableCell className="text-right text-green-600 font-mono">{model.successful}</TableCell>
<TableCell className="text-right text-red-600 font-mono">{model.failed}</TableCell>
<TableCell className="text-right font-mono">
<TableCell className='font-medium'>{model.model}</TableCell>
<TableCell className='text-right font-mono'>
{model.requests}
</TableCell>
<TableCell className='text-right font-mono text-green-600'>
{model.successful}
</TableCell>
<TableCell className='text-right font-mono text-red-600'>
{model.failed}
</TableCell>
<TableCell className='text-right font-mono'>
{formatAmount(model.revenue_sats)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={share} className="h-2" />
<span className="text-xs text-muted-foreground w-8 text-right">{share.toFixed(0)}%</span>
<div className='flex items-center gap-2'>
<Progress value={share} className='h-2' />
<span className='text-muted-foreground w-8 text-right text-xs'>
{share.toFixed(0)}%
</span>
</div>
</TableCell>
<TableCell className="text-right text-red-500 font-mono">
<TableCell className='text-right font-mono text-red-500'>
{formatAmount(model.refunds_sats)}
</TableCell>
<TableCell className="text-right font-semibold font-mono">
<TableCell className='text-right font-mono font-semibold'>
{formatAmount(model.net_revenue_sats)}
</TableCell>
<TableCell className="text-right text-muted-foreground font-mono">
<TableCell className='text-muted-foreground text-right font-mono'>
{formatAmount(model.avg_revenue_per_request)}
</TableCell>
</TableRow>

View File

@@ -90,7 +90,11 @@ export function UsageMetricsChart({
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
itemStyle={{ fontSize: '12px' }}
labelStyle={{ fontSize: '12px', color: 'hsl(var(--muted-foreground))', marginBottom: '8px' }}
labelStyle={{
fontSize: '12px',
color: 'hsl(var(--muted-foreground))',
marginBottom: '8px',
}}
/>
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '16px' }} />
{dataKeys.map((dataKey) => (

View File

@@ -114,15 +114,19 @@ export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
return (
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-4'>
{cards.map((card) => (
<Card key={card.title} className="hover:bg-muted/50 transition-colors">
<Card key={card.title} className='hover:bg-muted/50 transition-colors'>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium text-muted-foreground'>{card.title}</CardTitle>
<div className={`p-2 rounded-full bg-secondary`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
<CardTitle className='text-muted-foreground text-sm font-medium'>
{card.title}
</CardTitle>
<div className={`bg-secondary rounded-full p-2`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold tracking-tight'>{card.value}</div>
<div className='text-2xl font-bold tracking-tight'>
{card.value}
</div>
</CardContent>
</Card>
))}

View File

@@ -816,7 +816,9 @@ export class AdminService {
if (search) params.append('search', search);
params.append('limit', limit.toString());
return await apiClient.get<LogResponse>(`/admin/api/logs?${params.toString()}`);
return await apiClient.get<LogResponse>(
`/admin/api/logs?${params.toString()}`
);
}
static async getLogDates(): Promise<{ dates: string[] }> {
@@ -862,9 +864,7 @@ export class AdminService {
);
}
static async createProviderAccountByType(
providerType: string
): Promise<{
static async createProviderAccountByType(providerType: string): Promise<{
ok: boolean;
account_data: Record<string, unknown>;
message: string;
@@ -881,7 +881,11 @@ export class AdminService {
static async initiateProviderTopup(
providerId: number,
amount: number
): Promise<{ ok: boolean; topup_data: Record<string, unknown>; message: string }> {
): Promise<{
ok: boolean;
topup_data: Record<string, unknown>;
message: string;
}> {
return await apiClient.post<{
ok: boolean;
topup_data: Record<string, unknown>;
@@ -901,9 +905,10 @@ export class AdminService {
}>(`/admin/api/upstream-providers/${providerId}/topup/${invoiceId}/status`);
}
static async getProviderBalance(
providerId: number
): Promise<{ ok: boolean; balance_data: number | null | Record<string, unknown> }> {
static async getProviderBalance(providerId: number): Promise<{
ok: boolean;
balance_data: number | null | Record<string, unknown>;
}> {
return await apiClient.get<{
ok: boolean;
balance_data: number | null | Record<string, unknown>;

View File

@@ -18,4 +18,3 @@ export const useCurrencyStore = create<CurrencyState>()(
}
)
);