chore: sync submodule state (parent ref update)

Made-with: Cursor
This commit is contained in:
defiQUG
2026-03-02 12:14:09 -08:00
parent 50ab378da9
commit 5efe36b1e0
1100 changed files with 155024 additions and 8674 deletions

View File

@@ -0,0 +1,36 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './stores/authStore';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import ApiKeys from './pages/ApiKeys';
import Endpoints from './pages/Endpoints';
import DexFactories from './pages/DexFactories';
import Layout from './components/Layout';
import ProtectedRoute from './components/ProtectedRoute';
function App() {
const { isAuthenticated } = useAuthStore();
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={isAuthenticated ? <Navigate to="/" replace /> : <Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="api-keys" element={<ApiKeys />} />
<Route path="endpoints" element={<Endpoints />} />
<Route path="dex-factories" element={<DexFactories />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1,71 @@
import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import { LogOut, Key, Network, Factory, BarChart3 } from 'lucide-react';
import toast from 'react-hot-toast';
export default function Layout() {
const { user, logout } = useAuthStore();
const location = useLocation();
const handleLogout = () => {
logout();
toast.success('Logged out successfully');
};
const navigation = [
{ name: 'Dashboard', href: '/', icon: BarChart3 },
{ name: 'API Keys', href: '/api-keys', icon: Key },
{ name: 'Endpoints', href: '/endpoints', icon: Network },
{ name: 'DEX Factories', href: '/dex-factories', icon: Factory },
];
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<h1 className="text-xl font-bold text-gray-900">Token Aggregation Control Panel</h1>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
{navigation.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
isActive
? 'border-primary-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
}`}
>
<Icon className="mr-2 h-4 w-4" />
{item.name}
</Link>
);
})}
</div>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700">{user?.username}</span>
<button
onClick={handleLogout}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none"
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</button>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
}
body {
margin: 0;
min-height: 100vh;
background-color: #f9fafb;
}
#root {
min-height: 100vh;
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<Toaster position="top-right" />
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,225 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../services/api';
import { useState } from 'react';
import toast from 'react-hot-toast';
import { Plus, Trash2, Eye, EyeOff } from 'lucide-react';
interface ApiKey {
id: number;
provider: string;
keyName: string;
isActive: boolean;
rateLimitPerMinute?: number;
rateLimitPerDay?: number;
expiresAt?: string;
createdAt: string;
}
export default function ApiKeys() {
const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<{ apiKeys: ApiKey[] }>({
queryKey: ['api-keys'],
queryFn: async () => {
const response = await api.get('/api/v1/admin/api-keys');
return response.data;
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
await api.delete(`/api/v1/admin/api-keys/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
toast.success('API key deleted');
},
onError: () => {
toast.error('Failed to delete API key');
},
});
const toggleActiveMutation = useMutation({
mutationFn: async ({ id, isActive }: { id: number; isActive: boolean }) => {
await api.put(`/api/v1/admin/api-keys/${id}`, { isActive: !isActive });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
toast.success('API key updated');
},
});
if (isLoading) {
return <div className="text-center py-12">Loading...</div>;
}
return (
<div className="px-4 py-6 sm:px-0">
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">API Keys</h1>
<p className="mt-2 text-sm text-gray-600">Manage external API keys for data enrichment</p>
</div>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700"
>
<Plus className="h-4 w-4 mr-2" />
Add API Key
</button>
</div>
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{data?.apiKeys.map((key) => (
<li key={key.id} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center">
<span className="text-sm font-medium text-gray-900">{key.keyName}</span>
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{key.provider}
</span>
{key.isActive ? (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Active
</span>
) : (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Inactive
</span>
)}
</div>
<div className="mt-2 text-sm text-gray-500">
Created: {new Date(key.createdAt).toLocaleDateString()}
{key.expiresAt && ` • Expires: ${new Date(key.expiresAt).toLocaleDateString()}`}
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => toggleActiveMutation.mutate({ id: key.id, isActive: key.isActive })}
className="text-gray-400 hover:text-gray-600"
>
{key.isActive ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
<button
onClick={() => deleteMutation.mutate(key.id)}
className="text-red-400 hover:text-red-600"
>
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</li>
))}
</ul>
{data?.apiKeys.length === 0 && (
<div className="text-center py-12 text-gray-500">No API keys configured</div>
)}
</div>
{showAddModal && (
<AddApiKeyModal
onClose={() => {
setShowAddModal(false);
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
}}
/>
)}
</div>
);
}
function AddApiKeyModal({ onClose }: { onClose: () => void }) {
const [formData, setFormData] = useState({
provider: 'coingecko',
keyName: '',
apiKey: '',
rateLimitPerMinute: '',
rateLimitPerDay: '',
expiresAt: '',
});
const createMutation = useMutation({
mutationFn: async (data: any) => {
await api.post('/api/v1/admin/api-keys', data);
},
onSuccess: () => {
toast.success('API key added');
onClose();
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to add API key');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate({
...formData,
rateLimitPerMinute: formData.rateLimitPerMinute ? parseInt(formData.rateLimitPerMinute, 10) : undefined,
rateLimitPerDay: formData.rateLimitPerDay ? parseInt(formData.rateLimitPerDay, 10) : undefined,
expiresAt: formData.expiresAt || undefined,
});
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<h3 className="text-lg font-bold text-gray-900 mb-4">Add API Key</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Provider</label>
<select
value={formData.provider}
onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option value="coingecko">CoinGecko</option>
<option value="coinmarketcap">CoinMarketCap</option>
<option value="dexscreener">DexScreener</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Key Name</label>
<input
type="text"
required
value={formData.keyName}
onChange={(e) => setFormData({ ...formData, keyName: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">API Key</label>
<input
type="password"
required
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
/>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={createMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{createMutation.isPending ? 'Adding...' : 'Add'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { useQuery } from '@tanstack/react-query';
import api from '../services/api';
import { Key, Network, Factory, Activity } from 'lucide-react';
interface StatusData {
status: string;
stats: {
apiKeys: { total: number; active: number };
endpoints: { total: number; active: number };
factories: { total: number; active: number };
};
}
export default function Dashboard() {
const { data, isLoading } = useQuery<StatusData>({
queryKey: ['admin-status'],
queryFn: async () => {
const response = await api.get('/api/v1/admin/status');
return response.data;
},
refetchInterval: 30000,
});
if (isLoading) {
return <div className="text-center py-12">Loading...</div>;
}
const stats = [
{
name: 'API Keys',
value: data?.stats.apiKeys.active || 0,
total: data?.stats.apiKeys.total || 0,
icon: Key,
color: 'bg-blue-500',
},
{
name: 'Endpoints',
value: data?.stats.endpoints.active || 0,
total: data?.stats.endpoints.total || 0,
icon: Network,
color: 'bg-green-500',
},
{
name: 'DEX Factories',
value: data?.stats.factories.active || 0,
total: data?.stats.factories.total || 0,
icon: Factory,
color: 'bg-purple-500',
},
];
return (
<div className="px-4 py-6 sm:px-0">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="mt-2 text-sm text-gray-600">Service status and statistics</p>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3 mb-8">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div key={stat.name} className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className={`flex-shrink-0 ${stat.color} rounded-md p-3`}>
<Icon className="h-6 w-6 text-white" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">{stat.name}</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">{stat.value}</div>
<div className="ml-2 text-sm text-gray-500">of {stat.total}</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
);
})}
</div>
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Service Status</h3>
<div className="flex items-center">
<Activity
className={`h-5 w-5 mr-2 ${
data?.status === 'operational' ? 'text-green-500' : 'text-red-500'
}`}
/>
<span className="text-sm font-medium text-gray-900">
Status: {data?.status || 'Unknown'}
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,256 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../services/api';
import { useState } from 'react';
import toast from 'react-hot-toast';
import { Plus, Trash2 } from 'lucide-react';
interface DexFactory {
id: number;
chainId: number;
dexType: string;
factoryAddress: string;
routerAddress?: string;
poolManagerAddress?: string;
startBlock: number;
isActive: boolean;
description?: string;
}
export default function DexFactories() {
const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<{ factories: DexFactory[] }>({
queryKey: ['dex-factories'],
queryFn: async () => {
const response = await api.get('/api/v1/admin/dex-factories');
return response.data;
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
await api.put(`/api/v1/admin/dex-factories/${id}`, { isActive: false });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dex-factories'] });
toast.success('DEX factory deactivated');
},
});
if (isLoading) {
return <div className="text-center py-12">Loading...</div>;
}
return (
<div className="px-4 py-6 sm:px-0">
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">DEX Factories</h1>
<p className="mt-2 text-sm text-gray-600">Manage DEX factory addresses for pool discovery</p>
</div>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700"
>
<Plus className="h-4 w-4 mr-2" />
Add Factory
</button>
</div>
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{data?.factories.map((factory) => (
<li key={factory.id} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center">
<span className="text-sm font-medium text-gray-900">{factory.dexType}</span>
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Chain {factory.chainId}
</span>
{factory.isActive ? (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Active
</span>
) : (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Inactive
</span>
)}
</div>
<div className="mt-2 text-sm text-gray-500">
Factory: {factory.factoryAddress}
{factory.routerAddress && ` • Router: ${factory.routerAddress}`}
{factory.poolManagerAddress && ` • Pool Manager: ${factory.poolManagerAddress}`}
</div>
{factory.description && (
<div className="mt-1 text-sm text-gray-400">{factory.description}</div>
)}
</div>
<button
onClick={() => deleteMutation.mutate(factory.id)}
className="text-red-400 hover:text-red-600"
>
<Trash2 className="h-5 w-5" />
</button>
</div>
</li>
))}
</ul>
{data?.factories.length === 0 && (
<div className="text-center py-12 text-gray-500">No DEX factories configured</div>
)}
</div>
{showAddModal && (
<AddFactoryModal
onClose={() => {
setShowAddModal(false);
queryClient.invalidateQueries({ queryKey: ['dex-factories'] });
}}
/>
)}
</div>
);
}
function AddFactoryModal({ onClose }: { onClose: () => void }) {
const [formData, setFormData] = useState({
chainId: '138',
dexType: 'uniswap_v2',
factoryAddress: '',
routerAddress: '',
poolManagerAddress: '',
startBlock: '0',
description: '',
});
const createMutation = useMutation({
mutationFn: async (data: any) => {
await api.post('/api/v1/admin/dex-factories', data);
},
onSuccess: () => {
toast.success('DEX factory added');
onClose();
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to add factory');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate({
...formData,
chainId: parseInt(formData.chainId, 10),
startBlock: parseInt(formData.startBlock, 10),
routerAddress: formData.routerAddress || undefined,
poolManagerAddress: formData.poolManagerAddress || undefined,
description: formData.description || undefined,
});
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<h3 className="text-lg font-bold text-gray-900 mb-4">Add DEX Factory</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Chain ID</label>
<select
value={formData.chainId}
onChange={(e) => setFormData({ ...formData, chainId: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option value="138">138 (DeFi Oracle Meta Mainnet)</option>
<option value="651940">651940 (ALL Mainnet)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">DEX Type</label>
<select
value={formData.dexType}
onChange={(e) => setFormData({ ...formData, dexType: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option value="uniswap_v2">Uniswap V2</option>
<option value="uniswap_v3">Uniswap V3</option>
<option value="dodo">DODO</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Factory Address</label>
<input
type="text"
required
value={formData.factoryAddress}
onChange={(e) => setFormData({ ...formData, factoryAddress: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
placeholder="0x..."
/>
</div>
{formData.dexType !== 'dodo' && (
<div>
<label className="block text-sm font-medium text-gray-700">Router Address (optional)</label>
<input
type="text"
value={formData.routerAddress}
onChange={(e) => setFormData({ ...formData, routerAddress: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
placeholder="0x..."
/>
</div>
)}
{formData.dexType === 'dodo' && (
<div>
<label className="block text-sm font-medium text-gray-700">Pool Manager Address</label>
<input
type="text"
value={formData.poolManagerAddress}
onChange={(e) => setFormData({ ...formData, poolManagerAddress: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
placeholder="0x..."
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700">Start Block</label>
<input
type="number"
value={formData.startBlock}
onChange={(e) => setFormData({ ...formData, startBlock: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Description (optional)</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
rows={2}
/>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={createMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{createMutation.isPending ? 'Adding...' : 'Add'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../services/api';
import { useState } from 'react';
import toast from 'react-hot-toast';
import { Plus, Trash2, CheckCircle, XCircle } from 'lucide-react';
interface ApiEndpoint {
id: number;
chainId: number;
endpointType: string;
endpointName: string;
endpointUrl: string;
isPrimary: boolean;
isActive: boolean;
healthCheckStatus?: string;
lastHealthCheck?: string;
}
export default function Endpoints() {
const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<{ endpoints: ApiEndpoint[] }>({
queryKey: ['endpoints'],
queryFn: async () => {
const response = await api.get('/api/v1/admin/endpoints');
return response.data;
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
await api.put(`/api/v1/admin/endpoints/${id}`, { isActive: false });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
toast.success('Endpoint deactivated');
},
});
if (isLoading) {
return <div className="text-center py-12">Loading...</div>;
}
return (
<div className="px-4 py-6 sm:px-0">
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">API Endpoints</h1>
<p className="mt-2 text-sm text-gray-600">Manage RPC and API endpoints for chains</p>
</div>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700"
>
<Plus className="h-4 w-4 mr-2" />
Add Endpoint
</button>
</div>
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{data?.endpoints.map((endpoint) => (
<li key={endpoint.id} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center">
<span className="text-sm font-medium text-gray-900">{endpoint.endpointName}</span>
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Chain {endpoint.chainId}
</span>
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{endpoint.endpointType}
</span>
{endpoint.isPrimary && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Primary
</span>
)}
{endpoint.healthCheckStatus && (
<span className="ml-2">
{endpoint.healthCheckStatus === 'healthy' ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
</span>
)}
</div>
<div className="mt-2 text-sm text-gray-500">{endpoint.endpointUrl}</div>
</div>
<button
onClick={() => deleteMutation.mutate(endpoint.id)}
className="text-red-400 hover:text-red-600"
>
<Trash2 className="h-5 w-5" />
</button>
</div>
</li>
))}
</ul>
{data?.endpoints.length === 0 && (
<div className="text-center py-12 text-gray-500">No endpoints configured</div>
)}
</div>
{showAddModal && (
<AddEndpointModal
onClose={() => {
setShowAddModal(false);
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
}}
/>
)}
</div>
);
}
function AddEndpointModal({ onClose }: { onClose: () => void }) {
const [formData, setFormData] = useState({
chainId: '138',
endpointType: 'rpc',
endpointName: '',
endpointUrl: '',
isPrimary: false,
});
const createMutation = useMutation({
mutationFn: async (data: any) => {
await api.post('/api/v1/admin/endpoints', data);
},
onSuccess: () => {
toast.success('Endpoint added');
onClose();
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to add endpoint');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate({
...formData,
chainId: parseInt(formData.chainId, 10),
});
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<h3 className="text-lg font-bold text-gray-900 mb-4">Add Endpoint</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Chain ID</label>
<select
value={formData.chainId}
onChange={(e) => setFormData({ ...formData, chainId: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option value="138">138 (DeFi Oracle Meta Mainnet)</option>
<option value="651940">651940 (ALL Mainnet)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Endpoint Type</label>
<select
value={formData.endpointType}
onChange={(e) => setFormData({ ...formData, endpointType: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option value="rpc">RPC</option>
<option value="explorer">Explorer</option>
<option value="indexer">Indexer</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Endpoint Name</label>
<input
type="text"
required
value={formData.endpointName}
onChange={(e) => setFormData({ ...formData, endpointName: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Endpoint URL</label>
<input
type="url"
required
value={formData.endpointUrl}
onChange={(e) => setFormData({ ...formData, endpointUrl: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isPrimary"
checked={formData.isPrimary}
onChange={(e) => setFormData({ ...formData, isPrimary: e.target.checked })}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label htmlFor="isPrimary" className="ml-2 block text-sm text-gray-900">
Set as primary endpoint
</label>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={createMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{createMutation.isPending ? 'Adding...' : 'Add'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import api from '../services/api';
import toast from 'react-hot-toast';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuthStore();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await api.post('/api/v1/admin/auth/login', {
username,
password,
});
login(response.data.token, response.data.user);
toast.success('Login successful');
navigate('/');
} catch (error: any) {
toast.error(error.response?.data?.error || 'Login failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Token Aggregation Control Panel
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Sign in to manage API keys and endpoints
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">Username</label>
<input
id="username"
name="username"
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
const authStorage = localStorage.getItem('auth-storage');
if (authStorage) {
try {
const parsed = JSON.parse(authStorage);
if (parsed.state?.token) {
config.headers.Authorization = `Bearer ${parsed.state.token}`;
}
} catch (e) {
// Ignore parse errors
}
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('auth-storage');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;

View File

@@ -0,0 +1,35 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface AuthState {
token: string | null;
user: {
id: number;
username: string;
email?: string;
role: string;
} | null;
isAuthenticated: boolean;
login: (token: string, user: any) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
isAuthenticated: false,
login: (token, user) => {
set({ token, user, isAuthenticated: true });
},
logout: () => {
set({ token: null, user: null, isAuthenticated: false });
},
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => localStorage),
}
)
);