feat: surface mobile degraded state
This commit is contained in:
parent
4cdff95c26
commit
e1bb6e790e
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user