diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index c037962..b9858c4 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,5 +1,6 @@ import * as dotenv from 'dotenv'; import logger from '../utils/logger.js'; +import { listDynamicConfigEntries } from '../services/dynamicConfigRepository.js'; dotenv.config({ override: true }); @@ -400,39 +401,42 @@ const aiConfigParsers: Record unknown> = { FAIL_OPEN: (value) => toBoolean(value, config.AI.FAIL_OPEN), }; -/** - * Loads global configuration from Supabase to override .env defaults. - * This allows for remote management of bot behavior. - */ -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('*'); +export function applyDynamicConfigEntries(data: Array<{ key: string; value: unknown }>) { + if (!Array.isArray(data) || data.length === 0) { + return [] as string[]; + } - if (error) { - logger.warn(`[Config] Failed to load dynamic config: ${error.message}. Using .env defaults.`); - return; + const loadedKeys: string[] = []; + 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}`); } + }); + + 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) { - const loadedKeys: string[] = []; - 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}`); - } - }); + const loadedKeys = applyDynamicConfigEntries(data); logger.info(`✅ Dynamic Config Loaded: ${loadedKeys.join(', ')}`); } 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) { logger.error(`[Config] Unexpected error loading dynamic config: ${err.message}`); diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index f4702a8..8b7d4b3 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -6,13 +6,14 @@ import logger from '../utils/logger.js'; import fs from 'fs'; import path from 'path'; 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 { supabaseService } from './SupabaseService.js'; import { healthTracker, HealthSnapshot, TradingControlSnapshot } from './healthTracker.js'; import { observabilityService } from './observabilityService.js'; import { isTradingAdmin, verifyTradingAccessToken } from './platformAuthService.js'; import { loadGlobalTradingControl, saveGlobalTradingControl } from './tradingControlRepository.js'; +import { listDynamicConfigEntries, upsertDynamicConfigEntries } from './dynamicConfigRepository.js'; import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js'; import { OperationalEvent } from '../domain/operationalEvents.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) => { const authUserId = (req as AuthenticatedRequest).authUserId; if (!authUserId) { diff --git a/backend/src/services/dynamicConfigRepository.ts b/backend/src/services/dynamicConfigRepository.ts new file mode 100644 index 0000000..ca2ab3a --- /dev/null +++ b/backend/src/services/dynamicConfigRepository.ts @@ -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 | 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 { + if (!isCosmosConfigured()) return []; + const container = getContainer(CONTAINER_NAME); + const { resources } = await container.items + .query({ + 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 { + 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 { + 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 { + 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 { + 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({ + 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); +} diff --git a/web/src/components/GlobalConfigManager.dom.test.tsx b/web/src/components/GlobalConfigManager.dom.test.tsx index fc64457..73ce328 100644 --- a/web/src/components/GlobalConfigManager.dom.test.tsx +++ b/web/src/components/GlobalConfigManager.dom.test.tsx @@ -1,44 +1,27 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { GlobalConfigManager } from './GlobalConfigManager'; -// import { tableNameBotConfig } from '../lib/const'; - -// Simplified mocks -const configOrderMock = vi.fn(); -const configUpsertMock = vi.fn(); - -vi.mock('../lib/supabaseClient', () => ({ - supabase: { - from: (table: string) => { - 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', () => { - beforeEach(() => { - vi.clearAllMocks(); - configOrderMock.mockResolvedValue({ - data: [ - { key: 'BOT_MODE', value: 'paper', description: 'Trading mode' }, - { key: 'MAX_POSITIONS', value: '5', description: 'Max positions' } - ], - error: null - }); - configUpsertMock.mockResolvedValue({ error: null }); - }); +import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { GlobalConfigManager } from './GlobalConfigManager'; + +const { fetchDynamicConfigItemsMock, upsertDynamicConfigItemsMock } = vi.hoisted(() => ({ + fetchDynamicConfigItemsMock: vi.fn(), + upsertDynamicConfigItemsMock: vi.fn(), +})); + +vi.mock('../lib/dynamicConfigApi', () => ({ + fetchDynamicConfigItems: fetchDynamicConfigItemsMock, + upsertDynamicConfigItems: upsertDynamicConfigItemsMock, +})); + +describe('GlobalConfigManager DOM behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + fetchDynamicConfigItemsMock.mockResolvedValue([ + { key: 'BOT_MODE', value: 'paper', description: 'Trading mode' }, + { key: 'MAX_POSITIONS', value: '5', description: 'Max positions' } + ]); + upsertDynamicConfigItemsMock.mockResolvedValue(undefined); + }); afterEach(() => { vi.useRealTimers(); @@ -61,20 +44,22 @@ describe('GlobalConfigManager DOM behavior', () => { render(); await waitFor(() => expect(screen.getByText(/Global Bot Configuration/i)).toBeInTheDocument()); - const saveButtons = screen.getAllByRole('button', { name: /Save/i }); - fireEvent.click(saveButtons[0]); - - await waitFor(() => { - expect(configUpsertMock).toHaveBeenCalled(); - }); - - expect(screen.getByText(/updated successfully/i)).toBeInTheDocument(); - }); - - it('handles save failure', async () => { - configUpsertMock.mockResolvedValue({ error: { message: 'Write Denied' } }); - render(); - await waitFor(() => expect(screen.getByText(/Global Bot Configuration/i)).toBeInTheDocument()); + const saveButtons = screen.getAllByRole('button', { name: /Save/i }); + fireEvent.click(saveButtons[0]); + + await waitFor(() => { + expect(upsertDynamicConfigItemsMock).toHaveBeenCalledWith([ + { key: 'BOT_MODE', value: 'paper', description: 'Trading mode' } + ]); + }); + + expect(screen.getByText(/updated successfully/i)).toBeInTheDocument(); + }); + + it('handles save failure', async () => { + upsertDynamicConfigItemsMock.mockRejectedValue(new Error('Write Denied')); + render(); + await waitFor(() => expect(screen.getByText(/Global Bot Configuration/i)).toBeInTheDocument()); const saveButtons = screen.getAllByRole('button', { name: /Save/i }); fireEvent.click(saveButtons[0]); @@ -82,18 +67,15 @@ describe('GlobalConfigManager DOM behavior', () => { await waitFor(() => { expect(screen.getByText(/Failed to save BOT_MODE: Write Denied/i)).toBeInTheDocument(); }); - }); - - it('handles table missing error', async () => { - configOrderMock.mockResolvedValue({ - data: null, - error: { code: '42P01', message: 'miss' } - }); - - render(); - - await waitFor(() => { - expect(screen.getByText(/Database table "bot_config" not found/i)).toBeInTheDocument(); - }); - }); -}); + }); + + it('handles service unavailable error', async () => { + fetchDynamicConfigItemsMock.mockRejectedValue(new Error('Dynamic config service unavailable.')); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Dynamic config service unavailable./i)).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/GlobalConfigManager.tsx b/web/src/components/GlobalConfigManager.tsx index 93b1755..3e9e2b0 100644 --- a/web/src/components/GlobalConfigManager.tsx +++ b/web/src/components/GlobalConfigManager.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { supabase } from '../lib/supabaseClient'; -import { tableNameBotConfig } from '../lib/const'; import { Save, AlertCircle, CheckCircle } from 'lucide-react'; +import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi'; interface ConfigItem { key: string; @@ -17,19 +16,12 @@ export const GlobalConfigManager = () => { const fetchConfigs = async () => { setLoading(true); - const { data, error } = await supabase - .from(tableNameBotConfig) - .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 { + try { + const data = await fetchDynamicConfigItems(); setConfigs(data || []); + } catch (error: any) { + console.error('Error fetching global config:', error); + setMessage({ type: 'error', text: error?.message || 'Dynamic config service unavailable.' }); } setLoading(false); }; @@ -44,15 +36,12 @@ export const GlobalConfigManager = () => { const handleSave = async (item: ConfigItem) => { setSaving(true); - const { error } = await supabase - .from(tableNameBotConfig) - .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 { + try { + await upsertDynamicConfigItems([item]); setMessage({ type: 'success', text: `${item.key} updated successfully.` }); setTimeout(() => setMessage(null), 3000); + } catch (error: any) { + setMessage({ type: 'error', text: `Failed to save ${item.key}: ${error.message}` }); } setSaving(false); }; diff --git a/web/src/lib/dynamicConfigApi.ts b/web/src/lib/dynamicConfigApi.ts new file mode 100644 index 0000000..6b2c5be --- /dev/null +++ b/web/src/lib/dynamicConfigApi.ts @@ -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 { + 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 { + 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 { + 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})`); + } +} diff --git a/web/src/tabs/AdminTab.tsx b/web/src/tabs/AdminTab.tsx index 96df27a..437c53e 100644 --- a/web/src/tabs/AdminTab.tsx +++ b/web/src/tabs/AdminTab.tsx @@ -15,6 +15,7 @@ import type { BotState } from '../hooks/useWebSocket'; import { useWebSocket } from '../hooks/useWebSocket'; import { useAuth } from '../components/AuthContext'; import { tradingRuntime } from '../lib/runtime'; +import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi'; interface AdminTabProps { botState: BotState; @@ -256,12 +257,8 @@ export const AdminTab = ({ botState }: AdminTabProps) => { const fetchDbSyncSettings = async () => { try { - const { data, error } = await supabase - .from('bot_config') - .select('*') - .in('key', ['ENABLE_DB_SNAPSHOTS', 'DB_SNAPSHOT_INTERVAL_MS']); - - if (!error && data) { + const data = await fetchDynamicConfigItems(); + if (data) { data.forEach((item: { key: string; value: string; }) => { if (item.key === 'ENABLE_DB_SNAPSHOTS') { 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' } ]; - const { error } = await supabase - .from('bot_config') - .upsert(updates, { onConflict: 'key' }); - - if (error) throw error; + await upsertDynamicConfigItems(updates); } catch (err: any) { setControlError(`DB Sync Update Failed: ${err.message}`); } finally {