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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -33,7 +33,7 @@ struct StartTuneBody {
|
||||
description: String,
|
||||
}
|
||||
|
||||
fn default_num_trials() -> u32 {
|
||||
const fn default_num_trials() -> u32 {
|
||||
20
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user