feat: move dynamic config behind backend control plane
This commit is contained in:
parent
a4fce709f0
commit
b551ab2a4f
@ -1,5 +1,6 @@
|
|||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import logger from '../utils/logger.js';
|
import logger from '../utils/logger.js';
|
||||||
|
import { listDynamicConfigEntries } from '../services/dynamicConfigRepository.js';
|
||||||
|
|
||||||
dotenv.config({ override: true });
|
dotenv.config({ override: true });
|
||||||
|
|
||||||
@ -400,39 +401,42 @@ const aiConfigParsers: Record<string, (value: unknown) => unknown> = {
|
|||||||
FAIL_OPEN: (value) => toBoolean(value, config.AI.FAIL_OPEN),
|
FAIL_OPEN: (value) => toBoolean(value, config.AI.FAIL_OPEN),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export function applyDynamicConfigEntries(data: Array<{ key: string; value: unknown }>) {
|
||||||
* Loads global configuration from Supabase to override .env defaults.
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
* This allows for remote management of bot behavior.
|
return [] as string[];
|
||||||
*/
|
}
|
||||||
export const loadDynamicConfig = async (supabase: any) => {
|
|
||||||
try {
|
|
||||||
logger.info('--- Loading Dynamic Global Config from Supabase ---');
|
|
||||||
const { data, error } = await supabase.client
|
|
||||||
.from('bot_config') // Using 'bot_config' table
|
|
||||||
.select('*');
|
|
||||||
|
|
||||||
if (error) {
|
const loadedKeys: string[] = [];
|
||||||
logger.warn(`[Config] Failed to load dynamic config: ${error.message}. Using .env defaults.`);
|
data.forEach((item: any) => {
|
||||||
return;
|
const { key, value } = item;
|
||||||
|
if (key in config) {
|
||||||
|
const parser = dynamicConfigParsers[key];
|
||||||
|
(config as any)[key] = parser ? parser(value) : value;
|
||||||
|
loadedKeys.push(key);
|
||||||
|
} else if (key in config.AI) {
|
||||||
|
const parser = aiConfigParsers[key];
|
||||||
|
(config.AI as any)[key] = parser ? parser(value) : value;
|
||||||
|
loadedKeys.push(`AI.${key}`);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return loadedKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads global configuration from Cosmos-first dynamic config storage to override .env defaults.
|
||||||
|
* Falls back to the legacy Supabase table during migration.
|
||||||
|
*/
|
||||||
|
export const loadDynamicConfig = async (supabase?: any) => {
|
||||||
|
try {
|
||||||
|
logger.info('--- Loading Dynamic Global Config from control-plane storage ---');
|
||||||
|
const data = await listDynamicConfigEntries(supabase);
|
||||||
|
|
||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
const loadedKeys: string[] = [];
|
const loadedKeys = applyDynamicConfigEntries(data);
|
||||||
data.forEach((item: any) => {
|
|
||||||
const { key, value } = item;
|
|
||||||
if (key in config) {
|
|
||||||
const parser = dynamicConfigParsers[key];
|
|
||||||
(config as any)[key] = parser ? parser(value) : value;
|
|
||||||
loadedKeys.push(key);
|
|
||||||
} else if (key in config.AI) {
|
|
||||||
const parser = aiConfigParsers[key];
|
|
||||||
(config.AI as any)[key] = parser ? parser(value) : value;
|
|
||||||
loadedKeys.push(`AI.${key}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
logger.info(`✅ Dynamic Config Loaded: ${loadedKeys.join(', ')}`);
|
logger.info(`✅ Dynamic Config Loaded: ${loadedKeys.join(', ')}`);
|
||||||
} else {
|
} else {
|
||||||
logger.info('ℹ️ No dynamic config overrides found in DB. Using .env defaults.');
|
logger.info('ℹ️ No dynamic config overrides found in control-plane storage. Using .env defaults.');
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error(`[Config] Unexpected error loading dynamic config: ${err.message}`);
|
logger.error(`[Config] Unexpected error loading dynamic config: ${err.message}`);
|
||||||
|
|||||||
@ -6,13 +6,14 @@ import logger from '../utils/logger.js';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { ManualTrader } from './ManualTrader.js';
|
import { ManualTrader } from './ManualTrader.js';
|
||||||
import { config } from '../config/index.js';
|
import { applyDynamicConfigEntries, config, loadDynamicConfig } from '../config/index.js';
|
||||||
import { AIClient } from './aiClient.js';
|
import { AIClient } from './aiClient.js';
|
||||||
import { supabaseService } from './SupabaseService.js';
|
import { supabaseService } from './SupabaseService.js';
|
||||||
import { healthTracker, HealthSnapshot, TradingControlSnapshot } from './healthTracker.js';
|
import { healthTracker, HealthSnapshot, TradingControlSnapshot } from './healthTracker.js';
|
||||||
import { observabilityService } from './observabilityService.js';
|
import { observabilityService } from './observabilityService.js';
|
||||||
import { isTradingAdmin, verifyTradingAccessToken } from './platformAuthService.js';
|
import { isTradingAdmin, verifyTradingAccessToken } from './platformAuthService.js';
|
||||||
import { loadGlobalTradingControl, saveGlobalTradingControl } from './tradingControlRepository.js';
|
import { loadGlobalTradingControl, saveGlobalTradingControl } from './tradingControlRepository.js';
|
||||||
|
import { listDynamicConfigEntries, upsertDynamicConfigEntries } from './dynamicConfigRepository.js';
|
||||||
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
|
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
|
||||||
import { OperationalEvent } from '../domain/operationalEvents.js';
|
import { OperationalEvent } from '../domain/operationalEvents.js';
|
||||||
import { runBacktest } from '../backtest/index.js';
|
import { runBacktest } from '../backtest/index.js';
|
||||||
@ -1601,6 +1602,27 @@ export class ApiServer {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.app.get('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const items = await listDynamicConfigEntries(supabaseService);
|
||||||
|
res.json({ items });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: `Failed to load dynamic config: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.put('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const items = Array.isArray(req.body?.items) ? req.body.items : [];
|
||||||
|
await upsertDynamicConfigEntries(items, supabaseService);
|
||||||
|
applyDynamicConfigEntries(items);
|
||||||
|
await loadDynamicConfig(supabaseService);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: `Failed to update dynamic config: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.app.post('/api/backtest/run', this.requireAuth, async (req, res) => {
|
this.app.post('/api/backtest/run', this.requireAuth, async (req, res) => {
|
||||||
const authUserId = (req as AuthenticatedRequest).authUserId;
|
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||||
if (!authUserId) {
|
if (!authUserId) {
|
||||||
|
|||||||
145
backend/src/services/dynamicConfigRepository.ts
Normal file
145
backend/src/services/dynamicConfigRepository.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { getContainer } from '@bytelyst/cosmos';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
import type { supabaseService } from './SupabaseService.js';
|
||||||
|
|
||||||
|
export interface DynamicConfigEntry {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DynamicConfigDocument extends DynamicConfigEntry {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LegacySupabaseService = typeof supabaseService;
|
||||||
|
|
||||||
|
const CONTAINER_NAME = 'dynamic_config';
|
||||||
|
|
||||||
|
function isCosmosConfigured(): boolean {
|
||||||
|
return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntry(entry: Partial<DynamicConfigEntry> | null | undefined): DynamicConfigEntry | null {
|
||||||
|
const key = String(entry?.key || '').trim();
|
||||||
|
if (!key) return null;
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
value: String(entry?.value ?? ''),
|
||||||
|
description: entry?.description ? String(entry.description) : '',
|
||||||
|
updated_at: entry?.updated_at ? String(entry.updated_at) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFromCosmos(): Promise<DynamicConfigEntry[]> {
|
||||||
|
if (!isCosmosConfigured()) return [];
|
||||||
|
const container = getContainer(CONTAINER_NAME);
|
||||||
|
const { resources } = await container.items
|
||||||
|
.query<DynamicConfigDocument>({
|
||||||
|
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.key',
|
||||||
|
parameters: [{ name: '@productId', value: config.PRODUCT_ID }],
|
||||||
|
})
|
||||||
|
.fetchAll();
|
||||||
|
|
||||||
|
return resources
|
||||||
|
.map((resource) => normalizeEntry({
|
||||||
|
key: resource.key,
|
||||||
|
value: resource.value,
|
||||||
|
description: resource.description,
|
||||||
|
updated_at: resource.updatedAt,
|
||||||
|
}))
|
||||||
|
.filter((entry): entry is DynamicConfigEntry => Boolean(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFromSupabase(legacyService?: LegacySupabaseService): Promise<DynamicConfigEntry[]> {
|
||||||
|
const client = legacyService?.getClient?.();
|
||||||
|
if (!client) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('bot_config')
|
||||||
|
.select('*')
|
||||||
|
.order('key');
|
||||||
|
if (error || !Array.isArray(data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
.map((row) => normalizeEntry(row as DynamicConfigEntry))
|
||||||
|
.filter((entry): entry is DynamicConfigEntry => Boolean(entry));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DynamicConfig] Legacy Supabase read failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mirrorToSupabase(entries: DynamicConfigEntry[], legacyService?: LegacySupabaseService): Promise<void> {
|
||||||
|
const client = legacyService?.getClient?.();
|
||||||
|
if (!client || entries.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = entries.map((entry) => ({
|
||||||
|
key: entry.key,
|
||||||
|
value: entry.value,
|
||||||
|
description: entry.description || '',
|
||||||
|
updated_at: entry.updated_at || new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
const { error } = await client
|
||||||
|
.from('bot_config')
|
||||||
|
.upsert(payload, { onConflict: 'key' });
|
||||||
|
if (error) {
|
||||||
|
logger.warn(`[DynamicConfig] Legacy Supabase mirror failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DynamicConfig] Legacy Supabase mirror failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDynamicConfigEntries(legacyService?: LegacySupabaseService): Promise<DynamicConfigEntry[]> {
|
||||||
|
try {
|
||||||
|
const cosmosEntries = await listFromCosmos();
|
||||||
|
if (cosmosEntries.length > 0) {
|
||||||
|
return cosmosEntries;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DynamicConfig] Cosmos read failed, falling back to legacy store: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return listFromSupabase(legacyService);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertDynamicConfigEntries(
|
||||||
|
entries: DynamicConfigEntry[],
|
||||||
|
legacyService?: LegacySupabaseService
|
||||||
|
): Promise<void> {
|
||||||
|
const normalized = entries
|
||||||
|
.map((entry) => normalizeEntry(entry))
|
||||||
|
.filter((entry): entry is DynamicConfigEntry => Boolean(entry));
|
||||||
|
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
if (isCosmosConfigured()) {
|
||||||
|
try {
|
||||||
|
const container = getContainer(CONTAINER_NAME);
|
||||||
|
await Promise.all(normalized.map((entry) => container.items.upsert<DynamicConfigDocument>({
|
||||||
|
id: entry.key,
|
||||||
|
productId: config.PRODUCT_ID,
|
||||||
|
key: entry.key,
|
||||||
|
value: entry.value,
|
||||||
|
description: entry.description || '',
|
||||||
|
updated_at: entry.updated_at || now,
|
||||||
|
updatedAt: entry.updated_at || now,
|
||||||
|
})));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DynamicConfig] Cosmos upsert failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await mirrorToSupabase(normalized, legacyService);
|
||||||
|
}
|
||||||
@ -2,42 +2,25 @@
|
|||||||
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest';
|
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import { GlobalConfigManager } from './GlobalConfigManager';
|
import { GlobalConfigManager } from './GlobalConfigManager';
|
||||||
// import { tableNameBotConfig } from '../lib/const';
|
|
||||||
|
|
||||||
// Simplified mocks
|
const { fetchDynamicConfigItemsMock, upsertDynamicConfigItemsMock } = vi.hoisted(() => ({
|
||||||
const configOrderMock = vi.fn();
|
fetchDynamicConfigItemsMock: vi.fn(),
|
||||||
const configUpsertMock = vi.fn();
|
upsertDynamicConfigItemsMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../lib/supabaseClient', () => ({
|
vi.mock('../lib/dynamicConfigApi', () => ({
|
||||||
supabase: {
|
fetchDynamicConfigItems: fetchDynamicConfigItemsMock,
|
||||||
from: (table: string) => {
|
upsertDynamicConfigItems: upsertDynamicConfigItemsMock,
|
||||||
if (table === 'bot_config') {
|
|
||||||
return {
|
|
||||||
select: () => ({
|
|
||||||
order: configOrderMock
|
|
||||||
}),
|
|
||||||
upsert: configUpsertMock
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
select: () => ({ order: vi.fn().mockResolvedValue({ data: [], error: null }) }),
|
|
||||||
upsert: vi.fn().mockResolvedValue({ error: null })
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('GlobalConfigManager DOM behavior', () => {
|
describe('GlobalConfigManager DOM behavior', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
configOrderMock.mockResolvedValue({
|
fetchDynamicConfigItemsMock.mockResolvedValue([
|
||||||
data: [
|
{ key: 'BOT_MODE', value: 'paper', description: 'Trading mode' },
|
||||||
{ key: 'BOT_MODE', value: 'paper', description: 'Trading mode' },
|
{ key: 'MAX_POSITIONS', value: '5', description: 'Max positions' }
|
||||||
{ key: 'MAX_POSITIONS', value: '5', description: 'Max positions' }
|
]);
|
||||||
],
|
upsertDynamicConfigItemsMock.mockResolvedValue(undefined);
|
||||||
error: null
|
|
||||||
});
|
|
||||||
configUpsertMock.mockResolvedValue({ error: null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -65,14 +48,16 @@ describe('GlobalConfigManager DOM behavior', () => {
|
|||||||
fireEvent.click(saveButtons[0]);
|
fireEvent.click(saveButtons[0]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(configUpsertMock).toHaveBeenCalled();
|
expect(upsertDynamicConfigItemsMock).toHaveBeenCalledWith([
|
||||||
|
{ key: 'BOT_MODE', value: 'paper', description: 'Trading mode' }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText(/updated successfully/i)).toBeInTheDocument();
|
expect(screen.getByText(/updated successfully/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles save failure', async () => {
|
it('handles save failure', async () => {
|
||||||
configUpsertMock.mockResolvedValue({ error: { message: 'Write Denied' } });
|
upsertDynamicConfigItemsMock.mockRejectedValue(new Error('Write Denied'));
|
||||||
render(<GlobalConfigManager />);
|
render(<GlobalConfigManager />);
|
||||||
await waitFor(() => expect(screen.getByText(/Global Bot Configuration/i)).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText(/Global Bot Configuration/i)).toBeInTheDocument());
|
||||||
|
|
||||||
@ -84,16 +69,13 @@ describe('GlobalConfigManager DOM behavior', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles table missing error', async () => {
|
it('handles service unavailable error', async () => {
|
||||||
configOrderMock.mockResolvedValue({
|
fetchDynamicConfigItemsMock.mockRejectedValue(new Error('Dynamic config service unavailable.'));
|
||||||
data: null,
|
|
||||||
error: { code: '42P01', message: 'miss' }
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<GlobalConfigManager />);
|
render(<GlobalConfigManager />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/Database table "bot_config" not found/i)).toBeInTheDocument();
|
expect(screen.getByText(/Dynamic config service unavailable./i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { supabase } from '../lib/supabaseClient';
|
|
||||||
import { tableNameBotConfig } from '../lib/const';
|
|
||||||
import { Save, AlertCircle, CheckCircle } from 'lucide-react';
|
import { Save, AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
|
import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi';
|
||||||
|
|
||||||
interface ConfigItem {
|
interface ConfigItem {
|
||||||
key: string;
|
key: string;
|
||||||
@ -17,19 +16,12 @@ export const GlobalConfigManager = () => {
|
|||||||
|
|
||||||
const fetchConfigs = async () => {
|
const fetchConfigs = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { data, error } = await supabase
|
try {
|
||||||
.from(tableNameBotConfig)
|
const data = await fetchDynamicConfigItems();
|
||||||
.select('*')
|
|
||||||
.order('key');
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error fetching global config:', error);
|
|
||||||
// Table might not exist yet
|
|
||||||
if (error.code === '42P01') {
|
|
||||||
setMessage({ type: 'error', text: 'Database table "bot_config" not found. Please run the SQL setup.' });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setConfigs(data || []);
|
setConfigs(data || []);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching global config:', error);
|
||||||
|
setMessage({ type: 'error', text: error?.message || 'Dynamic config service unavailable.' });
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@ -44,15 +36,12 @@ export const GlobalConfigManager = () => {
|
|||||||
|
|
||||||
const handleSave = async (item: ConfigItem) => {
|
const handleSave = async (item: ConfigItem) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const { error } = await supabase
|
try {
|
||||||
.from(tableNameBotConfig)
|
await upsertDynamicConfigItems([item]);
|
||||||
.upsert({ key: item.key, value: item.value, updated_at: new Date().toISOString() });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
setMessage({ type: 'error', text: `Failed to save ${item.key}: ${error.message}` });
|
|
||||||
} else {
|
|
||||||
setMessage({ type: 'success', text: `${item.key} updated successfully.` });
|
setMessage({ type: 'success', text: `${item.key} updated successfully.` });
|
||||||
setTimeout(() => setMessage(null), 3000);
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error: any) {
|
||||||
|
setMessage({ type: 'error', text: `Failed to save ${item.key}: ${error.message}` });
|
||||||
}
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|||||||
50
web/src/lib/dynamicConfigApi.ts
Normal file
50
web/src/lib/dynamicConfigApi.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { supabase } from './supabaseClient';
|
||||||
|
import { tradingRuntime } from './runtime';
|
||||||
|
|
||||||
|
export interface DynamicConfigItem {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAccessToken(): Promise<string> {
|
||||||
|
const { data: sessionData } = await supabase.auth.getSession();
|
||||||
|
const accessToken = sessionData.session?.access_token;
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Not authenticated');
|
||||||
|
}
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDynamicConfigItems(): Promise<DynamicConfigItem[]> {
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/admin/config/dynamic`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({} as any));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body?.error || `Failed to load dynamic config (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(body?.items) ? (body.items as DynamicConfigItem[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertDynamicConfigItems(items: DynamicConfigItem[]): Promise<void> {
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/admin/config/dynamic`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({} as any));
|
||||||
|
if (!response.ok || body?.success === false) {
|
||||||
|
throw new Error(body?.error || `Failed to update dynamic config (${response.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import type { BotState } from '../hooks/useWebSocket';
|
|||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
import { useAuth } from '../components/AuthContext';
|
import { useAuth } from '../components/AuthContext';
|
||||||
import { tradingRuntime } from '../lib/runtime';
|
import { tradingRuntime } from '../lib/runtime';
|
||||||
|
import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi';
|
||||||
|
|
||||||
interface AdminTabProps {
|
interface AdminTabProps {
|
||||||
botState: BotState;
|
botState: BotState;
|
||||||
@ -256,12 +257,8 @@ export const AdminTab = ({ botState }: AdminTabProps) => {
|
|||||||
|
|
||||||
const fetchDbSyncSettings = async () => {
|
const fetchDbSyncSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const data = await fetchDynamicConfigItems();
|
||||||
.from('bot_config')
|
if (data) {
|
||||||
.select('*')
|
|
||||||
.in('key', ['ENABLE_DB_SNAPSHOTS', 'DB_SNAPSHOT_INTERVAL_MS']);
|
|
||||||
|
|
||||||
if (!error && data) {
|
|
||||||
data.forEach((item: { key: string; value: string; }) => {
|
data.forEach((item: { key: string; value: string; }) => {
|
||||||
if (item.key === 'ENABLE_DB_SNAPSHOTS') {
|
if (item.key === 'ENABLE_DB_SNAPSHOTS') {
|
||||||
setDbSyncEnabled(item.value === 'true');
|
setDbSyncEnabled(item.value === 'true');
|
||||||
@ -287,11 +284,7 @@ export const AdminTab = ({ botState }: AdminTabProps) => {
|
|||||||
{ key: 'DB_SNAPSHOT_INTERVAL_MS', value: String(dbSyncInterval), description: 'Minimum interval between database snapshots in ms' }
|
{ key: 'DB_SNAPSHOT_INTERVAL_MS', value: String(dbSyncInterval), description: 'Minimum interval between database snapshots in ms' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const { error } = await supabase
|
await upsertDynamicConfigItems(updates);
|
||||||
.from('bot_config')
|
|
||||||
.upsert(updates, { onConflict: 'key' });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setControlError(`DB Sync Update Failed: ${err.message}`);
|
setControlError(`DB Sync Update Failed: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user