add transaction view

This commit is contained in:
9qeklajc
2026-03-11 23:39:22 +01:00
parent f56ba92ae8
commit 2c2124952f
11 changed files with 503 additions and 60 deletions
@@ -1,4 +1,4 @@
"""add cashu_refunds table """add cashu_transactions table
Revision ID: a776ca70e5fe Revision ID: a776ca70e5fe
Revises: 614c0a740e68 Revises: 614c0a740e68
@@ -10,25 +10,33 @@ import sqlmodel
from alembic import op from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'a776ca70e5fe' revision = "a776ca70e5fe"
down_revision = '614c0a740e68' down_revision = "614c0a740e68"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
def upgrade() -> None: def upgrade() -> None:
op.create_table( op.create_table(
'cashu_refunds', "cashu_transactions",
sa.Column('payment_token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('refund_token', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('amount', sa.Integer(), nullable=False), sa.Column("amount", sa.Integer(), nullable=False),
sa.Column('unit', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('mint_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.Integer(), nullable=False), sa.Column(
sa.Column('collected', sa.Boolean(), nullable=False), "type",
sa.Column('swept', sa.Boolean(), nullable=False), sqlmodel.sql.sqltypes.AutoString(),
sa.PrimaryKeyConstraint('payment_token_hash'), nullable=False,
server_default="out",
),
sa.Column("request_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.Integer(), nullable=False),
sa.Column("collected", sa.Boolean(), nullable=False),
sa.Column("swept", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
) )
def downgrade() -> None: def downgrade() -> None:
op.drop_table('cashu_refunds') op.drop_table("cashu_transactions")
+3 -3
View File
@@ -9,7 +9,7 @@ from pydantic import BaseModel
from sqlmodel import select from sqlmodel import select
from .auth import get_billing_key, validate_bearer_key from .auth import get_billing_key, validate_bearer_key
from .core.db import ApiKey, AsyncSession, CashuRefund, get_session from .core.db import ApiKey, AsyncSession, CashuTransaction, get_session
from .core.logging import get_logger from .core.logging import get_logger
from .core.settings import settings from .core.settings import settings
from .lightning import lightning_router from .lightning import lightning_router
@@ -407,7 +407,7 @@ async def get_cashu_refund(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> dict: ) -> dict:
"""Retrieve a stored Cashu refund token by the hash of the original payment token.""" """Retrieve a stored Cashu refund token by the hash of the original payment token."""
result = await session.get(CashuRefund, payment_token_hash) result = await session.get(CashuTransaction, payment_token_hash)
if result is None: if result is None:
raise HTTPException(status_code=404, detail="Refund not found") raise HTTPException(status_code=404, detail="Refund not found")
if result.swept: if result.swept:
@@ -416,7 +416,7 @@ async def get_cashu_refund(
session.add(result) session.add(result)
await session.commit() await session.commit()
return { return {
"refund_token": result.refund_token, "refund_token": result.token,
"amount": result.amount, "amount": result.amount,
"unit": result.unit, "unit": result.unit,
} }
+53 -4
View File
@@ -16,7 +16,7 @@ from ..wallet import (
send_token, send_token,
slow_filter_spend_proofs, slow_filter_spend_proofs,
) )
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session from .db import ApiKey, CashuTransaction, ModelRow, UpstreamProviderRow, create_session
from .log_manager import log_manager from .log_manager import log_manager
from .logging import get_logger from .logging import get_logger
from .settings import SettingsService, settings from .settings import SettingsService, settings
@@ -747,7 +747,9 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
) )
db_model_ids = {model.id for model in db_models} db_model_ids = {model.id for model in db_models}
filtered_remote_models = [m for m in upstream_models if m.id not in db_model_ids] filtered_remote_models = [
m for m in upstream_models if m.id not in db_model_ids
]
return { return {
"provider": { "provider": {
@@ -893,7 +895,9 @@ async def initiate_provider_topup(
"purpose": "topup", "purpose": "topup",
"api_key": provider.api_key, "api_key": provider.api_key,
}, },
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, headers={"Authorization": f"Bearer {provider.api_key}"}
if provider.api_key
else {},
) )
if resp.status_code == 200: if resp.status_code == 200:
@@ -977,7 +981,9 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
clean_url = provider.base_url.rstrip("/") clean_url = provider.base_url.rstrip("/")
resp = await client.get( resp = await client.get(
f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status", f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status",
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, headers={"Authorization": f"Bearer {provider.api_key}"}
if provider.api_key
else {},
) )
if resp.status_code == 200: if resp.status_code == 200:
status_data = resp.json() status_data = resp.json()
@@ -1208,6 +1214,49 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
return {"dates": dates} return {"dates": dates}
@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)])
async def get_transactions_api(
type: str | None = None,
status: str | None = None,
search: str | None = None,
limit: int = 100,
) -> dict:
async with create_session() as session:
from sqlmodel import col
stmt = select(CashuTransaction)
if type:
stmt = stmt.where(CashuTransaction.type == type)
if status:
if status == "collected":
stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712
elif status == "swept":
stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712
elif status == "pending":
stmt = stmt.where(
not CashuTransaction.collected,
CashuTransaction.swept == False, # noqa: E712
)
if search:
search_pattern = f"%{search}%"
stmt = stmt.where(
(col(CashuTransaction.id).like(search_pattern))
| (col(CashuTransaction.token).like(search_pattern))
| (col(CashuTransaction.request_id).like(search_pattern))
)
stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit)
results = await session.exec(stmt)
transactions = results.all()
return {
"transactions": [tx.dict() for tx in transactions],
"total": len(transactions),
}
@admin_router.post( @admin_router.post(
"/api/upstream-providers/{provider_id}/routstr/refund", "/api/upstream-providers/{provider_id}/routstr/refund",
dependencies=[Depends(require_admin_api)], dependencies=[Depends(require_admin_api)],
+47 -20
View File
@@ -7,6 +7,7 @@ from typing import AsyncGenerator
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from alembic.util.exc import CommandError
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
from sqlalchemy.ext.asyncio.engine import create_async_engine from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select, update from sqlmodel import Field, Relationship, SQLModel, func, select, update
@@ -128,19 +129,19 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
paid_at: int | None = Field(default=None, description="Unix timestamp when paid") paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
class CashuRefund(SQLModel, table=True): # type: ignore class CashuTransaction(SQLModel, table=True): # type: ignore
__tablename__ = "cashu_refunds" __tablename__ = "cashu_transactions"
payment_token_hash: str = Field( id: str = Field(
primary_key=True, primary_key=True,
description="SHA-256 hash of the original x-cashu payment token", description="SHA-256 hash of the x-cashu token or unique identifier",
) )
refund_token: str = Field(description="Serialized Cashu refund token") token: str = Field(description="Serialized Cashu token")
amount: int = Field(description="Refund amount in the token's unit") amount: int = Field(description="Amount in the token's unit")
unit: str = Field(description="Token unit (sat or msat)") unit: str = Field(description="Token unit (sat or msat)")
mint_url: str | None = Field( mint_url: str | None = Field(default=None, description="Mint URL for the token")
default=None, description="Mint URL for the refund token" type: str = Field(default="out", description="Transaction type: in or out")
) request_id: str | None = Field(default=None, description="Associated request ID")
created_at: int = Field( created_at: int = Field(
default_factory=lambda: int(time.time()), default_factory=lambda: int(time.time()),
description="Unix timestamp", description="Unix timestamp",
@@ -149,28 +150,32 @@ class CashuRefund(SQLModel, table=True): # type: ignore
swept: bool = Field(default=False) swept: bool = Field(default=False)
async def store_cashu_refund( async def store_cashu_transaction(
payment_token_hash: str, id: str,
refund_token: str, token: str,
amount: int, amount: int,
unit: str, unit: str,
mint_url: str | None = None, mint_url: str | None = None,
type: str = "out",
request_id: str | None = None,
) -> None: ) -> None:
try: try:
async with create_session() as session: async with create_session() as session:
refund = CashuRefund( tx = CashuTransaction(
payment_token_hash=payment_token_hash, id=id,
refund_token=refund_token, token=token,
amount=amount, amount=amount,
unit=unit, unit=unit,
mint_url=mint_url, mint_url=mint_url,
type=type,
request_id=request_id,
) )
session.add(refund) session.add(tx)
await session.commit() await session.commit()
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"Failed to store cashu refund", "Failed to store cashu transaction",
extra={"error": str(e), "payment_token_hash": payment_token_hash}, extra={"error": str(e), "id": id, "type": type},
) )
@@ -273,6 +278,17 @@ def fix_cashu_migrations() -> None:
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}") logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
def _clear_alembic_version() -> None:
"""Clear the alembic_version table so stamp/upgrade can proceed."""
sync_url = DATABASE_URL.replace("+aiosqlite", "")
from sqlalchemy import create_engine, text
eng = create_engine(sync_url)
with eng.begin() as conn:
conn.execute(text("DELETE FROM alembic_version"))
eng.dispose()
def run_migrations() -> None: def run_migrations() -> None:
"""Run Alembic migrations programmatically.""" """Run Alembic migrations programmatically."""
try: try:
@@ -294,8 +310,19 @@ def run_migrations() -> None:
# Set the database URL in the config # Set the database URL in the config
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL) alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Run migrations to the latest revision try:
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
except CommandError as e:
if "Can't locate revision" in str(e):
logger.warning(
"Database stamped with unknown revision (likely from another branch). "
"Re-stamping to current head.",
extra={"error": str(e)},
)
_clear_alembic_version()
command.stamp(alembic_cfg, "head")
else:
raise
logger.info("Database migrations completed successfully") logger.info("Database migrations completed successfully")
+5 -2
View File
@@ -238,9 +238,10 @@ class SettingsService:
except Exception: except Exception:
db_json = {} db_json = {}
valid_fields = set(env_resolved.dict().keys())
merged_dict: dict[str, Any] = dict(env_resolved.dict()) merged_dict: dict[str, Any] = dict(env_resolved.dict())
merged_dict.update( merged_dict.update(
{k: v for k, v in db_json.items() if v not in (None, "", [], {})} {k: v for k, v in db_json.items() if v not in (None, "", [], {}) and k in valid_fields}
) )
# Ensure primary_mint is consistent with cashu_mints if not explicitly set # Ensure primary_mint is consistent with cashu_mints if not explicitly set
@@ -306,8 +307,10 @@ class SettingsService:
raise RuntimeError("Settings row missing") raise RuntimeError("Settings row missing")
(data_str,) = row (data_str,) = row
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str) data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
valid_fields = set(settings.dict().keys())
# Update in-place # Update in-place
for k, v in data.items(): for k, v in data.items():
setattr(settings, k, v) if k in valid_fields:
setattr(settings, k, v)
cls._current = settings cls._current = settings
return settings return settings
+20 -9
View File
@@ -21,7 +21,7 @@ from ..core.db import (
AsyncSession, AsyncSession,
UpstreamProviderRow, UpstreamProviderRow,
create_session, create_session,
store_cashu_refund, store_cashu_transaction,
) )
from ..core.exceptions import UpstreamError from ..core.exceptions import UpstreamError
from ..payment.cost_calculation import ( from ..payment.cost_calculation import (
@@ -1725,15 +1725,16 @@ class BaseUpstreamProvider:
if payment_token_hash: if payment_token_hash:
try: try:
await store_cashu_refund( await store_cashu_transaction(
payment_token_hash=payment_token_hash, id=payment_token_hash,
refund_token=refund_token, token=refund_token,
amount=amount, amount=amount,
unit=unit, unit=unit,
mint_url=mint, mint_url=mint,
type="out",
) )
except Exception: except Exception:
pass # store_cashu_refund already logs pass # store_cashu_transaction already logs
return refund_token return refund_token
except Exception as e: except Exception as e:
@@ -2033,8 +2034,13 @@ class BaseUpstreamProvider:
response.headers["X-Cashu"] = refund_token response.headers["X-Cashu"] = refund_token
if payment_token_hash: if payment_token_hash:
try: try:
await store_cashu_refund( await store_cashu_transaction(
payment_token_hash, refund_token, emergency_refund, unit, mint id=payment_token_hash,
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
type="out",
) )
except Exception: except Exception:
pass pass
@@ -2899,8 +2905,13 @@ class BaseUpstreamProvider:
response.headers["X-Cashu"] = refund_token response.headers["X-Cashu"] = refund_token
if payment_token_hash: if payment_token_hash:
try: try:
await store_cashu_refund( await store_cashu_transaction(
payment_token_hash, refund_token, emergency_refund, unit, mint id=payment_token_hash,
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
type="out",
) )
except Exception: except Exception:
pass pass
+25 -8
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import hashlib
import math import math
import time import time
from typing import TypedDict from typing import TypedDict
@@ -35,6 +36,21 @@ async def recieve_token(
wallet.verify_proofs_dleq(token_obj.proofs) wallet.verify_proofs_dleq(token_obj.proofs)
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True) await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
# Store incoming transaction
try:
token_hash = hashlib.sha256(token.encode()).hexdigest()
await db.store_cashu_transaction(
id=token_hash,
token=token,
amount=token_obj.amount,
unit=token_obj.unit,
mint_url=token_obj.mint,
type="in",
)
except Exception:
pass
return token_obj.amount, token_obj.unit, token_obj.mint return token_obj.amount, token_obj.unit, token_obj.mint
@@ -359,23 +375,24 @@ async def periodic_refund_sweep() -> None:
try: try:
cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds
async with db.create_session() as session: async with db.create_session() as session:
stmt = select(db.CashuRefund).where( stmt = select(db.CashuTransaction).where(
db.CashuRefund.collected == False, # noqa: E712 db.CashuTransaction.type == "out",
db.CashuRefund.swept == False, # noqa: E712 db.CashuTransaction.collected == False, # noqa: E712
db.CashuRefund.created_at < cutoff, db.CashuTransaction.swept == False, # noqa: E712
db.CashuTransaction.created_at < cutoff,
) )
results = await session.exec(stmt) results = await session.exec(stmt)
refunds = results.all() refunds = results.all()
for refund in refunds: for refund in refunds:
try: try:
await recieve_token(refund.refund_token) await recieve_token(refund.token)
refund.swept = True refund.swept = True
session.add(refund) session.add(refund)
logger.info( logger.info(
"Swept uncollected refund", "Swept uncollected refund",
extra={ extra={
"payment_token_hash": refund.payment_token_hash, "id": refund.id,
"amount": refund.amount, "amount": refund.amount,
"unit": refund.unit, "unit": refund.unit,
}, },
@@ -388,14 +405,14 @@ async def periodic_refund_sweep() -> None:
logger.info( logger.info(
"Refund already spent (client collected), marking swept", "Refund already spent (client collected), marking swept",
extra={ extra={
"payment_token_hash": refund.payment_token_hash, "id": refund.id,
}, },
) )
else: else:
logger.warning( logger.warning(
"Failed to sweep refund", "Failed to sweep refund",
extra={ extra={
"payment_token_hash": refund.payment_token_hash, "id": refund.id,
"error": str(e), "error": str(e),
}, },
) )
+285
View File
@@ -0,0 +1,285 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import {
RefreshCw,
Search,
ArrowDownLeft,
ArrowUpRight,
Copy,
Check,
} from 'lucide-react';
import { AdminService, type Transaction } from '@/lib/api/services/admin';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
export default function TransactionsPage() {
const [search, setSearch] = useState('');
const [type, setType] = useState<string>('all');
const [status, setStatus] = useState<string>('all');
const [copiedId, setCopiedId] = useState<string | null>(null);
const { data, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['transactions', type, status, search],
queryFn: () =>
AdminService.getTransactions(
type === 'all' ? undefined : type,
status === 'all' ? undefined : status,
search || undefined,
100
),
});
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
toast.success('Copied to clipboard');
setTimeout(() => setCopiedId(null), 2000);
};
const getStatusBadge = (tx: Transaction) => {
if (tx.swept)
return (
<Badge
variant='outline'
className='border-orange-500/20 bg-orange-500/10 text-orange-500'
>
Swept
</Badge>
);
if (tx.collected)
return (
<Badge
variant='outline'
className='border-green-500/20 bg-green-500/10 text-green-500'
>
Collected
</Badge>
);
return (
<Badge
variant='outline'
className='border-blue-500/20 bg-blue-500/10 text-blue-500'
>
Pending
</Badge>
);
};
return (
<AppPageShell contentClassName='mx-auto w-full max-w-7xl'>
<div className='space-y-6'>
<PageHeader
title='Transactions'
description='View all incoming and outgoing X-Cashu token transactions.'
actions={
<Button
onClick={() => refetch()}
variant='outline'
size='sm'
disabled={isRefetching}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefetching ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
}
/>
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
<div className='md:col-span-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder='Search by ID, token or request ID...'
className='pl-8'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<Select value={type} onValueChange={setType}>
<SelectTrigger>
<SelectValue placeholder='Type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Types</SelectItem>
<SelectItem value='in'>Incoming (Payments)</SelectItem>
<SelectItem value='out'>Outgoing (Refunds)</SelectItem>
</SelectContent>
</Select>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder='Status' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Statuses</SelectItem>
<SelectItem value='pending'>Pending</SelectItem>
<SelectItem value='collected'>Collected</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardHeader>
<CardTitle>Transaction History</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className='space-y-2'>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className='h-12 w-full' />
))}
</div>
) : (
<ScrollArea className='h-[600px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>ID / Request ID</TableHead>
<TableHead>Mint</TableHead>
<TableHead>Date</TableHead>
<TableHead className='text-right'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.transactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell>
<div className='flex items-center gap-2'>
{tx.type === 'in' ? (
<ArrowDownLeft className='h-4 w-4 text-green-500' />
) : (
<ArrowUpRight className='h-4 w-4 text-blue-500' />
)}
<span className='capitalize'>{tx.type}</span>
</div>
</TableCell>
<TableCell className='font-mono'>
{tx.amount} {tx.unit}
</TableCell>
<TableCell>{getStatusBadge(tx)}</TableCell>
<TableCell>
<div className='flex flex-col gap-1'>
<div className='text-muted-foreground flex items-center gap-1 text-xs'>
<span className='max-w-[100px] truncate font-mono'>
{tx.id}
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() =>
copyToClipboard(tx.id, tx.id + '-id')
}
>
{copiedId === tx.id + '-id' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
{tx.request_id && (
<div className='text-primary flex items-center gap-1 text-xs'>
<span className='max-w-[100px] truncate'>
{tx.request_id}
</span>
<Button
variant='ghost'
size='icon'
className='text-primary h-4 w-4'
onClick={() =>
copyToClipboard(
tx.request_id!,
tx.id + '-req'
)
}
>
{copiedId === tx.id + '-req' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
)}
</div>
</TableCell>
<TableCell>
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
<span className='truncate'>{tx.mint_url}</span>
</div>
</TableCell>
<TableCell className='text-xs whitespace-nowrap'>
{formatDistanceToNow(tx.created_at * 1000, {
addSuffix: true,
})}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
copyToClipboard(tx.token, tx.id + '-token')
}
title='Copy Token'
>
{copiedId === tx.id + '-token' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</TableCell>
</TableRow>
))}
{data?.transactions.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className='text-muted-foreground py-10 text-center'
>
No transactions found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent>
</Card>
</div>
</AppPageShell>
);
}
+2
View File
@@ -13,6 +13,7 @@ import {
ServerIcon, ServerIcon,
SettingsIcon, SettingsIcon,
WalletIcon, WalletIcon,
ArrowRightLeftIcon,
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -41,6 +42,7 @@ const NAV_ITEMS = [
{ title: 'Logs', url: '/logs', icon: FileTextIcon }, { title: 'Logs', url: '/logs', icon: FileTextIcon },
{ title: 'Models', url: '/models', icon: DatabaseIcon }, { title: 'Models', url: '/models', icon: DatabaseIcon },
{ title: 'Providers', url: '/providers', icon: ServerIcon }, { title: 'Providers', url: '/providers', icon: ServerIcon },
{ title: 'Transactions', url: '/transactions', icon: ArrowRightLeftIcon },
{ title: 'Settings', url: '/settings', icon: SettingsIcon }, { title: 'Settings', url: '/settings', icon: SettingsIcon },
] as const; ] as const;
+6
View File
@@ -9,6 +9,7 @@ import {
ServerIcon, ServerIcon,
SettingsIcon, SettingsIcon,
WalletIcon, WalletIcon,
ArrowRightLeftIcon,
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
@@ -45,6 +46,11 @@ const data = {
url: '/balances', url: '/balances',
icon: WalletIcon, icon: WalletIcon,
}, },
{
title: 'Transactions',
url: '/transactions',
icon: ArrowRightLeftIcon,
},
{ {
title: 'Logs', title: 'Logs',
url: '/logs', url: '/logs',
+35
View File
@@ -869,6 +869,23 @@ export class AdminService {
); );
} }
static async getTransactions(
type?: string,
status?: string,
search?: string,
limit: number = 100
): Promise<TransactionsResponse> {
const params = new URLSearchParams();
if (type) params.append('type', type);
if (status) params.append('status', status);
if (search) params.append('search', search);
params.append('limit', limit.toString());
return await apiClient.get<TransactionsResponse>(
`/admin/api/transactions?${params.toString()}`
);
}
static async createProviderAccountByType(providerType: string): Promise<{ static async createProviderAccountByType(providerType: string): Promise<{
ok: boolean; ok: boolean;
account_data: Record<string, unknown>; account_data: Record<string, unknown>;
@@ -1042,3 +1059,21 @@ export interface LogResponse {
search: string | null; search: string | null;
limit: number; limit: number;
} }
export interface Transaction {
id: string;
token: string;
amount: number;
unit: string;
mint_url: string;
type: 'in' | 'out';
request_id?: string;
created_at: number;
collected: boolean;
swept: boolean;
}
export interface TransactionsResponse {
transactions: Transaction[];
total: number;
}