safety(web-gateway,web-dashboard): clippy clean, gRPC timeouts, health probes, responsive grids, error boundaries

- Fix all 98 clippy warnings (0 remaining with -D warnings)
- Add 30s default gRPC timeout on all channels via Endpoint::timeout()
- Replace readiness probe is_some() with actual gRPC health check (returns 503 when degraded)
- Add responsive Tailwind breakpoints to all 5 dashboard pages
- Add ComponentErrorBoundary for crash-prone chart components
- Log JWT validation errors at debug level instead of silently discarding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-22 08:59:20 +01:00
parent bee0591ea1
commit 3c3bf30062
16 changed files with 202 additions and 75 deletions

View File

@@ -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<Props, State> {
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 (
<div className="flex flex-col items-center justify-center h-full p-4 text-center">
<span className="text-sm text-[var(--color-red)] mb-1">
{this.props.name} failed to render
</span>
<span className="text-xs text-[var(--color-text-secondary)] mb-2">
{this.state.error?.message}
</span>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="text-xs underline text-[var(--color-accent)]"
>
Retry
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -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 (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Start Form */}
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<div className="px-3 py-1.5 border-b border-[var(--color-border)]">
@@ -187,7 +188,9 @@ export function BacktestingDashboard() {
{/* Equity Curve */}
<div className="h-64 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<PnLChart title="Equity Curve" data={backtestResults.data?.equity_curve} />
<ComponentErrorBoundary name="Equity Curve">
<PnLChart title="Equity Curve" data={backtestResults.data?.equity_curve} />
</ComponentErrorBoundary>
</div>
{/* Trade List */}

View File

@@ -46,7 +46,7 @@ export function MLDashboard() {
)}
{/* Model Cards */}
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{MODELS.map((model) => {
const pred = preds.find((p) => p.model === model);
return (
@@ -66,7 +66,7 @@ export function MLDashboard() {
</div>
{/* Ensemble + Regime */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<EnsemblePanel predictions={preds} />
</div>

View File

@@ -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 */}
<div className="grid grid-cols-5 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<MetricCard label="Sharpe Ratio" value={m?.sharpe_ratio?.toFixed(2) ?? '--'} />
</div>
@@ -61,13 +62,17 @@ export function PerformanceDashboard() {
{/* Middle: P&L Chart */}
<div className="h-64 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<PnLChart title="Cumulative P&L" data={pnlData.data} />
<ComponentErrorBoundary name="P&L Chart">
<PnLChart title="Cumulative P&L" data={pnlData.data} />
</ComponentErrorBoundary>
</div>
{/* Bottom: Daily returns + Heatmap */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<PnLChart title="Daily Returns" data={pnlData.data} />
<ComponentErrorBoundary name="Daily Returns Chart">
<PnLChart title="Daily Returns" data={pnlData.data} />
</ComponentErrorBoundary>
</div>
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<div className="px-3 py-1.5 border-b border-[var(--color-border)]">

View File

@@ -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 */}
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<RiskGauge
label="VaR Utilization"
value={metrics?.portfolio_var ?? 0}
/>
<ComponentErrorBoundary name="VaR Gauge">
<RiskGauge
label="VaR Utilization"
value={metrics?.portfolio_var ?? 0}
/>
</ComponentErrorBoundary>
</div>
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<RiskGauge
label="Position Utilization"
value={metrics?.position_utilization ?? 0}
/>
<ComponentErrorBoundary name="Position Gauge">
<RiskGauge
label="Position Utilization"
value={metrics?.position_utilization ?? 0}
/>
</ComponentErrorBoundary>
</div>
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<RiskGauge
label="Drawdown"
value={Math.abs(metrics?.current_drawdown ?? 0)}
thresholds={{ warn: 5, danger: 10 }}
max={20}
/>
<ComponentErrorBoundary name="Drawdown Gauge">
<RiskGauge
label="Drawdown"
value={Math.abs(metrics?.current_drawdown ?? 0)}
thresholds={{ warn: 5, danger: 10 }}
max={20}
/>
</ComponentErrorBoundary>
</div>
</div>
{/* Middle: Drawdown chart */}
<div className="h-64 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<DrawdownChart />
<ComponentErrorBoundary name="Drawdown Chart">
<DrawdownChart />
</ComponentErrorBoundary>
</div>
{/* Bottom row: Alerts + Emergency Controls */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<div className="px-3 py-1.5 border-b border-[var(--color-border)]">
<span className="text-sm font-medium">Risk Alerts</span>

View File

@@ -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 */}
<div className="grid grid-cols-[1fr_320px] gap-4 h-80">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-4 lg:h-80">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)] overflow-hidden">
<CandlestickChart symbol="ES.FUT" />
<ComponentErrorBoundary name="Candlestick Chart">
<CandlestickChart symbol="ES.FUT" />
</ComponentErrorBoundary>
</div>
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)] overflow-hidden">
<OrderBook
@@ -68,7 +71,7 @@ export function TradingDashboard() {
</div>
{/* Bottom: Orders + Order Form */}
<div className="grid grid-cols-[1fr_360px] gap-4">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-4">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
<OrdersTable orders={orders.data} loading={orders.isLoading} />
</div>

View File

@@ -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::<Claims>(token, &key, &validation).map_err(|_| AppError::Unauthorized)?;
let token_data = decode::<Claims>(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)

View File

@@ -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<Channel> {
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<Channel> {
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<Channel> {
MlTrainingServiceClient::new(channel.clone())
}

View File

@@ -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<String>) {
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<String>) {
.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<String>) {
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<String>) {
.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<String>) {
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<String>) {
.await;
}
/// Bridge SubscribeMetrics gRPC stream to WebSocket broadcast
/// Bridge `SubscribeMetrics` gRPC stream to WebSocket broadcast
async fn stream_metrics(channel: Channel, tx: broadcast::Sender<String>) {
reconnect_loop("metrics", || async {
let mut client = trading_client(&channel);

View File

@@ -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");
}
}

View File

@@ -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(),

View File

@@ -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::<GatewayConfig>::clone(&state.config),
auth_middleware,
));
@@ -56,13 +61,42 @@ async fn health_check() -> Json<serde_json::Value> {
async fn readiness_check(
axum::extract::State(state): axum::extract::State<AppState>,
) -> Json<serde_json::Value> {
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<serde_json::Value>) {
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<tonic::transport::Channel>) -> 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);

View File

@@ -33,7 +33,7 @@ struct StartTuneBody {
description: String,
}
fn default_num_trials() -> u32 {
const fn default_num_trials() -> u32 {
20
}

View File

@@ -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<Self> {
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),

View File

@@ -41,7 +41,10 @@ fn validate_ws_token(token: &str, config: &Arc<GatewayConfig>) -> Result<Claims,
let validation = Validation::new(Algorithm::HS256);
let key = DecodingKey::from_secret(config.jwt_secret.as_bytes());
let token_data = decode::<Claims>(token, &key, &validation).map_err(|_| AppError::Unauthorized)?;
let token_data = decode::<Claims>(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<tokio::sync::Mutex<HashSet<String>>> =
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 {

View File

@@ -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",