finished exchanges api connections
This commit is contained in:
parent
d7780e9684
commit
e8fbe76f2e
6 changed files with 306 additions and 23 deletions
|
|
@ -183,3 +183,140 @@ exchangeRoutes.post('/api/exchanges/sync', async c => {
|
||||||
return c.json({ error: 'Internal server error' }, 500);
|
return c.json({ error: 'Internal server error' }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update exchange (shortName and active status)
|
||||||
|
exchangeRoutes.patch('/api/exchanges/:id', async c => {
|
||||||
|
try {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const updates = await c.req.json();
|
||||||
|
|
||||||
|
// Validate the updates - only allow shortName and active
|
||||||
|
const sanitizedUpdates: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if ('shortName' in updates && typeof updates.shortName === 'string') {
|
||||||
|
sanitizedUpdates.shortName = updates.shortName;
|
||||||
|
}
|
||||||
|
if ('active' in updates && typeof updates.active === 'boolean') {
|
||||||
|
sanitizedUpdates.active = updates.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(sanitizedUpdates).length === 0) {
|
||||||
|
return c.json({ error: 'No valid fields to update' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectMongoDB();
|
||||||
|
const db = getDatabase();
|
||||||
|
const collection = db.collection<MasterExchange>('masterExchanges');
|
||||||
|
|
||||||
|
// Update using MongoDB _id (ObjectId)
|
||||||
|
const result = await collection.updateOne(
|
||||||
|
{ _id: new (await import('mongodb')).ObjectId(id) },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
...sanitizedUpdates,
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.matchedCount === 0) {
|
||||||
|
return c.json({ error: 'Exchange not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Exchange updated successfully',
|
||||||
|
data: { id, updates: sanitizedUpdates },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating exchange', { error });
|
||||||
|
return c.json({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add source to exchange
|
||||||
|
exchangeRoutes.post('/api/exchanges/:id/sources', async c => {
|
||||||
|
try {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const { source, source_code, mapping } = await c.req.json();
|
||||||
|
|
||||||
|
if (!source || !source_code || !mapping || !mapping.id) {
|
||||||
|
return c.json({ error: 'source, source_code, and mapping with id are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the storage key using source and source_code
|
||||||
|
const storageKey = `${source}_${source_code}`;
|
||||||
|
|
||||||
|
// Add lastUpdated to the mapping
|
||||||
|
const sourceData = {
|
||||||
|
...mapping,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await connectMongoDB();
|
||||||
|
const db = getDatabase();
|
||||||
|
const collection = db.collection<MasterExchange>('masterExchanges');
|
||||||
|
|
||||||
|
// Update using MongoDB _id (ObjectId)
|
||||||
|
const result = await collection.updateOne(
|
||||||
|
{ _id: new (await import('mongodb')).ObjectId(id) },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
[`sourceMappings.${storageKey}`]: sourceData,
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.matchedCount === 0) {
|
||||||
|
return c.json({ error: 'Exchange not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Source mapping added successfully',
|
||||||
|
data: { id, storageKey, mapping: sourceData },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error adding source mapping', { error });
|
||||||
|
return c.json({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove source from exchange
|
||||||
|
exchangeRoutes.delete('/api/exchanges/:id/sources/:sourceName', async c => {
|
||||||
|
try {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const sourceName = c.req.param('sourceName');
|
||||||
|
|
||||||
|
await connectMongoDB();
|
||||||
|
const db = getDatabase();
|
||||||
|
const collection = db.collection<MasterExchange>('masterExchanges');
|
||||||
|
|
||||||
|
// Remove the source mapping using MongoDB _id (ObjectId)
|
||||||
|
const result = await collection.updateOne(
|
||||||
|
{ _id: new (await import('mongodb')).ObjectId(id) },
|
||||||
|
{
|
||||||
|
$unset: {
|
||||||
|
[`sourceMappings.${sourceName}`]: '',
|
||||||
|
},
|
||||||
|
$set: {
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.matchedCount === 0) {
|
||||||
|
return c.json({ error: 'Exchange not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Source mapping removed successfully',
|
||||||
|
data: { id, sourceName },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error removing source mapping', { error });
|
||||||
|
return c.json({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,115 @@
|
||||||
|
import { ArrowPathIcon, CheckCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { ExchangesTable } from './components/ExchangesTable';
|
import { ExchangesTable } from './components/ExchangesTable';
|
||||||
|
import { useExchanges } from './hooks/useExchanges';
|
||||||
|
|
||||||
export function ExchangesPage() {
|
export function ExchangesPage() {
|
||||||
|
const { syncExchanges } = useExchanges();
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncStatus, setSyncStatus] = useState<{
|
||||||
|
type: 'success' | 'error' | null;
|
||||||
|
message: string;
|
||||||
|
}>({ type: null, message: '' });
|
||||||
|
|
||||||
|
// Auto-dismiss success messages after 5 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (syncStatus.type === 'success') {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSyncStatus({ type: null, message: '' });
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [syncStatus.type]);
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
setSyncStatus({ type: null, message: '' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncExchanges();
|
||||||
|
if (result) {
|
||||||
|
setSyncStatus({
|
||||||
|
type: 'success',
|
||||||
|
message: `Exchange sync completed successfully! Job ID: ${result.jobId || 'Unknown'}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSyncStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Sync failed - no result returned from server',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSyncStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: error instanceof Error ? error.message : 'Sync failed with unknown error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-lg font-bold text-text-primary mb-2">Exchange Management</h1>
|
<div>
|
||||||
<p className="text-text-secondary text-sm">
|
<h1 className="text-lg font-bold text-text-primary mb-2">Exchange Management</h1>
|
||||||
Configure and manage master exchanges with their data sources and providers.
|
<p className="text-text-secondary text-sm">
|
||||||
</p>
|
Configure and manage master exchanges with their data sources and providers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={syncing}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||||
|
{syncing ? 'Syncing...' : 'Sync Exchanges'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{syncing && (
|
||||||
|
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowPathIcon className="h-4 w-4 text-primary-500 animate-spin" />
|
||||||
|
<span className="text-primary-700 text-sm">
|
||||||
|
Syncing exchanges from Interactive Brokers data...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{syncStatus.type && (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 max-w-sm">
|
||||||
|
<div className="bg-surface-secondary rounded-lg border border-border p-4 shadow-xl transform transition-all duration-300 ease-out">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{syncStatus.type === 'success' ? (
|
||||||
|
<CheckCircleIcon className="h-5 w-5 text-success flex-shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 text-danger flex-shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
syncStatus.type === 'success' ? 'text-success' : 'text-danger'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{syncStatus.type === 'success' ? 'Sync Completed' : 'Sync Failed'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 text-text-secondary">
|
||||||
|
{syncStatus.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSyncStatus({ type: null, message: '' })}
|
||||||
|
className="flex-shrink-0 p-1 rounded-full text-text-muted hover:text-text-primary hover:bg-surface transition-colors"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ExchangesTable />
|
<ExchangesTable />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@ export function AddSourceDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onAddSource,
|
onAddSource,
|
||||||
exchangeId,
|
|
||||||
exchangeName,
|
exchangeName,
|
||||||
}: AddSourceDialogProps) {
|
}: AddSourceDialogProps) {
|
||||||
const [source, setSource] = useState('');
|
const [source, setSource] = useState('');
|
||||||
|
const [sourceCode, setSourceCode] = useState('');
|
||||||
const [id, setId] = useState('');
|
const [id, setId] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
|
|
@ -27,12 +27,13 @@ export function AddSourceDialog({
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!source || !id || !name || !code) return;
|
if (!source || !sourceCode || !id || !name || !code) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await onAddSource({
|
await onAddSource({
|
||||||
source,
|
source,
|
||||||
|
source_code: sourceCode,
|
||||||
mapping: {
|
mapping: {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
|
@ -46,6 +47,7 @@ export function AddSourceDialog({
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setSource('');
|
setSource('');
|
||||||
|
setSourceCode('');
|
||||||
setId('');
|
setId('');
|
||||||
setName('');
|
setName('');
|
||||||
setCode('');
|
setCode('');
|
||||||
|
|
@ -116,6 +118,20 @@ export function AddSourceDialog({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||||
|
Source Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sourceCode}
|
||||||
|
onChange={e => setSourceCode(e.target.value)}
|
||||||
|
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="e.g., IB, ALP, POLY"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||||
Source ID
|
Source ID
|
||||||
|
|
@ -181,7 +197,7 @@ export function AddSourceDialog({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !source || !id || !name || !code}
|
disabled={loading || !source || !sourceCode || !id || !name || !code}
|
||||||
className="px-4 py-2 bg-primary-500 text-white rounded hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="px-4 py-2 bg-primary-500 text-white rounded hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? 'Adding...' : 'Add Source'}
|
{loading ? 'Adding...' : 'Add Source'}
|
||||||
|
|
|
||||||
|
|
@ -187,26 +187,31 @@ export function ExchangesTable() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1" style={{ width: cell.column.getSize() }}>
|
<div className="flex flex-wrap gap-1" style={{ width: cell.column.getSize() }}>
|
||||||
{sources.map(source => (
|
{sources.map(source => {
|
||||||
<span
|
// The source key is already in format "source_sourcecode" from the storage
|
||||||
key={source}
|
const displayText = source.toUpperCase();
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 bg-surface-secondary rounded text-xs"
|
|
||||||
>
|
return (
|
||||||
{source.toUpperCase()}
|
<span
|
||||||
<button
|
key={source}
|
||||||
onClick={() => handleRemoveSource(row.original._id, source)}
|
className="inline-flex items-center gap-1 px-2 py-1 bg-surface-secondary rounded text-xs"
|
||||||
className="text-danger hover:text-danger/80 transition-colors"
|
|
||||||
>
|
>
|
||||||
<XMarkIcon className="h-3 w-3" />
|
{displayText}
|
||||||
</button>
|
<button
|
||||||
</span>
|
onClick={() => handleRemoveSource(row.original._id, source)}
|
||||||
))}
|
className="text-danger hover:text-danger/80 transition-colors"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAddSource(row.original._id, row.original.officialName)}
|
onClick={() => handleAddSource(row.original._id, row.original.officialName)}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors"
|
className="inline-flex items-center justify-center w-6 h-6 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors"
|
||||||
|
title="Add Source"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-3 w-3" />
|
<PlusIcon className="h-3 w-3" />
|
||||||
Add Source
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -278,6 +283,7 @@ export function ExchangesTable() {
|
||||||
onClose={() => setAddSourceDialog(null)}
|
onClose={() => setAddSourceDialog(null)}
|
||||||
onAddSource={async (sourceRequest: {
|
onAddSource={async (sourceRequest: {
|
||||||
source: string;
|
source: string;
|
||||||
|
source_code: string;
|
||||||
mapping: { id: string; name: string; code: string; aliases: string[] };
|
mapping: { id: string; name: string; code: string; aliases: string[] };
|
||||||
}) => {
|
}) => {
|
||||||
const success = await addSource(addSourceDialog.id, sourceRequest);
|
const success = await addSource(addSourceDialog.id, sourceRequest);
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,28 @@ export function useExchanges() {
|
||||||
[fetchExchanges]
|
[fetchExchanges]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const syncExchanges = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/exchanges/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to sync exchanges: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Refresh the exchanges list after sync
|
||||||
|
await fetchExchanges();
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error syncing exchanges:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to sync exchanges');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [fetchExchanges]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchExchanges();
|
fetchExchanges();
|
||||||
}, [fetchExchanges]);
|
}, [fetchExchanges]);
|
||||||
|
|
@ -128,5 +150,6 @@ export function useExchanges() {
|
||||||
updateExchange,
|
updateExchange,
|
||||||
addSource,
|
addSource,
|
||||||
removeSource,
|
removeSource,
|
||||||
|
syncExchanges,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export interface UpdateExchangeRequest {
|
||||||
|
|
||||||
export interface AddSourceRequest {
|
export interface AddSourceRequest {
|
||||||
source: string;
|
source: string;
|
||||||
|
source_code: string;
|
||||||
mapping: {
|
mapping: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue