feat(simple): add lifecycle toast notifications
This commit is contained in:
parent
1be7a93d52
commit
62804ed4e5
@ -1,4 +1,5 @@
|
|||||||
export type OperationalEventType =
|
export type OperationalEventType =
|
||||||
|
| 'SIMPLE_SETUP_UPDATE'
|
||||||
| 'ORDER_FAILURE'
|
| 'ORDER_FAILURE'
|
||||||
| 'PARITY_WARNING'
|
| 'PARITY_WARNING'
|
||||||
| 'RECONCILIATION_DEGRADED'
|
| 'RECONCILIATION_DEGRADED'
|
||||||
|
|||||||
@ -246,6 +246,16 @@ async function main() {
|
|||||||
const livePrice = Number(apiServer.getState().symbols?.[symbol]?.price || 0);
|
const livePrice = Number(apiServer.getState().symbols?.[symbol]?.price || 0);
|
||||||
return Number.isFinite(livePrice) && livePrice > 0 ? livePrice : null;
|
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<boolean> => {
|
const bindSimpleBoughtPosition = async (entry: ManualEntryRecord, ctx: UserContext): Promise<boolean> => {
|
||||||
const linkedTradeId = String(entry.linked_trade_id || '').trim();
|
const linkedTradeId = String(entry.linked_trade_id || '').trim();
|
||||||
const activePosition = linkedTradeId
|
const activePosition = linkedTradeId
|
||||||
@ -265,6 +275,15 @@ async function main() {
|
|||||||
status: 'simple_bought',
|
status: 'simple_bought',
|
||||||
active: true,
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
let simpleWorkerRunning = false;
|
let simpleWorkerRunning = false;
|
||||||
@ -328,6 +347,15 @@ async function main() {
|
|||||||
status: 'simple_entry_submitted',
|
status: 'simple_entry_submitted',
|
||||||
active: true,
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -373,6 +401,11 @@ async function main() {
|
|||||||
sell_time: entry.sell_time || new Date().toISOString(),
|
sell_time: entry.sell_time || new Date().toISOString(),
|
||||||
sell_price: currentPrice,
|
sell_price: currentPrice,
|
||||||
});
|
});
|
||||||
|
emitSimpleSetupEvent(
|
||||||
|
entry,
|
||||||
|
`${symbol} setup completed. The linked position is closed.`,
|
||||||
|
'INFO'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -405,6 +438,15 @@ async function main() {
|
|||||||
status: 'simple_exit_submitted',
|
status: 'simple_exit_submitted',
|
||||||
active: true,
|
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) {
|
} catch (error: any) {
|
||||||
logger.error(`[SimpleWorker] Error during simple setup scan: ${error.message || error}`);
|
logger.error(`[SimpleWorker] Error during simple setup scan: ${error.message || error}`);
|
||||||
|
|||||||
@ -502,7 +502,9 @@ export class ApiServer {
|
|||||||
private broadcastOperationalEvent(event: OperationalEvent) {
|
private broadcastOperationalEvent(event: OperationalEvent) {
|
||||||
this.emitToConnectedUsers('operational_event', (userId, socket) => {
|
this.emitToConnectedUsers('operational_event', (userId, socket) => {
|
||||||
if (socket.data.isAdmin) return event;
|
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(),
|
health: healthTracker.getSnapshot(),
|
||||||
accountSnapshot: scopedAccountSnapshot,
|
accountSnapshot: scopedAccountSnapshot,
|
||||||
orderFailures: scopedOrderFailures,
|
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;
|
const userId = socket.data.userId;
|
||||||
if (!userId) continue;
|
if (!userId) continue;
|
||||||
const payload = payloadBuilder(userId, socket);
|
const payload = payloadBuilder(userId, socket);
|
||||||
|
if (payload == null) continue;
|
||||||
socket.emit(event, payload);
|
socket.emit(event, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { useWebSocket } from './hooks/useWebSocket';
|
import { useWebSocket } from './hooks/useWebSocket';
|
||||||
import { useAuth } from './components/AuthContext';
|
import { useAuth } from './components/AuthContext';
|
||||||
@ -51,6 +51,12 @@ function App() {
|
|||||||
const [activeSymbol, setActiveSymbol] = useState('');
|
const [activeSymbol, setActiveSymbol] = useState('');
|
||||||
const [chatProfiles, setChatProfiles] = useState<any[]>([]);
|
const [chatProfiles, setChatProfiles] = useState<any[]>([]);
|
||||||
const [previewAsCustomer] = useState(false);
|
const [previewAsCustomer] = useState(false);
|
||||||
|
const [simpleToasts, setSimpleToasts] = useState<Array<{
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
severity: 'INFO' | 'WARN' | 'ERROR';
|
||||||
|
}>>([]);
|
||||||
|
const seenSimpleEventIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const { enabled: backtestEnabledForView, loading: backtestGateLoading } =
|
const { enabled: backtestEnabledForView, loading: backtestGateLoading } =
|
||||||
useBacktestFeatureGate({ previewAsCustomer });
|
useBacktestFeatureGate({ previewAsCustomer });
|
||||||
@ -70,6 +76,33 @@ function App() {
|
|||||||
);
|
);
|
||||||
const hasCriticalEvents = recentCriticalEvents.length > 0;
|
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
|
// Chat profile management
|
||||||
const fetchChatProfiles = useCallback(async () => {
|
const fetchChatProfiles = useCallback(async () => {
|
||||||
const data = await fetchTradeProfiles();
|
const data = await fetchTradeProfiles();
|
||||||
@ -186,6 +219,48 @@ function App() {
|
|||||||
<AppShell />
|
<AppShell />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{simpleToasts.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
zIndex: 9998,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 10,
|
||||||
|
maxWidth: 420,
|
||||||
|
}}>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${palette.border}`,
|
||||||
|
background: palette.bg,
|
||||||
|
color: palette.fg,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '12px 14px',
|
||||||
|
boxShadow: '0 12px 32px rgba(0,0,0,0.28)',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 900, letterSpacing: '0.16em', textTransform: 'uppercase', opacity: 0.8, marginBottom: 4 }}>
|
||||||
|
Simple update
|
||||||
|
</div>
|
||||||
|
<div>{toast.message}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Floating AI strategy assistant */}
|
{/* Floating AI strategy assistant */}
|
||||||
<ChatControl profiles={chatProfiles} onApplyProfile={handleChatApply} />
|
<ChatControl profiles={chatProfiles} onApplyProfile={handleChatApply} />
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user