diff --git a/web-dashboard/src/components/common/ComponentErrorBoundary.tsx b/web-dashboard/src/components/common/ComponentErrorBoundary.tsx new file mode 100644 index 000000000..f193d2f51 --- /dev/null +++ b/web-dashboard/src/components/common/ComponentErrorBoundary.tsx @@ -0,0 +1,56 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface Props { + children: ReactNode; + name: string; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * Lightweight error boundary for individual dashboard components. + * Renders an inline fallback instead of crashing the entire page. + */ +export class ComponentErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.error(`[${this.props.name}] crashed:`, error, info.componentStack); + } + } + + render() { + if (this.state.hasError) { + return ( +
+ + {this.props.name} failed to render + + + {this.state.error?.message} + + +
+ ); + } + + return this.props.children; + } +} diff --git a/web-dashboard/src/pages/BacktestingDashboard.tsx b/web-dashboard/src/pages/BacktestingDashboard.tsx index f61e83cd2..9e93b9ca8 100644 --- a/web-dashboard/src/pages/BacktestingDashboard.tsx +++ b/web-dashboard/src/pages/BacktestingDashboard.tsx @@ -1,5 +1,6 @@ import { useState, type FormEvent } from 'react'; import { PnLChart } from '../components/charts/PnLChart'; +import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary'; import { useApiPost, useApiQuery } from '../hooks/useApi'; interface BacktestParams { @@ -70,7 +71,7 @@ export function BacktestingDashboard() { return (
-
+
{/* Start Form */}
@@ -187,7 +188,9 @@ export function BacktestingDashboard() { {/* Equity Curve */}
- + + +
{/* Trade List */} diff --git a/web-dashboard/src/pages/MLDashboard.tsx b/web-dashboard/src/pages/MLDashboard.tsx index 9d6155ff9..4ed7da9e3 100644 --- a/web-dashboard/src/pages/MLDashboard.tsx +++ b/web-dashboard/src/pages/MLDashboard.tsx @@ -46,7 +46,7 @@ export function MLDashboard() { )} {/* Model Cards */} -
+
{MODELS.map((model) => { const pred = preds.find((p) => p.model === model); return ( @@ -66,7 +66,7 @@ export function MLDashboard() {
{/* Ensemble + Regime */} -
+
diff --git a/web-dashboard/src/pages/PerformanceDashboard.tsx b/web-dashboard/src/pages/PerformanceDashboard.tsx index 8e6993e96..2ca402d3d 100644 --- a/web-dashboard/src/pages/PerformanceDashboard.tsx +++ b/web-dashboard/src/pages/PerformanceDashboard.tsx @@ -1,4 +1,5 @@ import { PnLChart } from '../components/charts/PnLChart'; +import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary'; import { MetricCard } from '../components/charts/MetricCard'; import { useApiQuery } from '../hooks/useApi'; import type { PerformanceMetrics } from '../lib/types'; @@ -33,7 +34,7 @@ export function PerformanceDashboard() { )} {/* Top: Metric cards */} -
+
@@ -61,13 +62,17 @@ export function PerformanceDashboard() { {/* Middle: P&L Chart */}
- + + +
{/* Bottom: Daily returns + Heatmap */} -
+
- + + +
diff --git a/web-dashboard/src/pages/RiskDashboard.tsx b/web-dashboard/src/pages/RiskDashboard.tsx index 4a75847f8..9176fc025 100644 --- a/web-dashboard/src/pages/RiskDashboard.tsx +++ b/web-dashboard/src/pages/RiskDashboard.tsx @@ -1,3 +1,4 @@ +import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary'; import { RiskGauge } from '../components/risk/RiskGauge'; import { DrawdownChart } from '../components/risk/DrawdownChart'; import { EmergencyControls } from '../components/risk/EmergencyControls'; @@ -24,36 +25,44 @@ export function RiskDashboard() { )} {/* Top row: Risk gauges */} -
+
- + + +
- + + +
- + + +
{/* Middle: Drawdown chart */}
- + + +
{/* Bottom row: Alerts + Emergency Controls */} -
+
Risk Alerts diff --git a/web-dashboard/src/pages/TradingDashboard.tsx b/web-dashboard/src/pages/TradingDashboard.tsx index 68cceda41..2fa03e557 100644 --- a/web-dashboard/src/pages/TradingDashboard.tsx +++ b/web-dashboard/src/pages/TradingDashboard.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { CandlestickChart } from '../components/charts/CandlestickChart'; +import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary'; import { OrderBook } from '../components/trading/OrderBook'; import { PositionsTable } from '../components/trading/PositionsTable'; import { OrderForm } from '../components/trading/OrderForm'; @@ -46,9 +47,11 @@ export function TradingDashboard() { )} {/* Top row: Chart + Order Book */} -
+
- + + +
{/* Bottom: Orders + Order Form */} -
+
diff --git a/web-gateway/src/auth/middleware.rs b/web-gateway/src/auth/middleware.rs index 20b06fd4c..a480255fa 100644 --- a/web-gateway/src/auth/middleware.rs +++ b/web-gateway/src/auth/middleware.rs @@ -27,8 +27,10 @@ pub async fn auth_middleware( let validation = Validation::new(Algorithm::HS256); let key = DecodingKey::from_secret(config.jwt_secret.as_bytes()); - let token_data = - decode::(token, &key, &validation).map_err(|_| AppError::Unauthorized)?; + let token_data = decode::(token, &key, &validation).map_err(|e| { + tracing::debug!("JWT validation failed: {e}"); + AppError::Unauthorized + })?; request.extensions_mut().insert(token_data.claims); Ok(next.run(request).await) diff --git a/web-gateway/src/grpc/clients.rs b/web-gateway/src/grpc/clients.rs index 3aec10ed1..49a98fac7 100644 --- a/web-gateway/src/grpc/clients.rs +++ b/web-gateway/src/grpc/clients.rs @@ -4,17 +4,17 @@ use crate::proto::ml_training::ml_training_service_client::MlTrainingServiceClie use crate::proto::trading::backtesting_service_client::BacktestingServiceClient; use crate::proto::trading::trading_service_client::TradingServiceClient; -/// Create a TradingServiceClient from the shared channel +/// Create a `TradingServiceClient` from the shared channel pub fn trading_client(channel: &Channel) -> TradingServiceClient { TradingServiceClient::new(channel.clone()) } -/// Create a BacktestingServiceClient from the shared channel +/// Create a `BacktestingServiceClient` from the shared channel pub fn backtesting_client(channel: &Channel) -> BacktestingServiceClient { BacktestingServiceClient::new(channel.clone()) } -/// Create an MlTrainingServiceClient from the shared channel +/// Create an `MlTrainingServiceClient` from the shared channel pub fn ml_training_client(channel: &Channel) -> MlTrainingServiceClient { MlTrainingServiceClient::new(channel.clone()) } diff --git a/web-gateway/src/grpc/streams.rs b/web-gateway/src/grpc/streams.rs index 8d2ebfde1..64af53acd 100644 --- a/web-gateway/src/grpc/streams.rs +++ b/web-gateway/src/grpc/streams.rs @@ -44,9 +44,8 @@ pub fn start_grpc_stream_bridges( // Metrics stream bridge let tx_metrics = ws_broadcast.clone(); - let ch_metrics = channel.clone(); tokio::spawn(async move { - stream_metrics(ch_metrics, tx_metrics).await; + stream_metrics(channel, tx_metrics).await; }); } @@ -57,7 +56,7 @@ pub fn start_grpc_stream_bridges( }); } -/// Bridge SubscribeMarketData gRPC stream to WebSocket broadcast +/// Bridge `SubscribeMarketData` gRPC stream to WebSocket broadcast async fn stream_market_data(channel: Channel, tx: broadcast::Sender) { reconnect_loop("market_data", || async { let mut client = trading_client(&channel); @@ -86,7 +85,7 @@ async fn stream_market_data(channel: Channel, tx: broadcast::Sender) { .await; } -/// Bridge SubscribeOrderUpdates gRPC stream to WebSocket broadcast +/// Bridge `SubscribeOrderUpdates` gRPC stream to WebSocket broadcast async fn stream_order_updates(channel: Channel, tx: broadcast::Sender) { reconnect_loop("order_update", || async { let mut client = trading_client(&channel); @@ -109,7 +108,7 @@ async fn stream_order_updates(channel: Channel, tx: broadcast::Sender) { .await; } -/// Bridge SubscribeRiskAlerts gRPC stream to WebSocket broadcast +/// Bridge `SubscribeRiskAlerts` gRPC stream to WebSocket broadcast async fn stream_risk_alerts(channel: Channel, tx: broadcast::Sender) { reconnect_loop("risk_alert", || async { let mut client = trading_client(&channel); @@ -138,7 +137,7 @@ async fn stream_risk_alerts(channel: Channel, tx: broadcast::Sender) { .await; } -/// Bridge SubscribeMetrics gRPC stream to WebSocket broadcast +/// Bridge `SubscribeMetrics` gRPC stream to WebSocket broadcast async fn stream_metrics(channel: Channel, tx: broadcast::Sender) { reconnect_loop("metrics", || async { let mut client = trading_client(&channel); diff --git a/web-gateway/src/lib.rs b/web-gateway/src/lib.rs index 81cafc601..155b8a939 100644 --- a/web-gateway/src/lib.rs +++ b/web-gateway/src/lib.rs @@ -4,6 +4,7 @@ clippy::panic, clippy::indexing_slicing )] +#![allow(clippy::module_name_repetitions)] pub mod auth; pub mod config; @@ -16,23 +17,31 @@ pub mod ws; pub mod proto { pub mod trading { #![allow( - clippy::impl_trait_in_params, - clippy::shadow_reuse, - clippy::shadow_same, - clippy::shadow_unrelated, - clippy::empty_structs_with_brackets + clippy::all, + clippy::pedantic, + clippy::restriction, + clippy::nursery )] tonic::include_proto!("foxhunt.tli"); } pub mod ml_training { #![allow( - clippy::impl_trait_in_params, - clippy::shadow_reuse, - clippy::shadow_same, - clippy::shadow_unrelated, - clippy::empty_structs_with_brackets + clippy::all, + clippy::pedantic, + clippy::restriction, + clippy::nursery )] tonic::include_proto!("ml_training"); } + + pub mod health { + #![allow( + clippy::all, + clippy::pedantic, + clippy::restriction, + clippy::nursery + )] + tonic::include_proto!("grpc.health.v1"); + } } diff --git a/web-gateway/src/routes/auth.rs b/web-gateway/src/routes/auth.rs index 3372fdde9..9f0523d29 100644 --- a/web-gateway/src/routes/auth.rs +++ b/web-gateway/src/routes/auth.rs @@ -41,7 +41,7 @@ async fn login( // Generate JWT token let now = chrono::Utc::now(); let claims = Claims { - sub: body.username.clone(), + sub: body.username, iat: now.timestamp() as u64, exp: (now + chrono::Duration::hours(24)).timestamp() as u64, jti: uuid::Uuid::new_v4().to_string(), diff --git a/web-gateway/src/routes/mod.rs b/web-gateway/src/routes/mod.rs index c2de6d567..7b7785cf6 100644 --- a/web-gateway/src/routes/mod.rs +++ b/web-gateway/src/routes/mod.rs @@ -1,7 +1,12 @@ use axum::{middleware, routing, Json, Router}; use serde_json::json; +use std::sync::Arc; + use crate::auth::middleware::auth_middleware; +use crate::config::GatewayConfig; +use crate::proto::health::health_client::HealthClient; +use crate::proto::health::HealthCheckRequest; use crate::state::AppState; use crate::ws::handler::ws_handler; @@ -27,7 +32,7 @@ pub fn create_router(state: AppState) -> Router { .nest("/config", config::router()) .nest("/tune", tune::router()) .layer(middleware::from_fn_with_state( - state.config.clone(), + Arc::::clone(&state.config), auth_middleware, )); @@ -56,13 +61,42 @@ async fn health_check() -> Json { async fn readiness_check( axum::extract::State(state): axum::extract::State, -) -> Json { - Json(json!({ - "status": "ok", - "trading_service": state.trading_channel.is_some(), - "backtesting_service": state.backtesting_channel.is_some(), - "ml_training_service": state.ml_training_channel.is_some(), - })) +) -> (axum::http::StatusCode, Json) { + let trading_ok = check_health(&state.trading_channel).await; + let backtesting_ok = check_health(&state.backtesting_channel).await; + let ml_training_ok = check_health(&state.ml_training_channel).await; + + let all_ok = trading_ok && backtesting_ok && ml_training_ok; + let status = if all_ok { + axum::http::StatusCode::OK + } else { + axum::http::StatusCode::SERVICE_UNAVAILABLE + }; + + ( + status, + Json(json!({ + "status": if all_ok { "ready" } else { "degraded" }, + "trading_service": trading_ok, + "backtesting_service": backtesting_ok, + "ml_training_service": ml_training_ok, + })), + ) +} + +/// Ping a gRPC service via the standard health check RPC with a 2s timeout. +/// Returns `true` if the service responds SERVING, `false` otherwise. +async fn check_health(channel: &Option) -> bool { + let Some(ch) = channel else { return false }; + let mut client = HealthClient::new(ch.clone()); + let mut req = tonic::Request::new(HealthCheckRequest { + service: String::new(), + }); + req.set_timeout(std::time::Duration::from_secs(2)); + match client.check(req).await { + Ok(resp) => resp.into_inner().status == 1, // SERVING = 1 + Err(_) => false, + } } #[cfg(test)] @@ -92,7 +126,7 @@ mod tests { } #[tokio::test] - async fn test_ready_returns_service_status() { + async fn test_ready_returns_degraded_when_services_unreachable() { let state = test_state().await; let app = create_router(state); let req = Request::builder() @@ -100,13 +134,12 @@ mod tests { .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); + // Services exist but aren't running, so health check fails → 503 + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(json["status"], "ok"); - // Default config creates valid channels - assert_eq!(json["trading_service"], true); + assert_eq!(json["status"], "degraded"); } #[tokio::test] @@ -122,6 +155,7 @@ mod tests { .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["trading_service"], false); diff --git a/web-gateway/src/routes/tune.rs b/web-gateway/src/routes/tune.rs index 21b55de06..62e24b4cc 100644 --- a/web-gateway/src/routes/tune.rs +++ b/web-gateway/src/routes/tune.rs @@ -33,7 +33,7 @@ struct StartTuneBody { description: String, } -fn default_num_trials() -> u32 { +const fn default_num_trials() -> u32 { 20 } diff --git a/web-gateway/src/state.rs b/web-gateway/src/state.rs index 3046b39ca..685a4602d 100644 --- a/web-gateway/src/state.rs +++ b/web-gateway/src/state.rs @@ -1,9 +1,13 @@ use std::sync::Arc; +use std::time::Duration; use tokio::sync::broadcast; use tonic::transport::Channel; use crate::config::GatewayConfig; +/// Default gRPC request deadline — prevents indefinite hangs on slow/unresponsive services +const GRPC_TIMEOUT: Duration = Duration::from_secs(30); + /// Shared application state passed to all handlers via Axum's State extractor #[derive(Clone)] pub struct AppState { @@ -19,18 +23,18 @@ impl AppState { pub async fn new(config: GatewayConfig) -> anyhow::Result { let (ws_broadcast, _) = broadcast::channel(4096); - // Create lazy gRPC channels (connect on first use) + // Create lazy gRPC channels (connect on first use) with default timeout let trading_channel = Channel::from_shared(config.trading_service_url.clone()) .ok() - .map(|c| c.connect_lazy()); + .map(|c| c.timeout(GRPC_TIMEOUT).connect_lazy()); let backtesting_channel = Channel::from_shared(config.backtesting_service_url.clone()) .ok() - .map(|c| c.connect_lazy()); + .map(|c| c.timeout(GRPC_TIMEOUT).connect_lazy()); let ml_training_channel = Channel::from_shared(config.ml_training_service_url.clone()) .ok() - .map(|c| c.connect_lazy()); + .map(|c| c.timeout(GRPC_TIMEOUT).connect_lazy()); Ok(Self { config: Arc::new(config), diff --git a/web-gateway/src/ws/handler.rs b/web-gateway/src/ws/handler.rs index c8eaca5b2..8d51e9389 100644 --- a/web-gateway/src/ws/handler.rs +++ b/web-gateway/src/ws/handler.rs @@ -41,7 +41,10 @@ fn validate_ws_token(token: &str, config: &Arc) -> Result(token, &key, &validation).map_err(|_| AppError::Unauthorized)?; + let token_data = decode::(token, &key, &validation).map_err(|e| { + tracing::debug!("WebSocket JWT validation failed: {e}"); + AppError::Unauthorized + })?; Ok(token_data.claims) } @@ -57,12 +60,12 @@ async fn handle_ws_connection(socket: WebSocket, state: AppState) { let subscribed_topics: Arc>> = Arc::new(tokio::sync::Mutex::new(HashSet::new())); - let topics_for_broadcast = subscribed_topics.clone(); + let topics_for_broadcast = Arc::clone(&subscribed_topics); // Shared sender for both broadcast forwarding and pong responses let sender = Arc::new(tokio::sync::Mutex::new(ws_sender)); - let sender_for_broadcast = sender.clone(); - let sender_for_recv = sender.clone(); + let sender_for_broadcast = Arc::clone(&sender); + let sender_for_recv = Arc::clone(&sender); // Task: forward broadcast events to WebSocket client (filtered by subscribed topics) let mut send_task = tokio::spawn(async move { @@ -80,7 +83,7 @@ async fn handle_ws_connection(socket: WebSocket, state: AppState) { if should_send { let mut s = sender_for_broadcast.lock().await; - if s.send(Message::Text(msg_json.into())).await.is_err() { + if s.send(Message::Text(msg_json)).await.is_err() { break; // Client disconnected } } @@ -88,7 +91,7 @@ async fn handle_ws_connection(socket: WebSocket, state: AppState) { }); // Task: receive client messages (subscribe/unsubscribe) and respond to pings - let topics_for_recv = subscribed_topics.clone(); + let topics_for_recv = Arc::clone(&subscribed_topics); let mut recv_task = tokio::spawn(async move { while let Some(Ok(msg)) = ws_receiver.next().await { match msg { diff --git a/web-gateway/src/ws/messages.rs b/web-gateway/src/ws/messages.rs index 5e9ca4890..63a66b618 100644 --- a/web-gateway/src/ws/messages.rs +++ b/web-gateway/src/ws/messages.rs @@ -50,7 +50,7 @@ pub enum ServerMessage { impl ServerMessage { /// Extract the topic string for this message (used for client-side filtering) - pub fn topic(&self) -> &str { + pub const fn topic(&self) -> &str { match self { ServerMessage::MarketData { .. } => "market_data", ServerMessage::OrderUpdate { .. } => "order_update",