From 62804ed4e506afc8f1daf9a8147098954e73417d Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 16:55:54 +0000 Subject: [PATCH] feat(simple): add lifecycle toast notifications --- backend/src/domain/operationalEvents.ts | 1 + backend/src/index.ts | 42 ++++++++++++++ backend/src/services/apiServer.ts | 9 ++- web/src/App.tsx | 77 ++++++++++++++++++++++++- 4 files changed, 126 insertions(+), 3 deletions(-) diff --git a/backend/src/domain/operationalEvents.ts b/backend/src/domain/operationalEvents.ts index 40e15d3..2aa0620 100644 --- a/backend/src/domain/operationalEvents.ts +++ b/backend/src/domain/operationalEvents.ts @@ -1,4 +1,5 @@ export type OperationalEventType = + | 'SIMPLE_SETUP_UPDATE' | 'ORDER_FAILURE' | 'PARITY_WARNING' | 'RECONCILIATION_DEGRADED' diff --git a/backend/src/index.ts b/backend/src/index.ts index b22e553..eefcae9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -246,6 +246,16 @@ async function main() { const livePrice = Number(apiServer.getState().symbols?.[symbol]?.price || 0); return Number.isFinite(livePrice) && livePrice > 0 ? livePrice : null; }; + const emitSimpleSetupEvent = (entry: ManualEntryRecord, message: string, severity: 'INFO' | 'WARN' | 'ERROR' = 'INFO') => { + observabilityService.emitEvent({ + type: 'SIMPLE_SETUP_UPDATE', + severity, + message, + profileId: String(entry.profile_id || '').trim() || undefined, + userId: String(entry.user_id || '').trim() || undefined, + symbol: String(entry.symbol || '').trim().toUpperCase() || undefined, + }); + }; const bindSimpleBoughtPosition = async (entry: ManualEntryRecord, ctx: UserContext): Promise => { const linkedTradeId = String(entry.linked_trade_id || '').trim(); const activePosition = linkedTradeId @@ -265,6 +275,15 @@ async function main() { status: 'simple_bought', active: true, }); + emitSimpleSetupEvent( + { + ...entry, + profile_id: entry.profile_id || ctx.profileId, + linked_trade_id: activePosition.tradeId || entry.linked_trade_id, + }, + `${String(entry.symbol || '').trim().toUpperCase()} entry filled. Monitoring for the configured profit exit.`, + 'INFO' + ); return true; }; let simpleWorkerRunning = false; @@ -328,6 +347,15 @@ async function main() { status: 'simple_entry_submitted', active: true, }); + emitSimpleSetupEvent( + { + ...entry, + profile_id: entry.profile_id || ctx.profileId, + linked_trade_id: result.tradeId || entry.linked_trade_id, + }, + `${symbol} entry submitted${result.adjustedQty ? ` for ${result.adjustedQty}` : ''}. Waiting for fill confirmation.`, + 'INFO' + ); continue; } @@ -373,6 +401,11 @@ async function main() { sell_time: entry.sell_time || new Date().toISOString(), sell_price: currentPrice, }); + emitSimpleSetupEvent( + entry, + `${symbol} setup completed. The linked position is closed.`, + 'INFO' + ); } continue; } @@ -405,6 +438,15 @@ async function main() { status: 'simple_exit_submitted', active: true, }); + emitSimpleSetupEvent( + { + ...entry, + profile_id: entry.profile_id || ctx.profileId, + linked_trade_id: linkedTradeId || activePosition.tradeId, + }, + `${symbol} exit submitted after hitting the configured profit target. Waiting for fill confirmation.`, + 'INFO' + ); } } catch (error: any) { logger.error(`[SimpleWorker] Error during simple setup scan: ${error.message || error}`); diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index 091e98e..68c00bb 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -502,7 +502,9 @@ export class ApiServer { private broadcastOperationalEvent(event: OperationalEvent) { this.emitToConnectedUsers('operational_event', (userId, socket) => { if (socket.data.isAdmin) return event; - return null; + return this.isOwnedByUser(String(userId || '').trim(), event.userId, event.profileId) + ? event + : null; }); } @@ -666,7 +668,9 @@ export class ApiServer { health: healthTracker.getSnapshot(), accountSnapshot: scopedAccountSnapshot, orderFailures: scopedOrderFailures, - operationalEvents: isAdmin ? this.state.operationalEvents : [] + operationalEvents: this.state.operationalEvents.filter((event) => + isAdmin || this.isOwnedByUser(userId, event.userId, event.profileId) + ) }; } @@ -682,6 +686,7 @@ export class ApiServer { const userId = socket.data.userId; if (!userId) continue; const payload = payloadBuilder(userId, socket); + if (payload == null) continue; socket.emit(event, payload); } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 3ba1ef6..7fde2de 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { BrowserRouter } from 'react-router-dom'; import { useWebSocket } from './hooks/useWebSocket'; import { useAuth } from './components/AuthContext'; @@ -51,6 +51,12 @@ function App() { const [activeSymbol, setActiveSymbol] = useState(''); const [chatProfiles, setChatProfiles] = useState([]); const [previewAsCustomer] = useState(false); + const [simpleToasts, setSimpleToasts] = useState>([]); + const seenSimpleEventIdsRef = useRef>(new Set()); const { enabled: backtestEnabledForView, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer }); @@ -70,6 +76,33 @@ function App() { ); const hasCriticalEvents = recentCriticalEvents.length > 0; + useEffect(() => { + const events = botState.operationalEvents ?? []; + const now = Date.now(); + const nextToasts: Array<{ id: string; message: string; severity: 'INFO' | 'WARN' | 'ERROR' }> = []; + + for (const event of events) { + if (!event || event.type !== 'SIMPLE_SETUP_UPDATE') continue; + if (seenSimpleEventIdsRef.current.has(event.id)) continue; + seenSimpleEventIdsRef.current.add(event.id); + if (now - event.timestamp > 120_000) continue; + nextToasts.push({ + id: event.id, + message: event.message, + severity: (event.severity as 'INFO' | 'WARN' | 'ERROR') || 'INFO', + }); + } + + if (nextToasts.length === 0) return; + + setSimpleToasts((prev) => [...prev, ...nextToasts].slice(-4)); + for (const toast of nextToasts) { + window.setTimeout(() => { + setSimpleToasts((prev) => prev.filter((entry) => entry.id !== toast.id)); + }, 4500); + } + }, [botState.operationalEvents]); + // Chat profile management const fetchChatProfiles = useCallback(async () => { const data = await fetchTradeProfiles(); @@ -186,6 +219,48 @@ function App() { + {simpleToasts.length > 0 && ( +
+ {simpleToasts.map((toast) => { + const palette = toast.severity === 'ERROR' + ? { border: 'rgba(239,68,68,0.35)', bg: 'rgba(127,29,29,0.95)', fg: '#fff' } + : toast.severity === 'WARN' + ? { border: 'rgba(245,158,11,0.35)', bg: 'rgba(120,53,15,0.95)', fg: '#fff' } + : { border: 'rgba(16,185,129,0.35)', bg: 'rgba(6,78,59,0.95)', fg: '#fff' }; + return ( +
+
+ Simple update +
+
{toast.message}
+
+ ); + })} +
+ )} + {/* Floating AI strategy assistant */}