feat: surface mobile degraded state

This commit is contained in:
Saravana Achu Mac 2026-04-04 11:57:47 -07:00
parent 4cdff95c26
commit e1bb6e790e
4 changed files with 128 additions and 16 deletions

View File

@ -29,7 +29,7 @@ It assumes:
- [x] Monorepo foundation scaffolded with root workspace config, shared runtime, shared product identity, local package linking, and verification scripts - [x] Monorepo foundation scaffolded with root workspace config, shared runtime, shared product identity, local package linking, and verification scripts
- [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates - [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates
- [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, and normalized backend URL resolution - [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, and normalized backend URL resolution
- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, live backend polling plus websocket-backed updates, startup/error telemetry capture, and secure session storage with invalidation handling - [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, live backend polling plus websocket-backed updates, startup/error telemetry capture, secure session storage with invalidation handling, and explicit degraded/offline status surfacing
- [-] DRY cleanup completed for runtime/config/bootstrap concerns and shared Supabase bootstrap, but not yet for all auth/session internals - [-] DRY cleanup completed for runtime/config/bootstrap concerns and shared Supabase bootstrap, but not yet for all auth/session internals
- [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses transitional Supabase-backed auth to stay compatible with the backend's current JWT boundary - [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses transitional Supabase-backed auth to stay compatible with the backend's current JWT boundary
@ -313,7 +313,7 @@ Build mobile as a real ecosystem surface, not a mock UI shell.
- [x] Define mobile action policy for monitor-first versus control-first flows - [x] Define mobile action policy for monitor-first versus control-first flows
- [x] Define alert and incident UX - [x] Define alert and incident UX
- [-] Define operator-safe interventions - [-] Define operator-safe interventions
- [-] Define offline and degraded-state behavior - [x] Define offline and degraded-state behavior
### Mobile v1 Scope ### Mobile v1 Scope
@ -444,7 +444,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
- [x] Define status polling/live update strategy - [x] Define status polling/live update strategy
- [ ] Define alert/incident UX - [ ] Define alert/incident UX
- [ ] Define operator-safe interventions - [ ] Define operator-safe interventions
- [ ] Define offline and degraded-state behavior - [x] Define offline and degraded-state behavior
## 10. Sequencing Recommendations ## 10. Sequencing Recommendations

View File

@ -12,7 +12,7 @@ import { useMobileAuth } from '@/providers/MobileAuthProvider';
export default function SettingsScreen() { export default function SettingsScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { botState, portfolio, pauseTrading, resumeTrading } = useTradingData(); const { botState, portfolio, pauseTrading, resumeTrading, connectionState, error, lastUpdatedAt } = useTradingData();
const { profile, signOut } = useMobileAuth(); const { profile, signOut } = useMobileAuth();
const executionModeIndex = botState?.settings.executionMode === 'Live' ? 2 : botState?.settings.executionMode === 'Paper' ? 1 : 0; const executionModeIndex = botState?.settings.executionMode === 'Live' ? 2 : botState?.settings.executionMode === 'Paper' ? 1 : 0;
const [maxOpenTrades, setMaxOpenTrades] = useState(botState?.settings.maxOpenTrades || 3); const [maxOpenTrades, setMaxOpenTrades] = useState(botState?.settings.maxOpenTrades || 3);
@ -25,6 +25,17 @@ export default function SettingsScreen() {
const [oledBlack, setOledBlack] = useState(false); const [oledBlack, setOledBlack] = useState(false);
const tradingMode = botState?.health?.tradingControl?.mode ?? 'RUNNING'; const tradingMode = botState?.health?.tradingControl?.mode ?? 'RUNNING';
const isAdmin = profile?.role === 'admin'; const isAdmin = profile?.role === 'admin';
const brokerStatusText =
connectionState === 'live' ? 'Connected' : connectionState === 'degraded' ? 'Degraded' : 'Offline';
const brokerHint = error
? error
: connectionState === 'live'
? tradingMode === 'PAUSED'
? 'Trading is currently paused.'
: 'Broker state is sourced from the live backend.'
: lastUpdatedAt
? `Last backend sync ${Math.max(Math.round((Date.now() - lastUpdatedAt) / 60000), 0)}m ago.`
: 'Waiting for backend connectivity.';
const modeColors = [Colors.text.secondary, Colors.accent.blue, Colors.accent.orange]; const modeColors = [Colors.text.secondary, Colors.accent.blue, Colors.accent.orange];
@ -123,11 +134,28 @@ export default function SettingsScreen() {
<View style={styles.brokerRow}> <View style={styles.brokerRow}>
<Text style={styles.brokerName}>Alpaca</Text> <Text style={styles.brokerName}>Alpaca</Text>
<View style={styles.connectedBadge}> <View style={styles.connectedBadge}>
<Check size={12} color={Colors.accent.green} /> <Check
<Text style={styles.connectedText}>Connected</Text> size={12}
color={
connectionState === 'live'
? Colors.accent.green
: connectionState === 'degraded'
? Colors.accent.amber
: Colors.accent.red
}
/>
<Text
style={[
styles.connectedText,
connectionState === 'degraded' && styles.degradedText,
connectionState === 'offline' && styles.offlineText,
]}
>
{brokerStatusText}
</Text>
</View> </View>
</View> </View>
<Text style={styles.apiKeyHint}>{tradingMode === 'PAUSED' ? 'Trading is currently paused.' : 'Broker state is sourced from the live backend.'}</Text> <Text style={styles.apiKeyHint}>{brokerHint}</Text>
</View> </View>
</AnimatedCard> </AnimatedCard>
@ -450,6 +478,12 @@ const styles = StyleSheet.create({
fontSize: FontSize.bodySmall, fontSize: FontSize.bodySmall,
color: Colors.accent.green, color: Colors.accent.green,
}, },
degradedText: {
color: Colors.accent.amber,
},
offlineText: {
color: Colors.accent.red,
},
apiKeyHint: { apiKeyHint: {
fontFamily: Fonts.mono.regular, fontFamily: Fonts.mono.regular,
fontSize: FontSize.bodySmall, fontSize: FontSize.bodySmall,

View File

@ -5,26 +5,43 @@ import PulsingDot from '@/components/PulsingDot';
import { useTradingData } from '@/providers/TradingDataProvider'; import { useTradingData } from '@/providers/TradingDataProvider';
export default function StatusBanner() { export default function StatusBanner() {
const { botState, connected } = useTradingData(); const { botState, connectionState, error, lastUpdatedAt } = useTradingData();
const controlMode = botState?.health?.tradingControl?.mode ?? 'RUNNING'; const controlMode = botState?.health?.tradingControl?.mode ?? 'RUNNING';
const executionMode = botState?.settings.executionMode || 'Paper'; const executionMode = botState?.settings.executionMode || 'Paper';
const uptimeSeconds = botState?.uptime || 0; const uptimeSeconds = botState?.uptime || 0;
const uptimeHours = Math.floor(uptimeSeconds / 3600); const uptimeHours = Math.floor(uptimeSeconds / 3600);
const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60); const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
const isPaused = controlMode === 'PAUSED'; const isPaused = controlMode === 'PAUSED';
const statusText =
connectionState === 'offline' ? 'OFFLINE' : connectionState === 'degraded' ? 'DEGRADED' : controlMode;
const statusDetail = error
? error
: lastUpdatedAt
? `Synced ${Math.max(Math.round((Date.now() - lastUpdatedAt) / 60000), 0)}m ago`
: 'Awaiting sync';
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={[styles.badge, isPaused && styles.pausedBadge]}> <View
{!isPaused ? <PulsingDot size={6} /> : null} style={[
styles.badge,
isPaused && connectionState === 'live' && styles.pausedBadge,
connectionState === 'degraded' && styles.degradedBadge,
connectionState === 'offline' && styles.offlineBadge,
]}
>
{connectionState === 'live' && !isPaused ? <PulsingDot size={6} /> : null}
<Text style={[styles.runningText, isPaused && styles.pausedText]}> <Text style={[styles.runningText, isPaused && styles.pausedText]}>
{connected ? controlMode : 'OFFLINE'} {statusText}
</Text> </Text>
</View> </View>
<View style={[styles.badge, styles.paperBadge]}> <View style={[styles.badge, styles.paperBadge]}>
<Text style={styles.paperText}>{executionMode.toUpperCase()}</Text> <Text style={styles.paperText}>{executionMode.toUpperCase()}</Text>
</View> </View>
<Text style={styles.uptime}>{uptimeHours}h {uptimeMinutes}m</Text> <View style={styles.metaColumn}>
<Text style={styles.detailText} numberOfLines={1}>{statusDetail}</Text>
<Text style={styles.uptime}>{uptimeHours}h {uptimeMinutes}m</Text>
</View>
</View> </View>
); );
} }
@ -68,13 +85,30 @@ const styles = StyleSheet.create({
pausedBadge: { pausedBadge: {
backgroundColor: 'rgba(255,149,0,0.15)', backgroundColor: 'rgba(255,149,0,0.15)',
}, },
degradedBadge: {
backgroundColor: 'rgba(255,184,0,0.15)',
},
offlineBadge: {
backgroundColor: 'rgba(255,71,87,0.15)',
},
pausedText: { pausedText: {
color: Colors.accent.amber, color: Colors.accent.amber,
}, },
metaColumn: {
marginLeft: 'auto',
alignItems: 'flex-end',
gap: 2,
minWidth: 110,
},
detailText: {
fontFamily: Fonts.inter.medium,
fontSize: FontSize.micro,
color: Colors.text.secondary,
maxWidth: 140,
},
uptime: { uptime: {
fontFamily: Fonts.mono.regular, fontFamily: Fonts.mono.regular,
fontSize: FontSize.bodySmall, fontSize: FontSize.bodySmall,
color: Colors.text.secondary, color: Colors.text.secondary,
marginLeft: 'auto',
}, },
}); });

View File

@ -115,6 +115,8 @@ interface TradingDataContextValue {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
connected: boolean; connected: boolean;
connectionState: 'live' | 'degraded' | 'offline';
lastUpdatedAt: number | null;
refresh: () => Promise<void>; refresh: () => Promise<void>;
pauseTrading: (reason?: string) => Promise<{ error?: string }>; pauseTrading: (reason?: string) => Promise<{ error?: string }>;
resumeTrading: (reason?: string) => Promise<{ error?: string }>; resumeTrading: (reason?: string) => Promise<{ error?: string }>;
@ -142,6 +144,12 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
const [statusClock, setStatusClock] = useState(() => Date.now());
const markStateSynced = useCallback(() => {
setLastUpdatedAt(Date.now());
}, []);
const fetchState = useCallback(async () => { const fetchState = useCallback(async () => {
if (!accessToken || !user) { if (!accessToken || !user) {
@ -171,6 +179,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
setBotState(data); setBotState(data);
setConnected(true); setConnected(true);
setError(null); setError(null);
markStateSynced();
} catch (fetchError) { } catch (fetchError) {
setConnected(false); setConnected(false);
setError(fetchError instanceof Error ? fetchError.message : 'Failed to load trading state'); setError(fetchError instanceof Error ? fetchError.message : 'Failed to load trading state');
@ -178,7 +187,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [accessToken, user, invalidateSession]); }, [accessToken, user, invalidateSession, markStateSynced]);
useEffect(() => { useEffect(() => {
void fetchState(); void fetchState();
@ -191,6 +200,14 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [accessToken, user, fetchState]); }, [accessToken, user, fetchState]);
useEffect(() => {
const interval = setInterval(() => {
setStatusClock(Date.now());
}, 30000);
return () => clearInterval(interval);
}, []);
useEffect(() => { useEffect(() => {
if (!accessToken || !user) { if (!accessToken || !user) {
return; return;
@ -232,13 +249,16 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
socket.on('state', (nextState: BotState) => { socket.on('state', (nextState: BotState) => {
mergePartialState(nextState); mergePartialState(nextState);
markStateSynced();
}); });
socket.on('health_update', (health: HealthSnapshot) => { socket.on('health_update', (health: HealthSnapshot) => {
markStateSynced();
setBotState((prev) => (prev ? { ...prev, health } : prev)); setBotState((prev) => (prev ? { ...prev, health } : prev));
}); });
socket.on('symbol_update', ({ symbol, data }: { symbol: string; data: BotState['symbols'][string] }) => { socket.on('symbol_update', ({ symbol, data }: { symbol: string; data: BotState['symbols'][string] }) => {
markStateSynced();
setBotState((prev) => { setBotState((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
@ -252,6 +272,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
}); });
socket.on('new_alert', (alert: BotState['alerts'][number]) => { socket.on('new_alert', (alert: BotState['alerts'][number]) => {
markStateSynced();
setBotState((prev) => { setBotState((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
@ -262,14 +283,17 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
}); });
socket.on('positions_update', (positions: BotState['positions']) => { socket.on('positions_update', (positions: BotState['positions']) => {
markStateSynced();
setBotState((prev) => (prev ? { ...prev, positions } : prev)); setBotState((prev) => (prev ? { ...prev, positions } : prev));
}); });
socket.on('orders_update', (orders: BotState['orders']) => { socket.on('orders_update', (orders: BotState['orders']) => {
markStateSynced();
setBotState((prev) => (prev ? { ...prev, orders } : prev)); setBotState((prev) => (prev ? { ...prev, orders } : prev));
}); });
socket.on('history_update', (trade: BotState['history'][number]) => { socket.on('history_update', (trade: BotState['history'][number]) => {
markStateSynced();
setBotState((prev) => { setBotState((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
@ -280,6 +304,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
}); });
socket.on('settings_update', (settings: BotState['settings']) => { socket.on('settings_update', (settings: BotState['settings']) => {
markStateSynced();
setBotState((prev) => (prev ? { ...prev, settings } : prev)); setBotState((prev) => (prev ? { ...prev, settings } : prev));
}); });
@ -298,7 +323,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
return () => { return () => {
socket?.close(); socket?.close();
}; };
}, [accessToken, user, fetchState]); }, [accessToken, user, fetchState, invalidateSession, markStateSynced]);
const postTradingAction = useCallback( const postTradingAction = useCallback(
async (path: '/internal/trading/pause' | '/internal/trading/resume', reason?: string) => { async (path: '/internal/trading/pause' | '/internal/trading/resume', reason?: string) => {
@ -378,19 +403,38 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
[botState] [botState]
); );
const connectionState = useMemo<'live' | 'degraded' | 'offline'>(() => {
if (!connected && !botState) {
return 'offline';
}
const staleMs = lastUpdatedAt ? statusClock - lastUpdatedAt : Number.POSITIVE_INFINITY;
if (connected && !error && staleMs < 90_000) {
return 'live';
}
if (botState || connected) {
return 'degraded';
}
return 'offline';
}, [connected, botState, error, lastUpdatedAt, statusClock]);
const value = useMemo<TradingDataContextValue>( const value = useMemo<TradingDataContextValue>(
() => ({ () => ({
botState, botState,
loading, loading,
error, error,
connected, connected,
connectionState,
lastUpdatedAt,
refresh: fetchState, refresh: fetchState,
pauseTrading: (reason?: string) => postTradingAction('/internal/trading/pause', reason), pauseTrading: (reason?: string) => postTradingAction('/internal/trading/pause', reason),
resumeTrading: (reason?: string) => postTradingAction('/internal/trading/resume', reason), resumeTrading: (reason?: string) => postTradingAction('/internal/trading/resume', reason),
portfolio, portfolio,
marketTicker, marketTicker,
}), }),
[botState, loading, error, connected, fetchState, postTradingAction, portfolio, marketTicker] [botState, loading, error, connected, connectionState, lastUpdatedAt, fetchState, postTradingAction, portfolio, marketTicker]
); );
return <TradingDataContext.Provider value={value}>{children}</TradingDataContext.Provider>; return <TradingDataContext.Provider value={value}>{children}</TradingDataContext.Provider>;