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] 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] 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
- [!] 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 alert and incident UX
- [-] Define operator-safe interventions
- [-] Define offline and degraded-state behavior
- [x] Define offline and degraded-state behavior
### 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
- [ ] Define alert/incident UX
- [ ] Define operator-safe interventions
- [ ] Define offline and degraded-state behavior
- [x] Define offline and degraded-state behavior
## 10. Sequencing Recommendations

View File

@ -12,7 +12,7 @@ import { useMobileAuth } from '@/providers/MobileAuthProvider';
export default function SettingsScreen() {
const insets = useSafeAreaInsets();
const { botState, portfolio, pauseTrading, resumeTrading } = useTradingData();
const { botState, portfolio, pauseTrading, resumeTrading, connectionState, error, lastUpdatedAt } = useTradingData();
const { profile, signOut } = useMobileAuth();
const executionModeIndex = botState?.settings.executionMode === 'Live' ? 2 : botState?.settings.executionMode === 'Paper' ? 1 : 0;
const [maxOpenTrades, setMaxOpenTrades] = useState(botState?.settings.maxOpenTrades || 3);
@ -25,6 +25,17 @@ export default function SettingsScreen() {
const [oledBlack, setOledBlack] = useState(false);
const tradingMode = botState?.health?.tradingControl?.mode ?? 'RUNNING';
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];
@ -123,11 +134,28 @@ export default function SettingsScreen() {
<View style={styles.brokerRow}>
<Text style={styles.brokerName}>Alpaca</Text>
<View style={styles.connectedBadge}>
<Check size={12} color={Colors.accent.green} />
<Text style={styles.connectedText}>Connected</Text>
<Check
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>
<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>
</AnimatedCard>
@ -450,6 +478,12 @@ const styles = StyleSheet.create({
fontSize: FontSize.bodySmall,
color: Colors.accent.green,
},
degradedText: {
color: Colors.accent.amber,
},
offlineText: {
color: Colors.accent.red,
},
apiKeyHint: {
fontFamily: Fonts.mono.regular,
fontSize: FontSize.bodySmall,

View File

@ -5,26 +5,43 @@ import PulsingDot from '@/components/PulsingDot';
import { useTradingData } from '@/providers/TradingDataProvider';
export default function StatusBanner() {
const { botState, connected } = useTradingData();
const { botState, connectionState, error, lastUpdatedAt } = useTradingData();
const controlMode = botState?.health?.tradingControl?.mode ?? 'RUNNING';
const executionMode = botState?.settings.executionMode || 'Paper';
const uptimeSeconds = botState?.uptime || 0;
const uptimeHours = Math.floor(uptimeSeconds / 3600);
const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
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 (
<View style={styles.container}>
<View style={[styles.badge, isPaused && styles.pausedBadge]}>
{!isPaused ? <PulsingDot size={6} /> : null}
<View
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]}>
{connected ? controlMode : 'OFFLINE'}
{statusText}
</Text>
</View>
<View style={[styles.badge, styles.paperBadge]}>
<Text style={styles.paperText}>{executionMode.toUpperCase()}</Text>
</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>
);
}
@ -68,13 +85,30 @@ const styles = StyleSheet.create({
pausedBadge: {
backgroundColor: 'rgba(255,149,0,0.15)',
},
degradedBadge: {
backgroundColor: 'rgba(255,184,0,0.15)',
},
offlineBadge: {
backgroundColor: 'rgba(255,71,87,0.15)',
},
pausedText: {
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: {
fontFamily: Fonts.mono.regular,
fontSize: FontSize.bodySmall,
color: Colors.text.secondary,
marginLeft: 'auto',
},
});

View File

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