Compare commits

..
Author SHA1 Message Date
redshift da0281c84f fix: correct type for labelFormatter parameter 2026-03-22 18:27:18 +00:00
29 changed files with 1083 additions and 2163 deletions
+41 -3
View File
@@ -49,17 +49,55 @@ curl https://api.routstr.com/v1/chat/completions \
}'
```
## Quick Start (Docker)
## Quick Start (Run a Node)
If you are a node runner, start a Routstr Core instance and configure upstream access in the dashboard.
Start earning Bitcoin by selling AI access in under 5 minutes.
### 1. Prepare Configuration
Create a `.env` file:
```bash
# Initial Admin Password
ADMIN_PASSWORD=mysecretpassword
# Node Identity
NAME="My AI Node"
DESCRIPTION="Fast access to models"
# Lightning Payouts
RECEIVE_LN_ADDRESS=yourname@wallet.com
```
### 2. Start the Node
```bash
docker run -d \
--name routstr-proxy \
--name routstr \
-p 8000:8000 \
--env-file .env \
-v routstr-data:/app/data \
ghcr.io/routstr/proxy:latest
```
Verify it's running:
```bash
curl http://localhost:8000/v1/info
```
### 3. Configure via Dashboard
1. Open **Admin Dashboard** at http://localhost:8000/admin/
2. Login with your `ADMIN_PASSWORD`
3. Go to **Settings****Upstream** - Add your AI provider (OpenAI, Anthropic, OpenRouter, etc.)
4. Go to **Settings****Pricing** - Set your profit margin (default 10%)
5. Go to **Settings****Admin** - Set a strong password
### 4. Start Earning
Clients pay you in Bitcoin (via Cashu) for every AI request. Monitor earnings and withdraw profits from the dashboard.
## Development
```bash
-355
View File
@@ -1,355 +0,0 @@
# Plan: Track unsupported incoming mints in `other_mints` and include them in balances
## Goal
When a Cashu token arrives from a mint that is **not** in `settings.cashu_mints`, we currently create/use a wallet for that mint and swap value into the primary mint. Any change/surplus left behind after `melt()` remains in the foreign `token_wallet`, but that balance is not surfaced by `fetch_all_balances()` because it only iterates over configured mints.
This plan adds persistent tracking for those foreign mints in a new database table called `other_mints`, and updates balance reporting to include them.
---
## Current behavior
### Incoming unsupported mint flow
In `routstr/wallet.py`:
- `recieve_token()` deserializes the token
- if `token_obj.mint not in settings.cashu_mints`, it calls `swap_to_primary_mint(token_obj, wallet)`
- `swap_to_primary_mint()` calls `token_wallet.melt(...)` to pay the primary mint invoice
### Important detail: change is retained, not discarded
The underlying Cashu wallet library keeps any melt change:
- `Wallet.melt()` constructs blank outputs for change
- when the melt succeeds, returned change is reconstructed into proofs
- those proofs are appended to `self.proofs` and stored in the wallet DB
So surplus from unsupported mints is **not discarded**, but it may become invisible operationally.
### Visibility problem
`fetch_all_balances()` currently only loops over:
- `settings.cashu_mints`
- units `sat` and `msat`
This means balances left on unsupported mints are not shown in admin balance reporting.
---
## Proposed design
## 1. Add a new DB table: `other_mints`
Add a small table in `routstr/core/db.py` to persist unsupported mints we have seen in incoming tokens.
Suggested schema:
- `mint_url: str` primary key
- `created_at: int`
- `last_seen_at: int`
Minimal model:
```python
class OtherMint(SQLModel, table=True):
__tablename__ = "other_mints"
mint_url: str = Field(primary_key=True)
created_at: int = Field(default_factory=lambda: int(time.time()))
last_seen_at: int = Field(default_factory=lambda: int(time.time()))
```
Why minimal:
- the only required function is mint discovery/tracking
- unit handling can remain dynamic via existing balance queries over `sat` and `msat`
---
## 2. Add DB helpers for `other_mints`
In `routstr/core/db.py`, add helper functions:
### `register_other_mint(mint_url: str) -> None`
Behavior:
- if the mint is not present, insert it
- if it already exists, update `last_seen_at`
### `list_other_mints(session) -> list[str]`
Behavior:
- return all tracked unsupported mint URLs
Optional later:
- `delete_other_mint(...)`
- admin cleanup helpers
---
## 3. Register unsupported mints during token receipt
Update `recieve_token()` in `routstr/wallet.py`.
Current logic:
```python
if token_obj.mint not in settings.cashu_mints:
return await swap_to_primary_mint(token_obj, wallet)
```
Planned logic:
```python
if token_obj.mint not in settings.cashu_mints:
await db.register_other_mint(token_obj.mint)
return await swap_to_primary_mint(token_obj, wallet)
```
Why here:
- this is the earliest reliable point where we know the mint came in via an actual token
- this is exactly the path that can leave foreign-mint change behind
- it avoids needing to infer unsupported mints later from wallet internals
---
## 4. Update `fetch_all_balances()` to include `other_mints`
Current behavior only includes configured mints.
Planned behavior:
- load tracked unsupported mints from DB
- combine them with `settings.cashu_mints`
- dedupe while preserving order
- fetch balances for all tracked mints across requested units
Conceptual flow:
```python
tracked_mints = dedupe(settings.cashu_mints + other_mints_from_db)
```
Then existing per-mint/per-unit balance logic can remain mostly unchanged.
This ensures that retained change on unsupported mints becomes visible in admin balance reporting.
---
## 5. Add a balance source marker
Extend `BalanceDetail` in `routstr/wallet.py` to identify whether a balance row comes from a configured mint or an `other_mints` entry.
Suggested field:
- `source: str` with values:
- `"configured"`
- `"other"`
Updated shape:
```python
class BalanceDetail(TypedDict, total=False):
mint_url: str
unit: str
source: str
wallet_balance: int
user_balance: int
owner_balance: int
error: str
```
Why this helps:
- admin can distinguish normal configured wallet balances from foreign/unsupported balances
- avoids confusion if unexpected mint URLs show up in the balances API/UI
---
## 6. Admin/API impact
Backend impact is minimal because `/admin/api/balances` already returns `fetch_all_balances()` output.
Effects:
- supported mints continue to show as before
- tracked unsupported mints will also appear
- UI can optionally display the new `source` field
No API contract break is expected if the frontend ignores unknown fields.
---
## 7. Payout behavior: do not change in phase 1
`periodic_payout()` currently only iterates over `settings.cashu_mints`.
Recommendation for this change:
- **do not** expand `periodic_payout()` to include `other_mints` yet
- only improve visibility through balance reporting
Reason:
- automatic payout from unsupported/foreign mints may be operationally undesirable
- visibility should come first, automation second
Possible future phase:
- add optional sweeping/payout support for `other_mints`
- or provide an admin-triggered withdrawal/sweep flow
---
## 8. Logging improvements (optional)
Optional follow-up improvement in `swap_to_primary_mint()`:
- capture the return value from `token_wallet.melt(...)`
- if feasible, log any reported change amount
- otherwise, rely on wallet balance reporting to surface residual amounts
This is useful but not required for the first implementation.
---
## Files to change
### `routstr/core/db.py`
Add:
- `OtherMint` SQLModel
- `register_other_mint()`
- `list_other_mints()`
### `migrations/versions/<new_revision>_add_other_mints_table.py`
Create migration to add the `other_mints` table.
### `routstr/wallet.py`
Update:
- `recieve_token()` to register unsupported mints
- `BalanceDetail` to include `source`
- `fetch_all_balances()` to include both configured and tracked unsupported mints
### `routstr/core/admin.py`
Likely no backend changes required unless a dedicated `other_mints` API is desired.
---
## Behavior rules
### Register a mint when
- an incoming token is processed
- the token mint is not in `settings.cashu_mints`
### Do not remove automatically when
- balance reaches zero
Reason:
- historical visibility is useful
- avoids flapping entries in the admin balance list
- mint may receive additional unsupported tokens later
Potential future enhancement:
- admin endpoint to prune zero-balance `other_mints`
---
## Edge cases
### A mint later becomes configured
If a mint in `other_mints` is later added to `settings.cashu_mints`:
- deduplication prevents duplicate balance rows
- `source` should resolve to `configured`
### Unsupported mint with zero balance
A tracked unsupported mint may show zero balances.
Initial recommendation:
- allow it to appear
- consider later filtering zero-balance `other` rows if the UI becomes noisy
### Units
Balance fetching can continue to query both `sat` and `msat` for each tracked mint.
If a mint has no proofs in one unit, current error/zero handling can continue to apply.
---
## Test plan
### DB tests
- registering a new unsupported mint inserts a row
- registering the same mint again updates `last_seen_at` without duplication
- listing other mints returns expected mint URLs
### Wallet tests
#### `recieve_token()`
- when mint is unsupported, `db.register_other_mint()` is called before swap
- when mint is configured, `db.register_other_mint()` is not called
#### `fetch_all_balances()`
- includes configured mints
- includes `other_mints` from DB
- dedupes if a mint exists in both configured and other lists
- sets `source` correctly
### Regression tests
- existing trusted mint balance reporting remains unchanged
- `/admin/api/balances` continues to work
---
## Recommended implementation order
1. Add `OtherMint` model to `routstr/core/db.py`
2. Add Alembic migration for `other_mints`
3. Add `register_other_mint()` and `list_other_mints()` helpers
4. Update `recieve_token()` to register unsupported mints
5. Update `fetch_all_balances()` to union configured + tracked other mints
6. Add `source` to `BalanceDetail`
7. Add/adjust tests
---
## Summary
This change solves an operational visibility problem:
- unsupported incoming mints can leave retained change in foreign wallets
- those funds are currently preserved but not surfaced in balance reporting
- introducing `other_mints` makes those mints discoverable and auditable
- expanding `fetch_all_balances()` ensures their balances are visible in admin tooling
Recommended scope for the first pass:
- track unsupported mints in DB
- include them in balance reporting
- mark them as `source="other"`
- do not yet change payout/sweeping behavior
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.4.1"
version = "0.4.0"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
+9 -4
View File
@@ -732,20 +732,22 @@ async def adjust_payment_for_tokens(
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
balance=col(ApiKey.balance) - cost.total_msats,
total_spent=col(ApiKey.total_spent) + cost.total_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent and balance on the child key if it's different
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_spent=col(ApiKey.total_spent) + cost.total_msats,
balance=col(ApiKey.balance) - cost.total_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -817,20 +819,23 @@ async def adjust_payment_for_tokens(
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
)
await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent and balance on the child key if it's different
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_spent=col(ApiKey.total_spent) + total_cost_msats,
balance=col(ApiKey.balance) - total_cost_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
-19
View File
@@ -5,7 +5,6 @@ from time import monotonic
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlmodel import select
@@ -208,7 +207,6 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
@router.post("/refund")
async def refund_wallet_endpoint(
authorization: Annotated[str, Header(...)],
x_cashu: Annotated[str | None, Header()] = None,
session: AsyncSession = Depends(get_session),
) -> dict[str, str]:
if not authorization.startswith("Bearer "):
@@ -219,23 +217,6 @@ async def refund_wallet_endpoint(
bearer_value: str = authorization[7:]
if x_cashu:
payment_token_hash = hashlib.sha256(x_cashu.strip().encode()).hexdigest()
result = await session.get(CashuTransaction, payment_token_hash)
if result is None:
raise HTTPException(status_code=404, detail="Refund not found")
if result.swept:
raise HTTPException(status_code=410, detail="Refund has been swept")
result.collected = True
session.add(result)
await session.commit()
body: dict[str, str] = {"token": result.token}
if result.unit == "sat":
body["sats"] = str(result.amount)
else:
body["msats"] = str(result.amount)
return JSONResponse(content=body, headers={"X-Cashu": result.token})
key: ApiKey = await validate_bearer_key(bearer_value, session)
if key.total_balance <= 0:
+2 -2
View File
@@ -36,9 +36,9 @@ setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.4.1-{os.getenv('VERSION_SUFFIX')}"
__version__ = f"0.4.0-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.4.1"
__version__ = "0.4.0"
@asynccontextmanager
+1 -3
View File
@@ -403,9 +403,7 @@ async def update_sats_pricing() -> None:
@models_router.get("/v1/models")
@models_router.get("/v1/models/", include_in_schema=False)
@models_router.get("/models")
@models_router.get("/models/", include_in_schema=False)
@models_router.get("/models", include_in_schema=False)
async def models(session: AsyncSession = Depends(get_session)) -> dict:
"""Get all available models from all providers with database overrides applied."""
from ..proxy import get_unique_models
+526 -977
View File
File diff suppressed because it is too large Load Diff
+16 -76
View File
@@ -1,4 +1,5 @@
import asyncio
import math
import time
from typing import TypedDict
@@ -59,76 +60,6 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str
return token
async def _calculate_swap_amount(
amount_msat: int,
token_unit: str,
token_mint_url: str,
token_wallet: Wallet,
primary_wallet: Wallet,
) -> int:
"""
Calculate the amount to mint on the primary mint after accounting for
potential swap fees (melt fees) on the foreign mint.
"""
if settings.primary_mint_unit == "sat":
receive_amount = amount_msat // 1000
else:
receive_amount = amount_msat
if token_mint_url == settings.primary_mint:
logger.info(
"swap_to_primary_mint: skipping fee estimation (same mint)",
extra={"minted_amount": receive_amount},
)
return int(receive_amount)
logger.info(
"swap_to_primary_mint: estimating fees",
extra={
"dummy_amount": receive_amount,
"unit": settings.primary_mint_unit,
},
)
try:
dummy_mint_quote = await primary_wallet.request_mint(receive_amount)
dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request)
fee_reserve = dummy_melt_quote.fee_reserve
if token_unit == "sat":
fee_msat = fee_reserve * 1000
else:
fee_msat = fee_reserve
amount_msat_after_fee = amount_msat - fee_msat
if settings.primary_mint_unit == "sat":
minted_amount = int(amount_msat_after_fee // 1000)
else:
minted_amount = int(amount_msat_after_fee)
if minted_amount <= 0:
raise ValueError(f"Fees ({fee_reserve} {token_unit}) exceed token amount")
logger.info(
"swap_to_primary_mint: fee estimation result",
extra={
"token_amount_sat": amount_msat // 1000,
"estimated_fee_sat": fee_msat // 1000,
"minted_amount": minted_amount,
"minted_unit": settings.primary_mint_unit,
},
)
return minted_amount
except Exception as e:
logger.error(
"swap_to_primary_mint: fee estimation failed",
extra={"error": str(e)},
)
raise ValueError(f"Failed to estimate fees: {e}") from e
async def swap_to_primary_mint(
token_obj: Token, token_wallet: Wallet
) -> tuple[int, str, str]:
@@ -153,14 +84,23 @@ async def swap_to_primary_mint(
amount_msat = token_amount
else:
raise ValueError("Invalid unit")
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) + 1
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
minted_amount = await _calculate_swap_amount(
amount_msat,
token_obj.unit,
token_obj.mint,
token_wallet,
primary_wallet,
if settings.primary_mint_unit == "sat":
minted_amount = int(amount_msat_after_fee // 1000)
else:
minted_amount = int(amount_msat_after_fee)
logger.info(
"swap_to_primary_mint: fee estimation",
extra={
"token_amount_sat": amount_msat // 1000,
"estimated_fee_sat": estimated_fee_sat,
"minted_amount": minted_amount,
"minted_unit": settings.primary_mint_unit,
},
)
mint_quote = await primary_wallet.request_mint(minted_amount)
-100
View File
@@ -1,100 +0,0 @@
import hashlib
from unittest.mock import AsyncMock, MagicMock
import pytest
from routstr.balance import refund_wallet_endpoint
from routstr.core.db import CashuTransaction
def _make_cashu_tx(token: str, amount: int, unit: str, swept: bool = False) -> CashuTransaction:
tx = CashuTransaction(token=token, amount=amount, unit=unit)
tx.swept = swept
tx.collected = False
return tx
@pytest.mark.asyncio
async def test_refund_x_cashu_returns_token() -> None:
x_cashu_token = "cashuAtest_token_value"
expected_hash = hashlib.sha256(x_cashu_token.strip().encode()).hexdigest()
tx = _make_cashu_tx(token="cashuArefund_token", amount=1000, unit="msat")
session = MagicMock()
session.get = AsyncMock(return_value=tx)
session.add = MagicMock()
session.commit = AsyncMock()
result = await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu=x_cashu_token,
session=session,
)
session.get.assert_awaited_once_with(CashuTransaction, expected_hash)
import json
body = json.loads(result.body)
assert body["token"] == "cashuArefund_token"
assert body["msats"] == "1000"
assert result.headers["X-Cashu"] == "cashuArefund_token"
assert tx.collected is True
@pytest.mark.asyncio
async def test_refund_x_cashu_sat_unit() -> None:
x_cashu_token = "cashuAsat_token"
tx = _make_cashu_tx(token="cashuArefund_sat", amount=500, unit="sat")
session = MagicMock()
session.get = AsyncMock(return_value=tx)
session.add = MagicMock()
session.commit = AsyncMock()
result = await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu=x_cashu_token,
session=session,
)
import json
body = json.loads(result.body)
assert body["token"] == "cashuArefund_sat"
assert body["sats"] == "500"
assert "msats" not in body
assert result.headers["X-Cashu"] == "cashuArefund_sat"
@pytest.mark.asyncio
async def test_refund_x_cashu_not_found_raises_404() -> None:
from fastapi import HTTPException
session = MagicMock()
session.get = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu="cashuAmissing_token",
session=session,
)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_refund_x_cashu_swept_raises_410() -> None:
from fastapi import HTTPException
tx = _make_cashu_tx(token="cashuAswept", amount=100, unit="msat", swept=True)
session = MagicMock()
session.get = AsyncMock(return_value=tx)
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu="cashuAswept_token",
session=session,
)
assert exc_info.value.status_code == 410
-74
View File
@@ -217,77 +217,3 @@ async def test_recieve_token_untrusted_mint() -> None:
assert amount == 900
assert unit == "sat"
assert mint == "http://mint:3338"
@pytest.mark.asyncio
async def test_swap_to_primary_mint_success() -> None:
"""Test successful swap with dynamic fee calculation."""
from routstr.wallet import swap_to_primary_mint
mock_token = Mock()
mock_token.mint = "http://foreign:3338"
mock_token.unit = "sat"
mock_token.amount = 1000
mock_token.keysets = ["keyset1"]
mock_token.proofs = [{"amount": 1000}]
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()
mock_primary_wallet.load_proofs = AsyncMock()
# Mocks for the estimation phase
# 1. request_mint(dummy_amount=1000) -> invoice_dummy
# 2. melt_quote(invoice_dummy) -> fee=10
# Mocks for the execution phase
# 3. request_mint(minted_amount=990) -> invoice_real
# 4. melt_quote(invoice_real) -> amount=990, fee=10
# 5. melt() -> success
# 6. mint() -> success
mock_mint_quote_dummy = Mock(quote="dummy_quote", request="lnbc_dummy")
mock_mint_quote_real = Mock(quote="real_quote", request="lnbc_real")
# side_effect for request_mint to return dummy then real
mock_primary_wallet.request_mint = AsyncMock(
side_effect=[mock_mint_quote_dummy, mock_mint_quote_real]
)
mock_melt_quote_dummy = Mock(amount=1000, fee_reserve=10)
mock_melt_quote_real = Mock(amount=990, fee_reserve=10)
# side_effect for melt_quote
mock_token_wallet.melt_quote = AsyncMock(
side_effect=[mock_melt_quote_dummy, mock_melt_quote_real]
)
mock_token_wallet.melt = AsyncMock(return_value="melted_proofs")
mock_primary_wallet.mint = AsyncMock(return_value="minted_proofs")
from routstr.core.settings import settings
with patch.object(settings, "primary_mint", "http://primary:3338"):
with patch.object(settings, "primary_mint_unit", "sat"):
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
amount, unit, mint = await swap_to_primary_mint(
mock_token, mock_token_wallet
)
assert amount == 990 # 1000 - 10
assert unit == "sat"
assert mint == "http://primary:3338"
# Verify call order/counts
assert mock_primary_wallet.request_mint.call_count == 2
# First call with full amount for estimation
mock_primary_wallet.request_mint.assert_any_call(1000)
# Second call with calculated amount
mock_primary_wallet.request_mint.assert_any_call(990)
assert mock_token_wallet.melt_quote.call_count == 2
assert mock_token_wallet.melt.called
assert mock_primary_wallet.mint.called
-5
View File
@@ -1,5 +0,0 @@
import { ModelsPage } from '@/components/models-page';
export default function ModelPage() {
return <ModelsPage />;
}
@@ -23,7 +23,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export function ModelsPage() {
export default function ModelsPage() {
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
undefined
);
+2 -2
View File
@@ -449,10 +449,10 @@ function DashboardInsights({
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_: React.ReactNode, payload) =>
labelFormatter={(_: any, payload: readonly any[]) =>
String(payload?.[0]?.payload?.type ?? '')
}
formatter={(value, name) => {
formatter={(value: any, name: any) => {
const numericValue =
typeof value === 'number'
? value
-39
View File
@@ -1,39 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import * as React from 'react';
import { Input } from '@/components/ui/input';
interface ApiKeyInputProps extends React.ComponentProps<'input'> {
onApiKeyChange: (apiKey: string) => void;
}
export function ApiKeyInput({
value,
onApiKeyChange,
...props
}: ApiKeyInputProps) {
const [internalValue, setInternalValue] = useState(value || '');
useEffect(() => {
setInternalValue(value || '');
}, [value]);
useEffect(() => {
const handler = setTimeout(() => {
onApiKeyChange(internalValue as string);
}, 300);
return () => clearTimeout(handler);
}, [internalValue, onApiKeyChange]);
return (
<Input
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
{...props}
/>
);
}
+1 -1
View File
@@ -40,7 +40,7 @@ const NAV_ITEMS = [
{ title: 'Dashboard', url: '/', icon: LayoutDashboardIcon },
{ title: 'Balances', url: '/balances', icon: WalletIcon },
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
{ title: 'Models', url: '/model', icon: DatabaseIcon },
{ title: 'Models', url: '/models', icon: DatabaseIcon },
{ title: 'Providers', url: '/providers', icon: ServerIcon },
{ title: 'Transactions', url: '/transactions', icon: ArrowRightLeftIcon },
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
+1 -1
View File
@@ -58,7 +58,7 @@ const data = {
},
{
title: 'Models',
url: '/model',
url: '/models',
icon: DatabaseIcon,
},
{
+1 -1
View File
@@ -257,7 +257,7 @@ export function ChartAreaInteractive() {
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
labelFormatter={(value: any) => {
return new Date(value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
+163 -92
View File
@@ -1,9 +1,7 @@
'use client';
import { useState } from 'react';
import { useWalletInfo } from '@/hooks/use-wallet-info';
import { WalletService } from '@/lib/api/services/wallet';
import { ApiKeyInput } from './api-key-input';
import { Button } from '@/components/ui/button';
import {
Card,
@@ -16,8 +14,17 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Key, Copy, Check, Loader2, Plus, Trash2 } from 'lucide-react';
import {
Key,
Copy,
Check,
Loader2,
RotateCcw,
Plus,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { KeyOptions } from './key-options';
interface KeyConfig {
@@ -35,14 +42,6 @@ interface ChildKeyCreatorProps {
costPerKeyMsats?: number;
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
export function ChildKeyCreator({
baseUrl,
apiKey: propApiKey,
@@ -51,7 +50,6 @@ export function ChildKeyCreator({
}: ChildKeyCreatorProps) {
const [internalApiKey, setInternalApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [configs, setConfigs] = useState<KeyConfig[]>([
{
id: crypto.randomUUID(),
@@ -61,15 +59,15 @@ export function ChildKeyCreator({
validityDate: '',
},
]);
const activeApiKey = propApiKey ?? internalApiKey;
const { data: walletInfo } = useWalletInfo(baseUrl ?? '', activeApiKey);
const handleApiKeyChange = (val: string) => {
setInternalApiKey(val);
onApiKeyChange?.(val);
};
const [childKeyToCheck, setChildKeyToCheck] = useState('');
const [checking, setChecking] = useState(false);
const [keyStatus, setKeyStatus] = useState<{
total_spent: number;
balance_limit: number | null;
validity_date: number | null;
is_expired: boolean;
is_drained: boolean;
} | null>(null);
const [newKeys, setNewKeys] = useState<string[]>([]);
const [resultInfo, setResultInfo] = useState<{
cost_msats: number;
@@ -77,6 +75,13 @@ export function ChildKeyCreator({
} | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const activeApiKey = propApiKey ?? internalApiKey;
const handleApiKeyChange = (val: string) => {
setInternalApiKey(val);
onApiKeyChange?.(val);
};
const addConfig = () => {
setConfigs([
...configs,
@@ -107,7 +112,6 @@ export function ChildKeyCreator({
}
setLoading(true);
setError(null);
try {
let allNewKeys: string[] = [];
let totalCost = 0;
@@ -148,21 +152,55 @@ export function ChildKeyCreator({
);
} catch (error) {
console.error('Failed to create child key:', error);
let errorMessage =
error instanceof Error ? error.message : 'Failed to create child key';
try {
const parsed = JSON.parse(errorMessage);
errorMessage =
parsed.detail?.error?.message ||
(typeof parsed.detail === 'string' ? parsed.detail : errorMessage);
} catch {}
setError(errorMessage);
toast.error(errorMessage);
toast.error(
error instanceof Error ? error.message : 'Failed to create child key'
);
} finally {
setLoading(false);
}
};
const handleCheckKey = async () => {
if (!childKeyToCheck) {
toast.error('Please provide a Child API key to check');
return;
}
setChecking(true);
setKeyStatus(null);
try {
const baseUrlToUse = baseUrl || '';
const response = await fetch(`${baseUrlToUse}/v1/balance/info`, {
headers: {
Authorization: `Bearer ${childKeyToCheck}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch key info');
}
const info = await response.json();
const now = Math.floor(Date.now() / 1000);
setKeyStatus({
total_spent: info.total_spent,
balance_limit: info.balance_limit,
validity_date: info.validity_date,
is_expired: info.validity_date ? now > info.validity_date : false,
is_drained: info.balance_limit
? info.total_spent >= info.balance_limit
: false,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to check child key'
);
} finally {
setChecking(false);
}
};
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
@@ -205,55 +243,12 @@ export function ChildKeyCreator({
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Parent API Key
</Label>
<div className='flex gap-2'>
<div className='flex-1'>
<ApiKeyInput
value={activeApiKey}
onApiKeyChange={handleApiKeyChange}
/>
</div>
<Button
variant='outline'
size='icon'
onClick={() => navigator.clipboard.writeText(activeApiKey)}
disabled={!activeApiKey}
>
<Copy className='h-4 w-4' />
</Button>
</div>
{walletInfo && (
<div className='bg-muted/30 mt-2 space-y-2 rounded-lg p-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Spent
</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
</div>
)}
<Input
value={activeApiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
</div>
)}
@@ -367,19 +362,12 @@ export function ChildKeyCreator({
)}
</Button>
</div>
{error && (
<Alert variant='destructive'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<p className='text-muted-foreground text-xs'>
Each key creation has a small one-time fee.
</p>
</div>
<p className='text-muted-foreground text-xs'>
Each key creation has a small one-time fee.
</p>
{newKeys.length > 0 && (
<div className='mt-6 space-y-4'>
<Alert>
@@ -470,6 +458,89 @@ export function ChildKeyCreator({
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className='text-lg'>Check Child Key Status</CardTitle>
<CardDescription>
View the current spending, limit, and expiration status of any child
key.
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
<div className='space-y-2'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Child API Key
</Label>
<Input
value={childKeyToCheck}
onChange={(e) => setChildKeyToCheck(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
</div>
<Button
onClick={handleCheckKey}
disabled={checking || !childKeyToCheck}
variant='outline'
className='w-full'
>
{checking ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Checking...
</>
) : (
<>
<RotateCcw className='mr-2 h-4 w-4' />
Check Status
</>
)}
</Button>
{keyStatus && (
<div className='bg-muted/30 mt-4 space-y-3 rounded-lg border p-4 text-sm'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Total Spent:</span>
<span className='font-mono font-medium'>
{keyStatus.total_spent} mSats
</span>
</div>
{keyStatus.balance_limit !== null && (
<div className='flex justify-between'>
<span className='text-muted-foreground'>Limit:</span>
<span className='font-mono font-medium'>
{keyStatus.balance_limit} mSats
</span>
</div>
)}
{keyStatus.validity_date !== null && (
<div className='flex justify-between'>
<span className='text-muted-foreground'>Expires:</span>
<span className='font-mono font-medium'>
{new Date(
keyStatus.validity_date * 1000
).toLocaleDateString()}
</span>
</div>
)}
<div className='flex gap-2 pt-2'>
{keyStatus.is_drained && (
<Badge variant='destructive'>Drained</Badge>
)}
{keyStatus.is_expired && (
<Badge variant='destructive'>Expired</Badge>
)}
{!keyStatus.is_drained && !keyStatus.is_expired && (
<Badge>Active</Badge>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+1 -1
View File
@@ -239,7 +239,7 @@ export function ApiKeyManager({
className='gap-2'
>
<Trash2 className='h-4 w-4' />
{isRefunding ? 'Processing...' : 'Refund Key'}
{isRefunding ? 'Processing...' : 'Refund & Delete Key'}
</Button>
<span className='text-muted-foreground text-xs'>
Burns the key and returns a fresh Cashu token.
+135 -73
View File
@@ -1,17 +1,16 @@
'use client';
import { type JSX, useCallback, useState } from 'react';
import { Copy, RefreshCcw } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Copy, RefreshCcw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { KeyOptions } from '@/components/key-options';
import { ApiKeyInput } from '../api-key-input';
import type { WalletSnapshot } from './key-info-details';
import { useWalletInfo } from '@/hooks/use-wallet-info';
import { WalletBalanceStats } from './wallet-balance-stats';
import type { ChildKeyInfo, WalletSnapshot } from './key-info-details';
export type RefundReceipt = {
token?: string;
@@ -30,42 +29,80 @@ interface CashuPaymentWorkflowProps {
onRefundComplete?: (receipt: RefundReceipt) => void;
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
async function fetchWalletInfo(
baseUrl: string,
apiKey: string
): Promise<WalletSnapshot> {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load wallet info');
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
reserved?: number;
is_child: boolean;
parent_key: string | null;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
child_keys?: ChildKeyInfo[];
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
export function CashuPaymentWorkflow({
baseUrl,
apiKey = '',
walletInfo: propWalletInfo = null,
walletInfo = null,
onApiKeyCreated,
onApiKeyChanged,
onWalletInfoUpdated,
onRefundComplete,
}: CashuPaymentWorkflowProps): JSX.Element {
const [initialToken, setInitialToken] = useState('');
const [topupToken, setTopupToken] = useState('');
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isCreatingKey, setIsCreatingKey] = useState(false);
const [isTopupLoading, setIsTopupLoading] = useState(false);
const [isRefunding, setIsRefunding] = useState(false);
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
const [hasInteractedManage, setHasInteractedManage] = useState(false);
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
const [balanceLimit, setBalanceLimit] = useState<string>('');
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
const [validityDate, setValidityDate] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const activeApiKey = apiKeyInput.trim();
const {
data: queryWalletInfo,
refetch,
isFetching,
} = useWalletInfo(baseUrl, activeApiKey);
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
const handleCopy = useCallback(async (value: string): Promise<void> => {
if (!value) {
return;
@@ -166,18 +203,20 @@ export function CashuPaymentWorkflow({
return;
}
setError(null);
setIsSyncingBalance(true);
try {
await refetch();
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
onWalletInfoUpdated?.(snapshot);
toast.success('Balance synced');
} catch (error) {
console.error(error);
const message =
error instanceof Error ? error.message : 'Failed to sync balance';
setError(message);
toast.error(message);
toast.error(
error instanceof Error ? error.message : 'Failed to sync balance'
);
} finally {
setIsSyncingBalance(false);
}
}, [activeApiKey, refetch]);
}, [activeApiKey, baseUrl, onWalletInfoUpdated]);
const handleTopup = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
@@ -206,25 +245,59 @@ export function CashuPaymentWorkflow({
const payload = (await response.json()) as { msats: number };
toast.success(`Added ${formatSats(payload.msats)} sats`);
setTopupToken('');
await refetch();
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
onApiKeyCreated?.(snapshot.apiKey, snapshot);
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Top-up failed');
} finally {
setIsTopupLoading(false);
}
}, [activeApiKey, baseUrl, topupToken, refetch]);
}, [activeApiKey, baseUrl, topupToken, onApiKeyCreated]);
const handleRefund = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsRefunding(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/refund`, {
method: 'POST',
headers: {
Authorization: `Bearer ${activeApiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Refund failed');
}
const payload = (await response.json()) as RefundReceipt;
onRefundComplete?.(payload);
onWalletInfoUpdated?.(null);
setApiKeyInput('');
toast.success('Refund requested');
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Refund failed');
} finally {
setIsRefunding(false);
}
}, [activeApiKey, baseUrl, onRefundComplete, onWalletInfoUpdated]);
const handleApiKeyChange = useCallback(
(newKey: string) => {
setApiKeyInput(newKey);
onApiKeyChanged?.(newKey);
if (newKey !== apiKey) {
onWalletInfoUpdated?.(null);
}
},
[apiKey, onWalletInfoUpdated]
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
);
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
const canTopup = Boolean(activeApiKey);
const showCreateDetails = initialToken.trim().length > 0;
@@ -292,73 +365,40 @@ export function CashuPaymentWorkflow({
)}
</header>
<div className='flex flex-col gap-2 sm:flex-row'>
<ApiKeyInput
<Input
value={apiKeyInput}
onApiKeyChange={handleApiKeyChange}
onChange={(event) => handleApiKeyChange(event.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
onFocus={() => setHasInteractedManage(true)}
/>
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10'
onClick={() => handleCopy(activeApiKey)}
disabled={!activeApiKey}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='secondary'
size='sm'
className='gap-1'
onClick={handleSyncBalance}
disabled={isFetching || !activeApiKey}
disabled={isSyncingBalance || !activeApiKey}
>
<RefreshCcw
className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`}
/>
<RefreshCcw className='h-4 w-4' />
Sync
</Button>
</div>
</div>
{walletInfo && (
<div className='bg-muted/30 mt-2 space-y-2 rounded-lg p-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Spent
</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
</div>
)}
{error && (
<Alert variant='destructive' className='mt-2'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
{showManageDetails && (
<WalletBalanceStats
balanceMsats={walletInfo?.balanceMsats}
reservedMsats={walletInfo?.reservedMsats}
/>
)}
</section>
@@ -404,6 +444,28 @@ export function CashuPaymentWorkflow({
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>4 · Refund</span>
</header>
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleRefund}
disabled={isRefunding || !activeApiKey}
variant='destructive'
className='gap-2'
>
<Trash2 className='h-4 w-4' />
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
</Button>
<span className='text-muted-foreground text-xs'>
Burns the key and returns a fresh Cashu token.
</span>
</div>
</section>
</CardContent>
</Card>
);
+19 -7
View File
@@ -18,6 +18,7 @@ import {
type RefundReceipt,
} from './cashu-payment-workflow';
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
import { ApiKeyManager } from './api-key-manager';
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
import { ChildKeyCreator } from '@/components/child-key-creator';
@@ -134,8 +135,8 @@ export function CheatSheet(): JSX.Element {
const handleRefundComplete = useCallback((receipt: RefundReceipt) => {
setRefundReceipt(receipt);
// setWalletInfo(null); // Keep info
// setApiKeyInput(''); // Keep input
setWalletInfo(null);
setApiKeyInput('');
}, []);
const handleRefreshInfo = useCallback(async (): Promise<void> => {
@@ -390,11 +391,12 @@ export function CheatSheet(): JSX.Element {
</section>
<Tabs defaultValue='cashu' className='w-full'>
<TabsList className='grid w-full grid-cols-4'>
<TabsList className='grid w-full grid-cols-5'>
<TabsTrigger value='cashu'>Cashu</TabsTrigger>
<TabsTrigger value='lightning'>Lightning</TabsTrigger>
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
<TabsTrigger value='details'>Key Details</TabsTrigger>
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
<TabsTrigger value='management'>Key Management</TabsTrigger>
</TabsList>
<TabsContent value='cashu' className='space-y-4'>
@@ -411,8 +413,8 @@ export function CheatSheet(): JSX.Element {
/>
</TabsContent>
<TabsContent value='management' className='space-y-4'>
<KeyInfoDetails
<TabsContent value='manage' className='space-y-4'>
<ApiKeyManager
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
walletInfo={walletInfo}
@@ -443,7 +445,7 @@ export function CheatSheet(): JSX.Element {
<Textarea
value={refundToken}
readOnly
rows={15}
rows={4}
className='font-mono text-xs'
/>
</div>
@@ -452,6 +454,16 @@ export function CheatSheet(): JSX.Element {
)}
</TabsContent>
<TabsContent value='details' className='space-y-4'>
<KeyInfoDetails
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
walletInfo={walletInfo}
onApiKeyChanged={handleApiKeyChanged}
onWalletInfoUpdated={handleWalletInfoUpdated}
/>
</TabsContent>
<TabsContent value='child-keys' className='space-y-4'>
<ChildKeyCreator
baseUrl={normalizedBaseUrl}
+73 -81
View File
@@ -1,10 +1,8 @@
'use client';
import { useWalletInfo } from '@/hooks/use-wallet-info';
import type { RefundReceipt } from './cashu-payment-workflow';
import { type JSX, useState, useCallback, useEffect } from 'react';
import { Copy, RefreshCcw, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { ApiKeyInput } from '../api-key-input';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
Card,
CardContent,
@@ -14,9 +12,8 @@ import {
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { WalletService } from '@/lib/api/services/wallet';
import React, { useState, useCallback, useEffect } from 'react';
import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react';
export type ChildKeyInfo = {
api_key: string;
@@ -47,44 +44,68 @@ interface KeyInfoDetailsProps {
walletInfo?: WalletSnapshot | null;
onApiKeyChanged?: (apiKey: string) => void;
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
onRefundComplete?: (receipt: RefundReceipt) => void;
}
export function KeyInfoDetails({
baseUrl,
apiKey = '',
walletInfo: propWalletInfo = null,
walletInfo = null,
onApiKeyChanged,
onWalletInfoUpdated,
onRefundComplete,
}: KeyInfoDetailsProps): React.ReactNode {
}: KeyInfoDetailsProps): JSX.Element {
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isResetting, setIsResetting] = useState<string | null>(null);
const [isRefunding, setIsRefunding] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
data: queryWalletInfo,
refetch,
isFetching,
} = useWalletInfo(baseUrl, apiKeyInput);
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
// Sync internal state with props if they change
useEffect(() => {
setApiKeyInput(apiKey);
}, [apiKey]);
const handleRefresh = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const fetchDetails = useCallback(
async (keyToFetch: string) => {
setIsRefreshing(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
headers: { Authorization: `Bearer ${keyToFetch}` },
});
if (!response.ok) {
throw new Error('Failed to fetch key info');
}
const payload = await response.json();
const snapshot: WalletSnapshot = {
apiKey: payload.api_key || keyToFetch,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
onWalletInfoUpdated?.(snapshot);
toast.success('Key details synced');
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to fetch details'
);
} finally {
setIsRefreshing(false);
}
},
[baseUrl, onWalletInfoUpdated]
);
const handleRefresh = async () => {
if (!apiKeyInput) return;
await refetch();
await fetchDetails(apiKeyInput);
};
const handleKeyChange = (newKey: string) => {
setApiKeyInput(newKey);
setError(null);
onApiKeyChanged?.(newKey);
// Optionally clear info when key changes
if (newKey !== apiKey) {
@@ -104,7 +125,7 @@ export function KeyInfoDetails({
try {
await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey);
toast.success('Child key spent reset');
await refetch();
await fetchDetails(apiKeyInput);
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to reset child key'
@@ -114,36 +135,6 @@ export function KeyInfoDetails({
}
};
const handleRefund = useCallback(async (): Promise<void> => {
if (!apiKeyInput) {
toast.error('Paste an API key first');
return;
}
setIsRefunding(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/refund`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKeyInput}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Refund failed');
}
const receipt = (await response.json()) as RefundReceipt;
onRefundComplete?.(receipt);
toast.success('Refund completed');
await refetch();
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Refund failed');
} finally {
setIsRefunding(false);
}
}, [apiKeyInput, baseUrl, onRefundComplete, refetch]);
const formatSats = (msats: number) =>
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
const formatMsats = (msats: number) =>
@@ -162,11 +153,17 @@ export function KeyInfoDetails({
</CardHeader>
<CardContent>
<div className='flex flex-col gap-2 sm:flex-row'>
<ApiKeyInput value={apiKeyInput} onApiKeyChange={handleKeyChange} />
<Input
value={apiKeyInput}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10 shrink-0'
onClick={() => handleCopy(apiKeyInput)}
disabled={!apiKeyInput}
>
@@ -177,22 +174,15 @@ export function KeyInfoDetails({
size='sm'
className='min-w-[80px] gap-1'
onClick={handleRefresh}
disabled={isFetching || !apiKeyInput}
type='button'
disabled={isRefreshing || !apiKeyInput}
>
<RefreshCcw
className={`h-8 w-4 ${isFetching ? 'animate-spin' : ''}`}
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
/>
{isFetching ? 'Syncing...' : 'Sync'}
{isRefreshing ? 'Syncing...' : 'Sync'}
</Button>
</div>
</div>
{error && (
<Alert variant='destructive' className='mt-2'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
@@ -238,14 +228,6 @@ export function KeyInfoDetails({
{formatDate(walletInfo.validityDate)}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Infos</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
@@ -254,6 +236,14 @@ export function KeyInfoDetails({
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Consumption</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
@@ -400,16 +390,18 @@ export function KeyInfoDetails({
</Card>
)}
<div className='flex justify-center gap-4'>
<div className='flex justify-center'>
<Button
onClick={handleRefund}
disabled={isRefunding || !apiKeyInput}
variant='destructive'
variant='ghost'
size='sm'
className='gap-2'
onClick={handleRefresh}
disabled={isRefreshing}
className='text-muted-foreground'
>
<Trash2 className='h-4 w-4' />
{isRefunding ? 'Processing...' : 'Refund Key'}
<RefreshCcw
className={`mr-2 h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`}
/>
Last synced: {new Date().toLocaleTimeString()}
</Button>
</div>
</>
-198
View File
@@ -1,198 +0,0 @@
'use client';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Copy, RotateCcw } from 'lucide-react';
import type { WalletSnapshot } from './key-info-details';
import { toast } from 'sonner';
interface KeyInfoDisplayProps {
walletInfo: WalletSnapshot;
onResetSpent?: (childKey: string) => Promise<void>;
isResetting?: string | null;
}
const formatSats = (msats: number) =>
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
const formatMsats = (msats: number) =>
new Intl.NumberFormat('en-US').format(msats);
const formatDate = (timestamp: number | null) =>
timestamp ? new Date(timestamp * 1000).toLocaleDateString() : 'Never';
export function KeyInfoDisplay({
walletInfo,
onResetSpent,
isResetting,
}: KeyInfoDisplayProps) {
const handleCopy = (value: string) => {
navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
};
return (
<div className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Status & Identity</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Type</span>
<Badge variant={walletInfo.isChild ? 'secondary' : 'default'}>
{walletInfo.isChild ? 'Child Key' : 'Parent Key'}
</Badge>
</div>
{walletInfo.parentKey && (
<div className='space-y-1'>
<span className='text-muted-foreground text-xs tracking-wider'>
Parent Key
</span>
<div className='flex items-center gap-2'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{walletInfo.parentKey}
</code>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(walletInfo.parentKey!)}
>
<Copy className='h-4 w-4' />
</Button>
</div>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Validity</span>
<span className='text-sm font-medium'>
{formatDate(walletInfo.validityDate)}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Infos</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Total Spent</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
{walletInfo.balanceLimit !== null && (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spend Limit
</span>
<span className='font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceLimit)} sats
</span>
</div>
{walletInfo.balanceLimitReset && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Reset Policy
</span>
<Badge variant='outline' className='capitalize'>
{walletInfo.balanceLimitReset}
</Badge>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
{!walletInfo.isChild &&
walletInfo.childKeys &&
walletInfo.childKeys.length > 0 && (
<Card>
<CardHeader>
<CardTitle className='text-lg'>
Child Keys ({walletInfo.childKeys.length})
</CardTitle>
<CardDescription>
Secondary keys using this account&apos;s balance
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
{walletInfo.childKeys.map((ck) => (
<div
key={ck.api_key}
className='space-y-3 rounded-lg border p-4'
>
<div className='flex items-center justify-between gap-4'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{ck.api_key}
</code>
<div className='flex gap-1'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(ck.api_key)}
>
<Copy className='h-4 w-4' />
</Button>
{onResetSpent && (
<Button
variant='ghost'
size='icon'
className='text-destructive h-8 w-8'
title='Reset consumption'
disabled={isResetting === ck.api_key}
onClick={() => onResetSpent(ck.api_key)}
>
{isResetting === ck.api_key ? (
<RotateCcw className='h-4 w-4 animate-spin' />
) : (
<RotateCcw className='h-4 w-4' />
)}
</Button>
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}
+1 -1
View File
@@ -23,7 +23,7 @@ const PAGE_META: Record<string, { title: string; description: string }> = {
title: 'System Logs',
description: 'Inspect request and application logs.',
},
'/model': {
'/models': {
title: 'Models',
description: 'Manage model catalog and provider mappings.',
},
+25 -6
View File
@@ -1,13 +1,26 @@
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals';
import nextTypescript from 'eslint-config-next/typescript';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactPlugin from 'eslint-plugin-react';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import nextPlugin from '@next/eslint-plugin-next';
const eslintConfig = [
...nextCoreWebVitals,
...nextTypescript,
{
plugins: {
'react-hooks': reactHooksPlugin,
react: reactPlugin,
jsxA11y: jsxA11yPlugin,
'@next/next': nextPlugin,
},
rules: {
'react-hooks/set-state-in-effect': 'off',
'react-hooks/incompatible-library': 'off',
...reactHooksPlugin.configs.recommended.rules,
...reactPlugin.configs.recommended.rules,
...jsxA11yPlugin.configs.recommended.rules,
...nextPlugin.configs.recommended.rules,
'react/no-unknown-property': 'off',
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@next/next/no-html-link-for-pages': 'off',
'@next/next/no-img-element': 'off',
},
settings: {
react: {
@@ -15,6 +28,12 @@ const eslintConfig = [
},
},
},
{
rules: {
'react-hooks/set-state-in-effect': 'off',
'react-hooks/incompatible-library': 'off',
},
},
];
export default eslintConfig;
-38
View File
@@ -1,38 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import type { WalletSnapshot } from '@/components/landing/key-info-details';
export function useWalletInfo(baseUrl: string, apiKey: string) {
return useQuery({
queryKey: ['walletInfo', baseUrl, apiKey],
queryFn: async (): Promise<WalletSnapshot> => {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load wallet info');
}
const payload = await response.json();
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
},
enabled: !!baseUrl && !!apiKey,
staleTime: 5000, // Consider data stale after 5 seconds
});
}
+4
View File
@@ -84,6 +84,10 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"@next/eslint-plugin-next": "^16.1.6",
"@typescript-eslint/parser": "^8.0.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.0",
+60 -2
View File
@@ -186,6 +186,9 @@ importers:
'@eslint/eslintrc':
specifier: ^3.3.3
version: 3.3.3
'@next/eslint-plugin-next':
specifier: ^16.1.6
version: 16.1.6
'@tailwindcss/postcss':
specifier: ^4.2.0
version: 4.2.1
@@ -204,6 +207,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@typescript-eslint/parser':
specifier: ^8.0.0
version: 8.57.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
eslint:
specifier: ^9.7.0
version: 9.38.0(jiti@2.6.1)
@@ -213,12 +219,18 @@ importers:
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@9.38.0(jiti@2.6.1))
eslint-plugin-jsx-a11y:
specifier: ^6.10.0
version: 6.10.2(eslint@9.38.0(jiti@2.6.1))
eslint-plugin-prettier:
specifier: ^5.5.5
version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1))(prettier@3.8.1)
eslint-plugin-react:
specifier: ^7.37.5
version: 7.37.5(eslint@9.38.0(jiti@2.6.1))
eslint-plugin-react-hooks:
specifier: ^5.2.0
version: 5.2.0(eslint@9.38.0(jiti@2.6.1))
prettier:
specifier: ^3.8.1
version: 3.8.1
@@ -474,89 +486,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -623,24 +651,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
@@ -1512,24 +1544,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
@@ -1744,41 +1780,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -2264,6 +2308,12 @@ packages:
eslint-config-prettier:
optional: true
eslint-plugin-react-hooks@5.2.0:
resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==}
engines: {node: '>=10'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
eslint-plugin-react-hooks@7.0.1:
resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
engines: {node: '>=18'}
@@ -2739,24 +2789,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -5164,7 +5218,7 @@ snapshots:
call-bind: 1.0.8
call-bound: 1.0.4
define-properties: 1.2.1
es-abstract: 1.24.0
es-abstract: 1.24.1
es-object-atoms: 1.1.1
get-intrinsic: 1.3.0
is-string: 1.1.1
@@ -5200,7 +5254,7 @@ snapshots:
dependencies:
call-bind: 1.0.8
define-properties: 1.2.1
es-abstract: 1.24.0
es-abstract: 1.24.1
es-shim-unscopables: 1.1.0
array.prototype.tosorted@1.1.4:
@@ -5754,6 +5808,10 @@ snapshots:
optionalDependencies:
eslint-config-prettier: 10.1.8(eslint@9.38.0(jiti@2.6.1))
eslint-plugin-react-hooks@5.2.0(eslint@9.38.0(jiti@2.6.1)):
dependencies:
eslint: 9.38.0(jiti@2.6.1)
eslint-plugin-react-hooks@7.0.1(eslint@9.38.0(jiti@2.6.1)):
dependencies:
'@babel/core': 7.29.0