feat: add auth gRPC service + migrate dashboard from REST/WS to grpc-web
Backend (Rust): - Create proto/auth.proto with Login and RefreshToken RPCs - Implement AuthGrpcService in services/api with JWT issuance - Register as unauthenticated service in main.rs (pre-auth) - Update JWT issuer from foxhunt-api-gateway to foxhunt-api Dashboard (TypeScript): - Install @connectrpc/connect-web + @bufbuild/protobuf - Generate TypeScript proto clients via buf (src/gen/) - Create grpc.ts transport with JWT interceptor - Rewrite all useApi hooks to typed gRPC calls - Replace WebSocket with useGrpcStream server-streaming hooks - Migrate all 6 pages + 6 components to grpc-web - Delete api.ts, websocket.ts, useWebSocket.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
35
proto/auth.proto
Normal file
35
proto/auth.proto
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package auth;
|
||||||
|
|
||||||
|
// Authentication service — token issuance and refresh.
|
||||||
|
// This service is exposed WITHOUT the auth interceptor (pre-authentication).
|
||||||
|
service AuthService {
|
||||||
|
// Authenticate with username/password, receive JWT tokens.
|
||||||
|
rpc Login(LoginRequest) returns (LoginResponse);
|
||||||
|
|
||||||
|
// Exchange a refresh token for a new access token.
|
||||||
|
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginRequest {
|
||||||
|
string username = 1;
|
||||||
|
string password = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginResponse {
|
||||||
|
string access_token = 1;
|
||||||
|
string refresh_token = 2;
|
||||||
|
// Unix timestamp (seconds) when the access token expires.
|
||||||
|
int64 expires_at = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RefreshTokenRequest {
|
||||||
|
string refresh_token = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RefreshTokenResponse {
|
||||||
|
string access_token = 1;
|
||||||
|
string refresh_token = 2;
|
||||||
|
int64 expires_at = 3;
|
||||||
|
}
|
||||||
@@ -179,6 +179,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
&["../../proto"],
|
&["../../proto"],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Compile Auth Service proto (package: auth) — server only (no client needed)
|
||||||
|
config
|
||||||
|
.clone()
|
||||||
|
.build_server(true)
|
||||||
|
.build_client(false)
|
||||||
|
.file_descriptor_set_path(out_dir.join("auth_descriptor.bin"))
|
||||||
|
.compile_well_known_types(true)
|
||||||
|
.extern_path(".google.protobuf", "::prost_types")
|
||||||
|
.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
|
||||||
|
.server_mod_attribute(".", "#[allow(unused_qualifications)]")
|
||||||
|
.client_mod_attribute(".", "#[allow(unused_qualifications)]")
|
||||||
|
.compile_protos(
|
||||||
|
&["../../proto/auth.proto"],
|
||||||
|
&["../../proto"],
|
||||||
|
)?;
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=../../proto/fxt_trading.proto");
|
println!("cargo:rerun-if-changed=../../proto/fxt_trading.proto");
|
||||||
println!("cargo:rerun-if-changed=../../proto/trading.proto");
|
println!("cargo:rerun-if-changed=../../proto/trading.proto");
|
||||||
println!("cargo:rerun-if-changed=../../proto/risk.proto");
|
println!("cargo:rerun-if-changed=../../proto/risk.proto");
|
||||||
@@ -189,6 +205,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
println!("cargo:rerun-if-changed=../../proto/broker_gateway.proto");
|
println!("cargo:rerun-if-changed=../../proto/broker_gateway.proto");
|
||||||
println!("cargo:rerun-if-changed=../../proto/data_acquisition.proto");
|
println!("cargo:rerun-if-changed=../../proto/data_acquisition.proto");
|
||||||
println!("cargo:rerun-if-changed=../../proto/ml.proto");
|
println!("cargo:rerun-if-changed=../../proto/ml.proto");
|
||||||
|
println!("cargo:rerun-if-changed=../../proto/auth.proto");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
201
services/api/src/auth/grpc_login.rs
Normal file
201
services/api/src/auth/grpc_login.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
//! gRPC AuthService implementation — Login and RefreshToken RPCs.
|
||||||
|
//!
|
||||||
|
//! This service is exposed WITHOUT the auth interceptor since it handles
|
||||||
|
//! pre-authentication token issuance.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::jwt::service::{JwtClaims, JwtConfig};
|
||||||
|
use crate::auth_proto::auth_service_server::AuthService;
|
||||||
|
use crate::auth_proto::{
|
||||||
|
LoginRequest, LoginResponse, RefreshTokenRequest, RefreshTokenResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Access token lifetime: 1 hour (seconds).
|
||||||
|
const ACCESS_TOKEN_EXPIRY_SECS: u64 = 3600;
|
||||||
|
|
||||||
|
/// Refresh token lifetime: 24 hours (seconds).
|
||||||
|
const REFRESH_TOKEN_EXPIRY_SECS: u64 = 86400;
|
||||||
|
|
||||||
|
/// gRPC implementation of `auth.AuthService`.
|
||||||
|
pub struct AuthGrpcService {
|
||||||
|
jwt_config: Arc<JwtConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthGrpcService {
|
||||||
|
/// Create a new `AuthGrpcService` backed by the given JWT configuration.
|
||||||
|
pub fn new(jwt_config: Arc<JwtConfig>) -> Self {
|
||||||
|
Self { jwt_config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate user credentials.
|
||||||
|
///
|
||||||
|
/// Checks the `USER_CREDENTIALS` environment variable (format: `user:pass`)
|
||||||
|
/// first, then falls back to a hard-coded development user (`admin` / `admin`).
|
||||||
|
fn validate_credentials(username: &str, password: &str) -> Result<(), Status> {
|
||||||
|
// Check USER_CREDENTIALS env var (format: "user:pass")
|
||||||
|
if let Ok(creds) = std::env::var("USER_CREDENTIALS") {
|
||||||
|
if let Some((env_user, env_pass)) = creds.split_once(':') {
|
||||||
|
if username == env_user && password == env_pass {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Env var set but credentials don't match
|
||||||
|
return Err(Status::unauthenticated("invalid credentials"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Development fallback: admin/admin
|
||||||
|
if username == "admin" && password == "admin" {
|
||||||
|
warn!("Login accepted using hard-coded dev credentials (set USER_CREDENTIALS for production)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Status::unauthenticated("invalid credentials"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build and sign a JWT token (access or refresh).
|
||||||
|
fn issue_token(
|
||||||
|
&self,
|
||||||
|
subject: &str,
|
||||||
|
token_type: &str,
|
||||||
|
session_id: &str,
|
||||||
|
expiry_secs: u64,
|
||||||
|
) -> Result<(String, u64), Status> {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("System time error: {}", e);
|
||||||
|
Status::internal("system time error")
|
||||||
|
})?
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let exp = now.checked_add(expiry_secs).ok_or_else(|| {
|
||||||
|
error!("Overflow computing token expiry");
|
||||||
|
Status::internal("token expiry overflow")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let claims = JwtClaims {
|
||||||
|
jti: Uuid::new_v4().to_string(),
|
||||||
|
sub: subject.to_string(),
|
||||||
|
iat: now,
|
||||||
|
exp,
|
||||||
|
iss: self.jwt_config.jwt_issuer.clone(),
|
||||||
|
aud: self.jwt_config.jwt_audience.clone(),
|
||||||
|
roles: vec!["user".to_string()],
|
||||||
|
permissions: vec![],
|
||||||
|
token_type: token_type.to_string(),
|
||||||
|
session_id: Some(session_id.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = Header::new(Algorithm::HS256);
|
||||||
|
let key = EncodingKey::from_secret(self.jwt_config.jwt_secret.as_bytes());
|
||||||
|
let token = encode(&header, &claims, &key).map_err(|e| {
|
||||||
|
error!("Failed to encode JWT: {}", e);
|
||||||
|
Status::internal("failed to issue token")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok((token, exp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode and validate a refresh token, returning its claims.
|
||||||
|
fn decode_refresh_token(&self, token: &str) -> Result<JwtClaims, Status> {
|
||||||
|
if token.is_empty() {
|
||||||
|
return Err(Status::invalid_argument("refresh token is empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if token.len() > 8192 {
|
||||||
|
return Err(Status::invalid_argument("refresh token too long"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = DecodingKey::from_secret(self.jwt_config.jwt_secret.as_bytes());
|
||||||
|
|
||||||
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
|
validation.set_issuer(&[&self.jwt_config.jwt_issuer]);
|
||||||
|
validation.set_audience(&[&self.jwt_config.jwt_audience]);
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.validate_nbf = false;
|
||||||
|
validation.leeway = 10;
|
||||||
|
validation.validate_aud = true;
|
||||||
|
|
||||||
|
let token_data = decode::<JwtClaims>(token, &key, &validation).map_err(|e| {
|
||||||
|
warn!("Refresh token decode failed: {}", e);
|
||||||
|
Status::unauthenticated("invalid refresh token")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if token_data.claims.token_type != "refresh" {
|
||||||
|
return Err(Status::unauthenticated(
|
||||||
|
"token is not a refresh token",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if token_data.claims.sub.is_empty() {
|
||||||
|
return Err(Status::unauthenticated("refresh token has empty subject"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(token_data.claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl AuthService for AuthGrpcService {
|
||||||
|
async fn login(
|
||||||
|
&self,
|
||||||
|
request: Request<LoginRequest>,
|
||||||
|
) -> Result<Response<LoginResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
if req.username.is_empty() || req.password.is_empty() {
|
||||||
|
return Err(Status::invalid_argument(
|
||||||
|
"username and password are required",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::validate_credentials(&req.username, &req.password)?;
|
||||||
|
|
||||||
|
let session_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let (access_token, expires_at) =
|
||||||
|
self.issue_token(&req.username, "access", &session_id, ACCESS_TOKEN_EXPIRY_SECS)?;
|
||||||
|
let (refresh_token, _) =
|
||||||
|
self.issue_token(&req.username, "refresh", &session_id, REFRESH_TOKEN_EXPIRY_SECS)?;
|
||||||
|
|
||||||
|
info!(user = %req.username, "Login successful, tokens issued");
|
||||||
|
|
||||||
|
Ok(Response::new(LoginResponse {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
expires_at: i64::try_from(expires_at).unwrap_or(i64::MAX),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_token(
|
||||||
|
&self,
|
||||||
|
request: Request<RefreshTokenRequest>,
|
||||||
|
) -> Result<Response<RefreshTokenResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let claims = self.decode_refresh_token(&req.refresh_token)?;
|
||||||
|
|
||||||
|
let session_id = claims
|
||||||
|
.session_id
|
||||||
|
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||||
|
|
||||||
|
let (access_token, expires_at) =
|
||||||
|
self.issue_token(&claims.sub, "access", &session_id, ACCESS_TOKEN_EXPIRY_SECS)?;
|
||||||
|
let (refresh_token, _) =
|
||||||
|
self.issue_token(&claims.sub, "refresh", &session_id, REFRESH_TOKEN_EXPIRY_SECS)?;
|
||||||
|
|
||||||
|
info!(user = %claims.sub, "Token refreshed successfully");
|
||||||
|
|
||||||
|
Ok(Response::new(RefreshTokenResponse {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
expires_at: i64::try_from(expires_at).unwrap_or(i64::MAX),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ impl JwtConfig {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
jwt_issuer: "foxhunt-api-gateway".to_string(),
|
jwt_issuer: "foxhunt-api".to_string(),
|
||||||
jwt_audience: "foxhunt-services".to_string(),
|
jwt_audience: "foxhunt-services".to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -454,7 +454,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("Should create config with valid JWT_SECRET");
|
.expect("Should create config with valid JWT_SECRET");
|
||||||
|
|
||||||
assert_eq!(config.jwt_issuer, "foxhunt-api-gateway");
|
assert_eq!(config.jwt_issuer, "foxhunt-api");
|
||||||
assert_eq!(config.jwt_audience, "foxhunt-services");
|
assert_eq!(config.jwt_audience, "foxhunt-services");
|
||||||
assert!(config.jwt_secret.len() >= 64);
|
assert!(config.jwt_secret.len() >= 64);
|
||||||
|
|
||||||
@@ -484,7 +484,7 @@ mod tests {
|
|||||||
match result {
|
match result {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
// Verify config was loaded successfully
|
// Verify config was loaded successfully
|
||||||
assert_eq!(config.jwt_issuer, "foxhunt-api-gateway");
|
assert_eq!(config.jwt_issuer, "foxhunt-api");
|
||||||
assert_eq!(config.jwt_audience, "foxhunt-services");
|
assert_eq!(config.jwt_audience, "foxhunt-services");
|
||||||
assert!(config.jwt_secret.len() >= 64);
|
assert!(config.jwt_secret.len() >= 64);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
//! - Authorization: <100ns (in-memory cache)
|
//! - Authorization: <100ns (in-memory cache)
|
||||||
//! - Rate limiting: <50ns (atomic counters)
|
//! - Rate limiting: <50ns (atomic counters)
|
||||||
|
|
||||||
|
pub mod grpc_login;
|
||||||
pub mod interceptor;
|
pub mod interceptor;
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
#[cfg(feature = "mfa")]
|
#[cfg(feature = "mfa")]
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ pub const DATA_ACQUISITION_FILE_DESCRIPTOR_SET: &[u8] =
|
|||||||
tonic::include_file_descriptor_set!("data_acquisition_descriptor");
|
tonic::include_file_descriptor_set!("data_acquisition_descriptor");
|
||||||
pub const ML_FILE_DESCRIPTOR_SET: &[u8] =
|
pub const ML_FILE_DESCRIPTOR_SET: &[u8] =
|
||||||
tonic::include_file_descriptor_set!("ml_descriptor");
|
tonic::include_file_descriptor_set!("ml_descriptor");
|
||||||
|
pub const AUTH_FILE_DESCRIPTOR_SET: &[u8] =
|
||||||
|
tonic::include_file_descriptor_set!("auth_descriptor");
|
||||||
|
|
||||||
// Include generated protobuf code FIRST (needed by other modules)
|
// Include generated protobuf code FIRST (needed by other modules)
|
||||||
pub mod foxhunt {
|
pub mod foxhunt {
|
||||||
@@ -91,6 +93,11 @@ pub mod ml_inference {
|
|||||||
tonic::include_proto!("ml");
|
tonic::include_proto!("ml");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth Service proto (server-only, unauthenticated)
|
||||||
|
pub mod auth_proto {
|
||||||
|
tonic::include_proto!("auth");
|
||||||
|
}
|
||||||
|
|
||||||
// Error module MUST come before modules that use it
|
// Error module MUST come before modules that use it
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ async fn main() -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build gRPC server with all services
|
// Build gRPC server with all services
|
||||||
|
use api::auth_proto::auth_service_server::AuthServiceServer;
|
||||||
use api::foxhunt::tli::{
|
use api::foxhunt::tli::{
|
||||||
backtesting_service_server::BacktestingServiceServer,
|
backtesting_service_server::BacktestingServiceServer,
|
||||||
trading_service_server::TradingServiceServer,
|
trading_service_server::TradingServiceServer,
|
||||||
@@ -633,13 +634,18 @@ async fn main() -> Result<()> {
|
|||||||
.register_encoded_file_descriptor_set(api::BROKER_GATEWAY_FILE_DESCRIPTOR_SET)
|
.register_encoded_file_descriptor_set(api::BROKER_GATEWAY_FILE_DESCRIPTOR_SET)
|
||||||
.register_encoded_file_descriptor_set(api::DATA_ACQUISITION_FILE_DESCRIPTOR_SET)
|
.register_encoded_file_descriptor_set(api::DATA_ACQUISITION_FILE_DESCRIPTOR_SET)
|
||||||
.register_encoded_file_descriptor_set(api::ML_FILE_DESCRIPTOR_SET)
|
.register_encoded_file_descriptor_set(api::ML_FILE_DESCRIPTOR_SET)
|
||||||
|
.register_encoded_file_descriptor_set(api::AUTH_FILE_DESCRIPTOR_SET)
|
||||||
.build_v1()
|
.build_v1()
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to build reflection service: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to build reflection service: {}", e))?;
|
||||||
|
|
||||||
// Add health + reflection services
|
// Create unauthenticated auth service (Login / RefreshToken -- pre-authentication)
|
||||||
|
let auth_grpc_service = api::auth::grpc_login::AuthGrpcService::new(Arc::new(jwt_config));
|
||||||
|
|
||||||
|
// Add health + reflection services, then unauthenticated auth service
|
||||||
let mut router = server_builder
|
let mut router = server_builder
|
||||||
.add_service(health_service)
|
.add_service(health_service)
|
||||||
.add_service(reflection_service);
|
.add_service(reflection_service)
|
||||||
|
.add_service(AuthServiceServer::new(auth_grpc_service));
|
||||||
|
|
||||||
// Always add trading service (required) with authentication
|
// Always add trading service (required) with authentication
|
||||||
router = router.add_service(TradingServiceServer::with_interceptor(
|
router = router.add_service(TradingServiceServer::with_interceptor(
|
||||||
|
|||||||
7
web-dashboard/buf.gen.yaml
Normal file
7
web-dashboard/buf.gen.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
version: v2
|
||||||
|
plugins:
|
||||||
|
- local: protoc-gen-es
|
||||||
|
out: src/gen
|
||||||
|
opt: target=ts
|
||||||
|
inputs:
|
||||||
|
- directory: ../proto
|
||||||
238
web-dashboard/package-lock.json
generated
238
web-dashboard/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "web-dashboard",
|
"name": "web-dashboard",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.11.0",
|
||||||
|
"@connectrpc/connect": "^2.1.1",
|
||||||
|
"@connectrpc/connect-web": "^2.1.1",
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"lightweight-charts": "^5.1.0",
|
"lightweight-charts": "^5.1.0",
|
||||||
@@ -19,6 +22,8 @@
|
|||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@bufbuild/buf": "^1.66.0",
|
||||||
|
"@bufbuild/protoc-gen-es": "^2.11.0",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
@@ -315,6 +320,226 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bufbuild/buf": {
|
||||||
|
"version": "1.66.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.66.0.tgz",
|
||||||
|
"integrity": "sha512-JSZT6OT8uKG2frScYNOS3Y4E+7wg1KSPQMKfLlbZZFnBoXwMqDdsxQF7O5ucameU+3DKRs+yQFI8tNQjFJ5T2g==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"buf": "bin/buf",
|
||||||
|
"protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking",
|
||||||
|
"protoc-gen-buf-lint": "bin/protoc-gen-buf-lint"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@bufbuild/buf-darwin-arm64": "1.66.0",
|
||||||
|
"@bufbuild/buf-darwin-x64": "1.66.0",
|
||||||
|
"@bufbuild/buf-linux-aarch64": "1.66.0",
|
||||||
|
"@bufbuild/buf-linux-armv7": "1.66.0",
|
||||||
|
"@bufbuild/buf-linux-x64": "1.66.0",
|
||||||
|
"@bufbuild/buf-win32-arm64": "1.66.0",
|
||||||
|
"@bufbuild/buf-win32-x64": "1.66.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-darwin-arm64": {
|
||||||
|
"version": "1.66.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.66.0.tgz",
|
||||||
|
"integrity": "sha512-C9orXX4SSVYKEumWFEO2T6xKrXsrvij0uS8kR20sHt9MYuFke14y6DfnAvXINPZrNpDiyOTldG7BPQtR40poRw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-darwin-x64": {
|
||||||
|
"version": "1.66.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.66.0.tgz",
|
||||||
|
"integrity": "sha512-5I33S9UbimoBNCpOdMsmJHcW9NfxzKW5PsVrSyajpo+LL22Lfyha2ZO3JNhT/k3ZlGVvtpDAS9pRS0ouNKmGgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-linux-aarch64": {
|
||||||
|
"version": "1.66.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.66.0.tgz",
|
||||||
|
"integrity": "sha512-KFz9HukP3ACKfSe0fo+XbxndQR0kKCVLVfUkoc6qPUTrKn9lACnah6HlUtBlwanK95vg+MIMvy0DWoLYUX9cuA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-linux-armv7": {
|
||||||
|
"version": "1.66.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.66.0.tgz",
|
||||||
|
"integrity": "sha512-W56iEZtnqB4ww9JCyhIipPrwZVe/zQ5I0pmWrJF8Daw67qvRYtvP3NSxtF5f0yAoB0tQAXDoqIiB/Ypl1UOz0g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-linux-x64": {
|
||||||
|
"version": "1.66.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.66.0.tgz",
|
||||||
|
"integrity": "sha512-6AYYyc2O32jnDjjHbyW9Oqcehd+PMw+34qVo+wGfaEgGwC+fyoL2oxcEmzsPyEvhY4mkYJRXaCb8p90Ndzd2vQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-win32-arm64": {
|
||||||
|
"version": "1.66.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.66.0.tgz",
|
||||||
|
"integrity": "sha512-S+CD5NZ6q0HCI0g0PTVypjSQ63LsLm1Svd7yG1MjKz9mQ83Vl6rYXuzmYYVi2cww7w5tDew8sYASZETewvEuxg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-win32-x64": {
|
||||||
|
"version": "1.66.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.66.0.tgz",
|
||||||
|
"integrity": "sha512-uSPoWRaX8Qd5pHZt6zzRuZXr4Sdbqe4Pm1PmMax/SGUPacVpH1v9AGX1/BTgx+g+b4vLS5T4SGxTl9ngkQaY3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/protobuf": {
|
||||||
|
"version": "2.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
|
||||||
|
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
||||||
|
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/protoc-gen-es": {
|
||||||
|
"version": "2.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.11.0.tgz",
|
||||||
|
"integrity": "sha512-VzQuwEQDXipbZ1soWUuAWm1Z0C3B/IDWGeysnbX6ogJ6As91C2mdvAND/ekQ4YIWgen4d5nqLfIBOWLqCCjYUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "2.11.0",
|
||||||
|
"@bufbuild/protoplugin": "2.11.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"protoc-gen-es": "bin/protoc-gen-es"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@bufbuild/protobuf": "2.11.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@bufbuild/protobuf": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/protoplugin": {
|
||||||
|
"version": "2.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.11.0.tgz",
|
||||||
|
"integrity": "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "2.11.0",
|
||||||
|
"@typescript/vfs": "^1.6.2",
|
||||||
|
"typescript": "5.4.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/protoplugin/node_modules/typescript": {
|
||||||
|
"version": "5.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||||
|
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@connectrpc/connect": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@connectrpc/connect-web": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.7.0",
|
||||||
|
"@connectrpc/connect": "2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
@@ -2087,6 +2312,19 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript/vfs": {
|
||||||
|
"version": "1.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz",
|
||||||
|
"integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
||||||
|
|||||||
@@ -7,9 +7,13 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"generate": "buf generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.11.0",
|
||||||
|
"@connectrpc/connect": "^2.1.1",
|
||||||
|
"@connectrpc/connect-web": "^2.1.1",
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"lightweight-charts": "^5.1.0",
|
"lightweight-charts": "^5.1.0",
|
||||||
@@ -21,6 +25,8 @@
|
|||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@bufbuild/buf": "^1.66.0",
|
||||||
|
"@bufbuild/protoc-gen-es": "^2.11.0",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { getTokenClaims } from '../../lib/auth';
|
import { getTokenClaims } from '../../lib/auth';
|
||||||
import { wsManager } from '../../lib/websocket';
|
import { useMetricsStream } from '../../hooks/useGrpcStream';
|
||||||
|
|
||||||
const EMPTY_TOPICS: string[] = [];
|
|
||||||
|
|
||||||
export function StatusBar() {
|
export function StatusBar() {
|
||||||
const { connected } = useWebSocket(EMPTY_TOPICS);
|
const { connected } = useMetricsStream();
|
||||||
const { authenticated, logout } = useAuth();
|
const { authenticated, logout } = useAuth();
|
||||||
const claims = authenticated ? getTokenClaims() : null;
|
const claims = authenticated ? getTokenClaims() : null;
|
||||||
|
|
||||||
@@ -20,18 +17,7 @@ export function StatusBar() {
|
|||||||
}`}
|
}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
WS: {connected ? 'Connected' : (
|
gRPC: {connected ? 'Connected' : 'Disconnected'}
|
||||||
<>
|
|
||||||
Disconnected
|
|
||||||
<button
|
|
||||||
onClick={() => wsManager.reconnect()}
|
|
||||||
className="ml-1 px-1.5 py-0.5 rounded text-[var(--color-accent)] hover:bg-[var(--color-accent)]/10 transition-colors"
|
|
||||||
aria-label="Reconnect WebSocket"
|
|
||||||
>
|
|
||||||
Reconnect
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{authenticated && claims && (
|
{authenticated && claims && (
|
||||||
<span>User: {claims.sub}</span>
|
<span>User: {claims.sub}</span>
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
import type { TrainingJob } from '../../lib/types';
|
import type { TrainingJobSummary } from '../../gen/ml_training_pb';
|
||||||
|
import { TrainingStatus } from '../../gen/ml_training_pb';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
jobs?: TrainingJob[];
|
jobs?: TrainingJobSummary[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusStr(status: TrainingStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case TrainingStatus.PENDING: return 'queued';
|
||||||
|
case TrainingStatus.RUNNING: return 'running';
|
||||||
|
case TrainingStatus.COMPLETED: return 'completed';
|
||||||
|
case TrainingStatus.FAILED: return 'failed';
|
||||||
|
case TrainingStatus.STOPPED: return 'stopped';
|
||||||
|
default: return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function TrainingProgress({ jobs = [], loading }: Props) {
|
export function TrainingProgress({ jobs = [], loading }: Props) {
|
||||||
|
const runningCount = jobs.filter(
|
||||||
|
(j) => j.status === TrainingStatus.RUNNING,
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--color-border)]">
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--color-border)]">
|
||||||
<span className="text-sm font-medium">Training Jobs</span>
|
<span className="text-sm font-medium">Training Jobs</span>
|
||||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||||
{jobs.filter((j) => j.status === 'running').length} running
|
{runningCount} running
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -27,37 +43,40 @@ export function TrainingProgress({ jobs = [], loading }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{jobs.map((job) => (
|
{jobs.map((job) => (
|
||||||
<div key={job.job_id} className="px-3 py-2 space-y-1">
|
<div key={job.jobId} className="px-3 py-2 space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium">{job.model_type}</span>
|
<span className="text-xs font-medium">{job.modelType}</span>
|
||||||
<span className="text-xs font-mono text-[var(--color-text-secondary)]">
|
<span className="text-xs font-mono text-[var(--color-text-secondary)]">
|
||||||
{job.job_id.slice(0, 8)}
|
{job.jobId.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar — use description or a placeholder since proto doesn't have progress field */}
|
||||||
<div className="w-full bg-[var(--color-bg-primary)] rounded-full h-1.5">
|
<div className="w-full bg-[var(--color-bg-primary)] rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
className={`h-1.5 rounded-full transition-all ${
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
job.status === 'failed'
|
job.status === TrainingStatus.FAILED
|
||||||
? 'bg-[var(--color-red)]'
|
? 'bg-[var(--color-red)]'
|
||||||
: job.status === 'completed'
|
: job.status === TrainingStatus.COMPLETED
|
||||||
? 'bg-[var(--color-green)]'
|
? 'bg-[var(--color-green)]'
|
||||||
: 'bg-[var(--color-accent)]'
|
: 'bg-[var(--color-accent)]'
|
||||||
}`}
|
}`}
|
||||||
style={{ width: `${job.progress * 100}%` }}
|
style={{
|
||||||
|
width: job.status === TrainingStatus.COMPLETED
|
||||||
|
? '100%'
|
||||||
|
: job.status === TrainingStatus.RUNNING
|
||||||
|
? '50%'
|
||||||
|
: '0%',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between text-xs text-[var(--color-text-secondary)]">
|
<div className="flex justify-between text-xs text-[var(--color-text-secondary)]">
|
||||||
<span>
|
<span>{job.modelType}</span>
|
||||||
Epoch {job.epoch}/{job.total_epochs}
|
<span>{job.description || statusStr(job.status)}</span>
|
||||||
</span>
|
|
||||||
<span>Loss: {job.loss.toFixed(4)}</span>
|
|
||||||
<span>{(job.progress * 100).toFixed(0)}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -66,18 +85,19 @@ export function TrainingProgress({ jobs = [], loading }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: TrainingJob['status'] }) {
|
function StatusBadge({ status }: { status: TrainingStatus }) {
|
||||||
const styles = {
|
const label = statusStr(status);
|
||||||
|
const styles: Record<string, string> = {
|
||||||
queued: 'text-[var(--color-text-secondary)] bg-slate-500/10',
|
queued: 'text-[var(--color-text-secondary)] bg-slate-500/10',
|
||||||
running: 'text-[var(--color-accent)] bg-blue-500/10',
|
running: 'text-[var(--color-accent)] bg-blue-500/10',
|
||||||
completed: 'text-[var(--color-green)] bg-green-500/10',
|
completed: 'text-[var(--color-green)] bg-green-500/10',
|
||||||
failed: 'text-[var(--color-red)] bg-red-500/10',
|
failed: 'text-[var(--color-red)] bg-red-500/10',
|
||||||
stopped: 'text-[var(--color-yellow)] bg-yellow-500/10',
|
stopped: 'text-[var(--color-yellow)] bg-yellow-500/10',
|
||||||
}[status];
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`text-xs px-2 py-0.5 rounded ${styles}`}>
|
<span className={`text-xs px-2 py-0.5 rounded ${styles[label] ?? ''}`}>
|
||||||
{status}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useApiPost } from '../../hooks/useApi';
|
import { useEmergencyStop } from '../../hooks/useApi';
|
||||||
|
|
||||||
export function EmergencyControls() {
|
export function EmergencyControls() {
|
||||||
const [confirmText, setConfirmText] = useState('');
|
const [confirmText, setConfirmText] = useState('');
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
const emergencyStop = useApiPost('/risk/emergency-stop');
|
const emergencyStop = useEmergencyStop();
|
||||||
|
|
||||||
function handleEmergencyStop() {
|
function handleEmergencyStop() {
|
||||||
if (confirmText === 'STOP') {
|
if (confirmText === 'STOP') {
|
||||||
emergencyStop.mutate({});
|
emergencyStop.mutate({ reason: 'Manual emergency stop from dashboard' });
|
||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
setConfirmText('');
|
setConfirmText('');
|
||||||
emergencyStop.reset();
|
emergencyStop.reset();
|
||||||
@@ -26,7 +26,7 @@ export function EmergencyControls() {
|
|||||||
{!showConfirm ? (
|
{!showConfirm ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowConfirm(true)}
|
onClick={() => setShowConfirm(true)}
|
||||||
aria-label="Trigger emergency stop — cancels all orders and closes all positions"
|
aria-label="Trigger emergency stop -- cancels all orders and closes all positions"
|
||||||
className="w-full py-3 text-sm font-bold rounded bg-[var(--color-red)] text-white hover:bg-red-600 transition-colors"
|
className="w-full py-3 text-sm font-bold rounded bg-[var(--color-red)] text-white hover:bg-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
EMERGENCY STOP
|
EMERGENCY STOP
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useApiPost } from '../../hooks/useApi';
|
import { useSubmitOrder } from '../../hooks/useApi';
|
||||||
|
import { OrderSide, OrderType } from '../../gen/trading_pb';
|
||||||
interface OrderPayload {
|
|
||||||
symbol: string;
|
|
||||||
side: string;
|
|
||||||
order_type: string;
|
|
||||||
quantity: number;
|
|
||||||
price?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ValidationErrors {
|
interface ValidationErrors {
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
@@ -23,7 +16,7 @@ export function OrderForm() {
|
|||||||
const [price, setPrice] = useState('');
|
const [price, setPrice] = useState('');
|
||||||
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
||||||
|
|
||||||
const submitOrder = useApiPost<unknown, OrderPayload>('/trading/orders');
|
const submitOrder = useSubmitOrder();
|
||||||
|
|
||||||
const symbolValid = /^[A-Z0-9.]+$/i.test(symbol);
|
const symbolValid = /^[A-Z0-9.]+$/i.test(symbol);
|
||||||
|
|
||||||
@@ -61,18 +54,13 @@ export function OrderForm() {
|
|||||||
setValidationErrors(errors);
|
setValidationErrors(errors);
|
||||||
if (Object.keys(errors).length > 0) return;
|
if (Object.keys(errors).length > 0) return;
|
||||||
|
|
||||||
const payload: OrderPayload = {
|
submitOrder.mutate({
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side: side === 'buy' ? String(OrderSide.BUY) : String(OrderSide.SELL),
|
||||||
order_type: orderType,
|
orderType: orderType === 'market' ? String(OrderType.MARKET) : String(OrderType.LIMIT),
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
};
|
price: orderType === 'limit' ? parseFloat(price) : undefined,
|
||||||
|
});
|
||||||
if (orderType === 'limit') {
|
|
||||||
payload.price = parseFloat(price);
|
|
||||||
}
|
|
||||||
|
|
||||||
submitOrder.mutate(payload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,18 +1,74 @@
|
|||||||
import type { Order } from '../../lib/types';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useApiDelete } from '../../hooks/useApi';
|
import { tradingClient } from '../../lib/grpc';
|
||||||
|
import {
|
||||||
|
OrderSide,
|
||||||
|
OrderType as ProtoOrderType,
|
||||||
|
OrderStatus,
|
||||||
|
} from '../../gen/trading_pb';
|
||||||
|
import type { Order } from '../../gen/trading_pb';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
orders?: Order[];
|
orders?: Order[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sideLabel(side: OrderSide): string {
|
||||||
|
switch (side) {
|
||||||
|
case OrderSide.BUY: return 'BUY';
|
||||||
|
case OrderSide.SELL: return 'SELL';
|
||||||
|
default: return 'N/A';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderTypeLabel(ot: ProtoOrderType): string {
|
||||||
|
switch (ot) {
|
||||||
|
case ProtoOrderType.MARKET: return 'market';
|
||||||
|
case ProtoOrderType.LIMIT: return 'limit';
|
||||||
|
case ProtoOrderType.STOP: return 'stop';
|
||||||
|
case ProtoOrderType.STOP_LIMIT: return 'stop-limit';
|
||||||
|
default: return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabelFn(status: OrderStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case OrderStatus.PENDING: return 'pending';
|
||||||
|
case OrderStatus.SUBMITTED: return 'open';
|
||||||
|
case OrderStatus.PARTIALLY_FILLED: return 'partially_filled';
|
||||||
|
case OrderStatus.FILLED: return 'filled';
|
||||||
|
case OrderStatus.CANCELLED: return 'cancelled';
|
||||||
|
case OrderStatus.REJECTED: return 'rejected';
|
||||||
|
default: return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColorFn(status: OrderStatus): string {
|
||||||
|
const label = statusLabelFn(status);
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
pending: 'text-[var(--color-yellow)]',
|
||||||
|
open: 'text-[var(--color-accent)]',
|
||||||
|
partially_filled: 'text-[var(--color-yellow)]',
|
||||||
|
filled: 'text-[var(--color-green)]',
|
||||||
|
cancelled: 'text-[var(--color-text-secondary)]',
|
||||||
|
rejected: 'text-[var(--color-red)]',
|
||||||
|
};
|
||||||
|
return colors[label] ?? 'text-[var(--color-text-secondary)]';
|
||||||
|
}
|
||||||
|
|
||||||
export function OrdersTable({ orders = [], loading }: Props) {
|
export function OrdersTable({ orders = [], loading }: Props) {
|
||||||
|
const activeCount = orders.filter(
|
||||||
|
(o) =>
|
||||||
|
o.status === OrderStatus.SUBMITTED ||
|
||||||
|
o.status === OrderStatus.PENDING ||
|
||||||
|
o.status === OrderStatus.PARTIALLY_FILLED,
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--color-border)]">
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--color-border)]">
|
||||||
<span className="text-sm font-medium">Orders</span>
|
<span className="text-sm font-medium">Orders</span>
|
||||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||||
{orders.filter((o) => o.status === 'open' || o.status === 'pending').length} active
|
{activeCount} active
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -46,7 +102,7 @@ export function OrdersTable({ orders = [], loading }: Props) {
|
|||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{orders.map((order) => (
|
{orders.map((order) => (
|
||||||
<OrderRow key={order.order_id} order={order} />
|
<OrderRow key={order.orderId} order={order} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -56,47 +112,46 @@ export function OrdersTable({ orders = [], loading }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OrderRow({ order }: { order: Order }) {
|
function OrderRow({ order }: { order: Order }) {
|
||||||
const cancelOrder = useApiDelete(`/trading/orders/${order.order_id}`);
|
const cancelOrder = useMutation({
|
||||||
const canCancel = order.status === 'open' || order.status === 'pending';
|
mutationFn: () => tradingClient.cancelOrder({ orderId: order.orderId }),
|
||||||
|
});
|
||||||
|
|
||||||
const statusColor = {
|
const canCancel =
|
||||||
pending: 'text-[var(--color-yellow)]',
|
order.status === OrderStatus.SUBMITTED ||
|
||||||
open: 'text-[var(--color-accent)]',
|
order.status === OrderStatus.PENDING ||
|
||||||
partially_filled: 'text-[var(--color-yellow)]',
|
order.status === OrderStatus.PARTIALLY_FILLED;
|
||||||
filled: 'text-[var(--color-green)]',
|
|
||||||
cancelled: 'text-[var(--color-text-secondary)]',
|
|
||||||
rejected: 'text-[var(--color-red)]',
|
|
||||||
}[order.status];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-primary)]">
|
<tr className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-primary)]">
|
||||||
<td className="px-3 py-2 font-mono">{order.order_id.slice(0, 8)}</td>
|
<td className="px-3 py-2 font-mono">{order.orderId.slice(0, 8)}</td>
|
||||||
<td className="px-3 py-2">{order.symbol}</td>
|
<td className="px-3 py-2">{order.symbol}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
order.side === 'buy'
|
order.side === OrderSide.BUY
|
||||||
? 'text-[var(--color-green)]'
|
? 'text-[var(--color-green)]'
|
||||||
: 'text-[var(--color-red)]'
|
: 'text-[var(--color-red)]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{order.side.toUpperCase()}
|
{sideLabel(order.side)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 capitalize">{order.order_type}</td>
|
<td className="px-3 py-2 capitalize">{orderTypeLabel(order.orderType)}</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
{order.filled_quantity}/{order.quantity}
|
{order.filledQuantity}/{order.quantity}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
{order.price ? order.price.toFixed(2) : 'MKT'}
|
{order.price ? order.price.toFixed(2) : 'MKT'}
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-3 py-2 capitalize ${statusColor}`}>{order.status}</td>
|
<td className={`px-3 py-2 capitalize ${statusColorFn(order.status)}`}>
|
||||||
|
{statusLabelFn(order.status)}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
{canCancel && (
|
{canCancel && (
|
||||||
<button
|
<button
|
||||||
onClick={() => cancelOrder.mutate()}
|
onClick={() => cancelOrder.mutate()}
|
||||||
disabled={cancelOrder.isPending}
|
disabled={cancelOrder.isPending}
|
||||||
aria-label={`Cancel order ${order.order_id.slice(0, 8)}`}
|
aria-label={`Cancel order ${order.orderId.slice(0, 8)}`}
|
||||||
className="px-2 py-0.5 text-xs rounded bg-[var(--color-red)]/20 text-[var(--color-red)] hover:bg-[var(--color-red)]/30 disabled:opacity-50"
|
className="px-2 py-0.5 text-xs rounded bg-[var(--color-red)]/20 text-[var(--color-red)] hover:bg-[var(--color-red)]/30 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{cancelOrder.isPending ? 'Cancelling...' : 'Cancel'}
|
{cancelOrder.isPending ? 'Cancelling...' : 'Cancel'}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Position } from '../../lib/types';
|
import type { Position } from '../../gen/trading_pb';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
positions?: Position[];
|
positions?: Position[];
|
||||||
@@ -22,8 +22,8 @@ export function PositionsTable({ positions = [], loading }: Props) {
|
|||||||
<th scope="col" className="text-left px-3 py-2 font-medium">Symbol</th>
|
<th scope="col" className="text-left px-3 py-2 font-medium">Symbol</th>
|
||||||
<th scope="col" className="text-left px-3 py-2 font-medium">Side</th>
|
<th scope="col" className="text-left px-3 py-2 font-medium">Side</th>
|
||||||
<th scope="col" className="text-right px-3 py-2 font-medium">Qty</th>
|
<th scope="col" className="text-right px-3 py-2 font-medium">Qty</th>
|
||||||
<th scope="col" className="text-right px-3 py-2 font-medium">Entry</th>
|
<th scope="col" className="text-right px-3 py-2 font-medium">Avg Price</th>
|
||||||
<th scope="col" className="text-right px-3 py-2 font-medium">Market</th>
|
<th scope="col" className="text-right px-3 py-2 font-medium">Mkt Value</th>
|
||||||
<th scope="col" className="text-right px-3 py-2 font-medium">P&L</th>
|
<th scope="col" className="text-right px-3 py-2 font-medium">P&L</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -42,38 +42,41 @@ export function PositionsTable({ positions = [], loading }: Props) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{positions.map((pos) => (
|
{positions.map((pos) => {
|
||||||
<tr
|
const isLong = pos.quantity >= 0;
|
||||||
key={`${pos.symbol}-${pos.side}`}
|
return (
|
||||||
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-primary)]"
|
<tr
|
||||||
>
|
key={`${pos.symbol}-${pos.quantity}`}
|
||||||
<td className="px-3 py-2 font-medium">{pos.symbol}</td>
|
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-primary)]"
|
||||||
<td className="px-3 py-2">
|
>
|
||||||
<span
|
<td className="px-3 py-2 font-medium">{pos.symbol}</td>
|
||||||
className={
|
<td className="px-3 py-2">
|
||||||
pos.side === 'long'
|
<span
|
||||||
|
className={
|
||||||
|
isLong
|
||||||
|
? 'text-[var(--color-green)]'
|
||||||
|
: 'text-[var(--color-red)]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLong ? 'LONG' : 'SHORT'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">{Math.abs(pos.quantity)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{pos.averagePrice.toFixed(2)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{pos.marketValue.toFixed(2)}</td>
|
||||||
|
<td
|
||||||
|
className={`px-3 py-2 text-right font-medium ${
|
||||||
|
pos.unrealizedPnl >= 0
|
||||||
? 'text-[var(--color-green)]'
|
? 'text-[var(--color-green)]'
|
||||||
: 'text-[var(--color-red)]'
|
: 'text-[var(--color-red)]'
|
||||||
}
|
}`}
|
||||||
>
|
>
|
||||||
{pos.side.toUpperCase()}
|
{pos.unrealizedPnl >= 0 ? '+' : ''}
|
||||||
</span>
|
{pos.unrealizedPnl.toFixed(2)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">{pos.quantity}</td>
|
</tr>
|
||||||
<td className="px-3 py-2 text-right">{pos.entry_price.toFixed(2)}</td>
|
);
|
||||||
<td className="px-3 py-2 text-right">{pos.current_price.toFixed(2)}</td>
|
})}
|
||||||
<td
|
|
||||||
className={`px-3 py-2 text-right font-medium ${
|
|
||||||
pos.unrealized_pnl >= 0
|
|
||||||
? 'text-[var(--color-green)]'
|
|
||||||
: 'text-[var(--color-red)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pos.unrealized_pnl >= 0 ? '+' : ''}
|
|
||||||
{pos.unrealized_pnl.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
139
web-dashboard/src/gen/auth_pb.ts
Normal file
139
web-dashboard/src/gen/auth_pb.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// @generated by protoc-gen-es v2.11.0 with parameter "target=ts"
|
||||||
|
// @generated from file auth.proto (package auth, syntax proto3)
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import type { Message } from "@bufbuild/protobuf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the file auth.proto.
|
||||||
|
*/
|
||||||
|
export const file_auth: GenFile = /*@__PURE__*/
|
||||||
|
fileDesc("CgphdXRoLnByb3RvEgRhdXRoIjIKDExvZ2luUmVxdWVzdBIQCgh1c2VybmFtZRgBIAEoCRIQCghwYXNzd29yZBgCIAEoCSJQCg1Mb2dpblJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfYXQYAyABKAMiLAoTUmVmcmVzaFRva2VuUmVxdWVzdBIVCg1yZWZyZXNoX3Rva2VuGAEgASgJIlcKFFJlZnJlc2hUb2tlblJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfYXQYAyABKAMyhgEKC0F1dGhTZXJ2aWNlEjAKBUxvZ2luEhIuYXV0aC5Mb2dpblJlcXVlc3QaEy5hdXRoLkxvZ2luUmVzcG9uc2USRQoMUmVmcmVzaFRva2VuEhkuYXV0aC5SZWZyZXNoVG9rZW5SZXF1ZXN0GhouYXV0aC5SZWZyZXNoVG9rZW5SZXNwb25zZWIGcHJvdG8z");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message auth.LoginRequest
|
||||||
|
*/
|
||||||
|
export type LoginRequest = Message<"auth.LoginRequest"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string username = 1;
|
||||||
|
*/
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string password = 2;
|
||||||
|
*/
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message auth.LoginRequest.
|
||||||
|
* Use `create(LoginRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const LoginRequestSchema: GenMessage<LoginRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_auth, 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message auth.LoginResponse
|
||||||
|
*/
|
||||||
|
export type LoginResponse = Message<"auth.LoginResponse"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string access_token = 1;
|
||||||
|
*/
|
||||||
|
accessToken: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string refresh_token = 2;
|
||||||
|
*/
|
||||||
|
refreshToken: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unix timestamp (seconds) when the access token expires.
|
||||||
|
*
|
||||||
|
* @generated from field: int64 expires_at = 3;
|
||||||
|
*/
|
||||||
|
expiresAt: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message auth.LoginResponse.
|
||||||
|
* Use `create(LoginResponseSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const LoginResponseSchema: GenMessage<LoginResponse> = /*@__PURE__*/
|
||||||
|
messageDesc(file_auth, 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message auth.RefreshTokenRequest
|
||||||
|
*/
|
||||||
|
export type RefreshTokenRequest = Message<"auth.RefreshTokenRequest"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string refresh_token = 1;
|
||||||
|
*/
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message auth.RefreshTokenRequest.
|
||||||
|
* Use `create(RefreshTokenRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const RefreshTokenRequestSchema: GenMessage<RefreshTokenRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_auth, 2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message auth.RefreshTokenResponse
|
||||||
|
*/
|
||||||
|
export type RefreshTokenResponse = Message<"auth.RefreshTokenResponse"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string access_token = 1;
|
||||||
|
*/
|
||||||
|
accessToken: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string refresh_token = 2;
|
||||||
|
*/
|
||||||
|
refreshToken: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 expires_at = 3;
|
||||||
|
*/
|
||||||
|
expiresAt: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message auth.RefreshTokenResponse.
|
||||||
|
* Use `create(RefreshTokenResponseSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const RefreshTokenResponseSchema: GenMessage<RefreshTokenResponse> = /*@__PURE__*/
|
||||||
|
messageDesc(file_auth, 3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication service — token issuance and refresh.
|
||||||
|
* This service is exposed WITHOUT the auth interceptor (pre-authentication).
|
||||||
|
*
|
||||||
|
* @generated from service auth.AuthService
|
||||||
|
*/
|
||||||
|
export const AuthService: GenService<{
|
||||||
|
/**
|
||||||
|
* Authenticate with username/password, receive JWT tokens.
|
||||||
|
*
|
||||||
|
* @generated from rpc auth.AuthService.Login
|
||||||
|
*/
|
||||||
|
login: {
|
||||||
|
methodKind: "unary";
|
||||||
|
input: typeof LoginRequestSchema;
|
||||||
|
output: typeof LoginResponseSchema;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Exchange a refresh token for a new access token.
|
||||||
|
*
|
||||||
|
* @generated from rpc auth.AuthService.RefreshToken
|
||||||
|
*/
|
||||||
|
refreshToken: {
|
||||||
|
methodKind: "unary";
|
||||||
|
input: typeof RefreshTokenRequestSchema;
|
||||||
|
output: typeof RefreshTokenResponseSchema;
|
||||||
|
},
|
||||||
|
}> = /*@__PURE__*/
|
||||||
|
serviceDesc(file_auth, 0);
|
||||||
|
|
||||||
948
web-dashboard/src/gen/broker_gateway_pb.ts
Normal file
948
web-dashboard/src/gen/broker_gateway_pb.ts
Normal file
File diff suppressed because one or more lines are too long
1432
web-dashboard/src/gen/config_pb.ts
Normal file
1432
web-dashboard/src/gen/config_pb.ts
Normal file
File diff suppressed because one or more lines are too long
697
web-dashboard/src/gen/data_acquisition_pb.ts
Normal file
697
web-dashboard/src/gen/data_acquisition_pb.ts
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
// @generated by protoc-gen-es v2.11.0 with parameter "target=ts"
|
||||||
|
// @generated from file data_acquisition.proto (package data_acquisition, syntax proto3)
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import type { Message } from "@bufbuild/protobuf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the file data_acquisition.proto.
|
||||||
|
*/
|
||||||
|
export const file_data_acquisition: GenFile = /*@__PURE__*/
|
||||||
|
fileDesc("ChZkYXRhX2FjcXVpc2l0aW9uLnByb3RvEhBkYXRhX2FjcXVpc2l0aW9uIjcKG1N0cmVhbURvd25sb2FkU3RhdHVzUmVxdWVzdBIYChBpbnRlcnZhbF9zZWNvbmRzGAEgASgNIogCChdTY2hlZHVsZURvd25sb2FkUmVxdWVzdBIPCgdkYXRhc2V0GAEgASgJEg8KB3N5bWJvbHMYAiADKAkSEgoKc3RhcnRfZGF0ZRgDIAEoCRIQCghlbmRfZGF0ZRgEIAEoCRIOCgZzY2hlbWEYBSABKAkSEwoLZGVzY3JpcHRpb24YBiABKAkSQQoEdGFncxgHIAMoCzIzLmRhdGFfYWNxdWlzaXRpb24uU2NoZWR1bGVEb3dubG9hZFJlcXVlc3QuVGFnc0VudHJ5EhAKCHByaW9yaXR5GAggASgNGisKCVRhZ3NFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIokBChhTY2hlZHVsZURvd25sb2FkUmVzcG9uc2USDgoGam9iX2lkGAEgASgJEjAKBnN0YXR1cxgCIAEoDjIgLmRhdGFfYWNxdWlzaXRpb24uRG93bmxvYWRTdGF0dXMSGgoSZXN0aW1hdGVkX2Nvc3RfdXNkGAMgASgBEg8KB21lc3NhZ2UYBCABKAkiKgoYR2V0RG93bmxvYWRTdGF0dXNSZXF1ZXN0Eg4KBmpvYl9pZBgBIAEoCSJWChlHZXREb3dubG9hZFN0YXR1c1Jlc3BvbnNlEjkKC2pvYl9kZXRhaWxzGAEgASgLMiQuZGF0YV9hY3F1aXNpdGlvbi5Eb3dubG9hZEpvYkRldGFpbHMiNwoVQ2FuY2VsRG93bmxvYWRSZXF1ZXN0Eg4KBmpvYl9pZBgBIAEoCRIOCgZyZWFzb24YAiABKAkiOgoWQ2FuY2VsRG93bmxvYWRSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg8KB21lc3NhZ2UYAiABKAkimQEKF0xpc3REb3dubG9hZEpvYnNSZXF1ZXN0EgwKBHBhZ2UYASABKA0SEQoJcGFnZV9zaXplGAIgASgNEjcKDXN0YXR1c19maWx0ZXIYAyABKA4yIC5kYXRhX2FjcXVpc2l0aW9uLkRvd25sb2FkU3RhdHVzEhIKCnN0YXJ0X3RpbWUYBCABKAMSEAoIZW5kX3RpbWUYBSABKAMihAEKGExpc3REb3dubG9hZEpvYnNSZXNwb25zZRIyCgRqb2JzGAEgAygLMiQuZGF0YV9hY3F1aXNpdGlvbi5Eb3dubG9hZEpvYlN1bW1hcnkSEwoLdG90YWxfY291bnQYAiABKA0SDAoEcGFnZRgDIAEoDRIRCglwYWdlX3NpemUYBCABKA0iFAoSSGVhbHRoQ2hlY2tSZXF1ZXN0IqwBChNIZWFsdGhDaGVja1Jlc3BvbnNlEg8KB2hlYWx0aHkYASABKAgSDwoHbWVzc2FnZRgCIAEoCRJDCgdkZXRhaWxzGAMgAygLMjIuZGF0YV9hY3F1aXNpdGlvbi5IZWFsdGhDaGVja1Jlc3BvbnNlLkRldGFpbHNFbnRyeRouCgxEZXRhaWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKzBQoSRG93bmxvYWRKb2JEZXRhaWxzEg4KBmpvYl9pZBgBIAEoCRIwCgZzdGF0dXMYAiABKA4yIC5kYXRhX2FjcXVpc2l0aW9uLkRvd25sb2FkU3RhdHVzEg8KB2RhdGFzZXQYAyABKAkSDwoHc3ltYm9scxgEIAMoCRISCgpzdGFydF9kYXRlGAUgASgJEhAKCGVuZF9kYXRlGAYgASgJEg4KBnNjaGVtYRgHIAEoCRITCgtkZXNjcmlwdGlvbhgIIAEoCRI8CgR0YWdzGAkgAygLMi4uZGF0YV9hY3F1aXNpdGlvbi5Eb3dubG9hZEpvYkRldGFpbHMuVGFnc0VudHJ5EhAKCHByaW9yaXR5GAogASgNEhsKE3Byb2dyZXNzX3BlcmNlbnRhZ2UYCyABKAISGAoQYnl0ZXNfZG93bmxvYWRlZBgMIAEoBBITCgt0b3RhbF9ieXRlcxgNIAEoBBIaChJlc3RpbWF0ZWRfY29zdF91c2QYDiABKAESFwoPYWN0dWFsX2Nvc3RfdXNkGA8gASgBEhUKDXJlY29yZHNfY291bnQYECABKAQSFwoPaW52YWxpZF9yZWNvcmRzGBEgASgEEhoKEmRhdGFfcXVhbGl0eV9zY29yZRgSIAEoARISCgpsb2NhbF9wYXRoGBMgASgJEhIKCm1pbmlvX3BhdGgYFCABKAkSEgoKY3JlYXRlZF9hdBgVIAEoAxISCgpzdGFydGVkX2F0GBYgASgDEhQKDGNvbXBsZXRlZF9hdBgXIAEoAxIVCg1lcnJvcl9tZXNzYWdlGBggASgJEhMKC3JldHJ5X2NvdW50GBkgASgNEhIKCmNyZWF0ZWRfYnkYGiABKAkaKwoJVGFnc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEi/gEKEkRvd25sb2FkSm9iU3VtbWFyeRIOCgZqb2JfaWQYASABKAkSMAoGc3RhdHVzGAIgASgOMiAuZGF0YV9hY3F1aXNpdGlvbi5Eb3dubG9hZFN0YXR1cxIPCgdkYXRhc2V0GAMgASgJEg8KB3N5bWJvbHMYBCADKAkSEgoKc3RhcnRfZGF0ZRgFIAEoCRIQCghlbmRfZGF0ZRgGIAEoCRIbChNwcm9ncmVzc19wZXJjZW50YWdlGAcgASgCEhcKD2FjdHVhbF9jb3N0X3VzZBgIIAEoARISCgpjcmVhdGVkX2F0GAkgASgDEhQKDGNvbXBsZXRlZF9hdBgKIAEoAyqUAQoORG93bmxvYWRTdGF0dXMSGwoXRE9XTkxPQURfU1RBVFVTX1VOS05PV04QABILCgdQRU5ESU5HEAESDwoLRE9XTkxPQURJTkcQAhIOCgpWQUxJREFUSU5HEAMSDQoJVVBMT0FESU5HEAQSDQoJQ09NUExFVEVEEAUSCgoGRkFJTEVEEAYSDQoJQ0FOQ0VMTEVEEAcykgUKFkRhdGFBY3F1aXNpdGlvblNlcnZpY2USaQoQU2NoZWR1bGVEb3dubG9hZBIpLmRhdGFfYWNxdWlzaXRpb24uU2NoZWR1bGVEb3dubG9hZFJlcXVlc3QaKi5kYXRhX2FjcXVpc2l0aW9uLlNjaGVkdWxlRG93bmxvYWRSZXNwb25zZRJsChFHZXREb3dubG9hZFN0YXR1cxIqLmRhdGFfYWNxdWlzaXRpb24uR2V0RG93bmxvYWRTdGF0dXNSZXF1ZXN0GisuZGF0YV9hY3F1aXNpdGlvbi5HZXREb3dubG9hZFN0YXR1c1Jlc3BvbnNlEmMKDkNhbmNlbERvd25sb2FkEicuZGF0YV9hY3F1aXNpdGlvbi5DYW5jZWxEb3dubG9hZFJlcXVlc3QaKC5kYXRhX2FjcXVpc2l0aW9uLkNhbmNlbERvd25sb2FkUmVzcG9uc2USaQoQTGlzdERvd25sb2FkSm9icxIpLmRhdGFfYWNxdWlzaXRpb24uTGlzdERvd25sb2FkSm9ic1JlcXVlc3QaKi5kYXRhX2FjcXVpc2l0aW9uLkxpc3REb3dubG9hZEpvYnNSZXNwb25zZRJaCgtIZWFsdGhDaGVjaxIkLmRhdGFfYWNxdWlzaXRpb24uSGVhbHRoQ2hlY2tSZXF1ZXN0GiUuZGF0YV9hY3F1aXNpdGlvbi5IZWFsdGhDaGVja1Jlc3BvbnNlEnMKFFN0cmVhbURvd25sb2FkU3RhdHVzEi0uZGF0YV9hY3F1aXNpdGlvbi5TdHJlYW1Eb3dubG9hZFN0YXR1c1JlcXVlc3QaKi5kYXRhX2FjcXVpc2l0aW9uLkxpc3REb3dubG9hZEpvYnNSZXNwb25zZTABYgZwcm90bzM");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.StreamDownloadStatusRequest
|
||||||
|
*/
|
||||||
|
export type StreamDownloadStatusRequest = Message<"data_acquisition.StreamDownloadStatusRequest"> & {
|
||||||
|
/**
|
||||||
|
* 0 = server default (5s)
|
||||||
|
*
|
||||||
|
* @generated from field: uint32 interval_seconds = 1;
|
||||||
|
*/
|
||||||
|
intervalSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.StreamDownloadStatusRequest.
|
||||||
|
* Use `create(StreamDownloadStatusRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const StreamDownloadStatusRequestSchema: GenMessage<StreamDownloadStatusRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to schedule a new data download
|
||||||
|
*
|
||||||
|
* @generated from message data_acquisition.ScheduleDownloadRequest
|
||||||
|
*/
|
||||||
|
export type ScheduleDownloadRequest = Message<"data_acquisition.ScheduleDownloadRequest"> & {
|
||||||
|
/**
|
||||||
|
* Databento dataset (e.g., "GLBX.MDP3" for CME futures)
|
||||||
|
*
|
||||||
|
* @generated from field: string dataset = 1;
|
||||||
|
*/
|
||||||
|
dataset: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of symbols to download (e.g., ["ES.FUT", "NQ.FUT"])
|
||||||
|
*
|
||||||
|
* @generated from field: repeated string symbols = 2;
|
||||||
|
*/
|
||||||
|
symbols: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start date in YYYY-MM-DD format
|
||||||
|
*
|
||||||
|
* @generated from field: string start_date = 3;
|
||||||
|
*/
|
||||||
|
startDate: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End date in YYYY-MM-DD format
|
||||||
|
*
|
||||||
|
* @generated from field: string end_date = 4;
|
||||||
|
*/
|
||||||
|
endDate: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema type (e.g., "ohlcv-1m", "mbp-10", "trades")
|
||||||
|
*
|
||||||
|
* @generated from field: string schema = 5;
|
||||||
|
*/
|
||||||
|
schema: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional description for this download
|
||||||
|
*
|
||||||
|
* @generated from field: string description = 6;
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional tags for categorization
|
||||||
|
*
|
||||||
|
* @generated from field: map<string, string> tags = 7;
|
||||||
|
*/
|
||||||
|
tags: { [key: string]: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority level (1=low, 5=high)
|
||||||
|
*
|
||||||
|
* @generated from field: uint32 priority = 8;
|
||||||
|
*/
|
||||||
|
priority: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.ScheduleDownloadRequest.
|
||||||
|
* Use `create(ScheduleDownloadRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ScheduleDownloadRequestSchema: GenMessage<ScheduleDownloadRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.ScheduleDownloadResponse
|
||||||
|
*/
|
||||||
|
export type ScheduleDownloadResponse = Message<"data_acquisition.ScheduleDownloadResponse"> & {
|
||||||
|
/**
|
||||||
|
* Unique job identifier
|
||||||
|
*
|
||||||
|
* @generated from field: string job_id = 1;
|
||||||
|
*/
|
||||||
|
jobId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial job status
|
||||||
|
*
|
||||||
|
* @generated from field: data_acquisition.DownloadStatus status = 2;
|
||||||
|
*/
|
||||||
|
status: DownloadStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimated cost in USD
|
||||||
|
*
|
||||||
|
* @generated from field: double estimated_cost_usd = 3;
|
||||||
|
*/
|
||||||
|
estimatedCostUsd: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable message
|
||||||
|
*
|
||||||
|
* @generated from field: string message = 4;
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.ScheduleDownloadResponse.
|
||||||
|
* Use `create(ScheduleDownloadResponseSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ScheduleDownloadResponseSchema: GenMessage<ScheduleDownloadResponse> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.GetDownloadStatusRequest
|
||||||
|
*/
|
||||||
|
export type GetDownloadStatusRequest = Message<"data_acquisition.GetDownloadStatusRequest"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string job_id = 1;
|
||||||
|
*/
|
||||||
|
jobId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.GetDownloadStatusRequest.
|
||||||
|
* Use `create(GetDownloadStatusRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const GetDownloadStatusRequestSchema: GenMessage<GetDownloadStatusRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.GetDownloadStatusResponse
|
||||||
|
*/
|
||||||
|
export type GetDownloadStatusResponse = Message<"data_acquisition.GetDownloadStatusResponse"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: data_acquisition.DownloadJobDetails job_details = 1;
|
||||||
|
*/
|
||||||
|
jobDetails?: DownloadJobDetails;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.GetDownloadStatusResponse.
|
||||||
|
* Use `create(GetDownloadStatusResponseSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const GetDownloadStatusResponseSchema: GenMessage<GetDownloadStatusResponse> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 4);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.CancelDownloadRequest
|
||||||
|
*/
|
||||||
|
export type CancelDownloadRequest = Message<"data_acquisition.CancelDownloadRequest"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string job_id = 1;
|
||||||
|
*/
|
||||||
|
jobId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional cancellation reason
|
||||||
|
*
|
||||||
|
* @generated from field: string reason = 2;
|
||||||
|
*/
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.CancelDownloadRequest.
|
||||||
|
* Use `create(CancelDownloadRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const CancelDownloadRequestSchema: GenMessage<CancelDownloadRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 5);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.CancelDownloadResponse
|
||||||
|
*/
|
||||||
|
export type CancelDownloadResponse = Message<"data_acquisition.CancelDownloadResponse"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: bool success = 1;
|
||||||
|
*/
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string message = 2;
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.CancelDownloadResponse.
|
||||||
|
* Use `create(CancelDownloadResponseSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const CancelDownloadResponseSchema: GenMessage<CancelDownloadResponse> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 6);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.ListDownloadJobsRequest
|
||||||
|
*/
|
||||||
|
export type ListDownloadJobsRequest = Message<"data_acquisition.ListDownloadJobsRequest"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 page = 1;
|
||||||
|
*/
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 page_size = 2;
|
||||||
|
*/
|
||||||
|
pageSize: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: data_acquisition.DownloadStatus status_filter = 3;
|
||||||
|
*/
|
||||||
|
statusFilter: DownloadStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unix timestamp
|
||||||
|
*
|
||||||
|
* @generated from field: int64 start_time = 4;
|
||||||
|
*/
|
||||||
|
startTime: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unix timestamp
|
||||||
|
*
|
||||||
|
* @generated from field: int64 end_time = 5;
|
||||||
|
*/
|
||||||
|
endTime: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.ListDownloadJobsRequest.
|
||||||
|
* Use `create(ListDownloadJobsRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ListDownloadJobsRequestSchema: GenMessage<ListDownloadJobsRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 7);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.ListDownloadJobsResponse
|
||||||
|
*/
|
||||||
|
export type ListDownloadJobsResponse = Message<"data_acquisition.ListDownloadJobsResponse"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: repeated data_acquisition.DownloadJobSummary jobs = 1;
|
||||||
|
*/
|
||||||
|
jobs: DownloadJobSummary[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 total_count = 2;
|
||||||
|
*/
|
||||||
|
totalCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 page = 3;
|
||||||
|
*/
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 page_size = 4;
|
||||||
|
*/
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.ListDownloadJobsResponse.
|
||||||
|
* Use `create(ListDownloadJobsResponseSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ListDownloadJobsResponseSchema: GenMessage<ListDownloadJobsResponse> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 8);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.HealthCheckRequest
|
||||||
|
*/
|
||||||
|
export type HealthCheckRequest = Message<"data_acquisition.HealthCheckRequest"> & {
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.HealthCheckRequest.
|
||||||
|
* Use `create(HealthCheckRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const HealthCheckRequestSchema: GenMessage<HealthCheckRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 9);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.HealthCheckResponse
|
||||||
|
*/
|
||||||
|
export type HealthCheckResponse = Message<"data_acquisition.HealthCheckResponse"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: bool healthy = 1;
|
||||||
|
*/
|
||||||
|
healthy: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string message = 2;
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: map<string, string> details = 3;
|
||||||
|
*/
|
||||||
|
details: { [key: string]: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.HealthCheckResponse.
|
||||||
|
* Use `create(HealthCheckResponseSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const HealthCheckResponseSchema: GenMessage<HealthCheckResponse> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 10);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.DownloadJobDetails
|
||||||
|
*/
|
||||||
|
export type DownloadJobDetails = Message<"data_acquisition.DownloadJobDetails"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string job_id = 1;
|
||||||
|
*/
|
||||||
|
jobId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: data_acquisition.DownloadStatus status = 2;
|
||||||
|
*/
|
||||||
|
status: DownloadStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string dataset = 3;
|
||||||
|
*/
|
||||||
|
dataset: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: repeated string symbols = 4;
|
||||||
|
*/
|
||||||
|
symbols: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string start_date = 5;
|
||||||
|
*/
|
||||||
|
startDate: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string end_date = 6;
|
||||||
|
*/
|
||||||
|
endDate: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string schema = 7;
|
||||||
|
*/
|
||||||
|
schema: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string description = 8;
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: map<string, string> tags = 9;
|
||||||
|
*/
|
||||||
|
tags: { [key: string]: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 priority = 10;
|
||||||
|
*/
|
||||||
|
priority: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress tracking
|
||||||
|
*
|
||||||
|
* 0.0 to 100.0
|
||||||
|
*
|
||||||
|
* @generated from field: float progress_percentage = 11;
|
||||||
|
*/
|
||||||
|
progressPercentage: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint64 bytes_downloaded = 12;
|
||||||
|
*/
|
||||||
|
bytesDownloaded: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint64 total_bytes = 13;
|
||||||
|
*/
|
||||||
|
totalBytes: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cost tracking
|
||||||
|
*
|
||||||
|
* @generated from field: double estimated_cost_usd = 14;
|
||||||
|
*/
|
||||||
|
estimatedCostUsd: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: double actual_cost_usd = 15;
|
||||||
|
*/
|
||||||
|
actualCostUsd: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quality metrics
|
||||||
|
*
|
||||||
|
* @generated from field: uint64 records_count = 16;
|
||||||
|
*/
|
||||||
|
recordsCount: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint64 invalid_records = 17;
|
||||||
|
*/
|
||||||
|
invalidRecords: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0.0 to 1.0
|
||||||
|
*
|
||||||
|
* @generated from field: double data_quality_score = 18;
|
||||||
|
*/
|
||||||
|
dataQualityScore: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage paths
|
||||||
|
*
|
||||||
|
* @generated from field: string local_path = 19;
|
||||||
|
*/
|
||||||
|
localPath: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string minio_path = 20;
|
||||||
|
*/
|
||||||
|
minioPath: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamps
|
||||||
|
*
|
||||||
|
* Unix timestamp
|
||||||
|
*
|
||||||
|
* @generated from field: int64 created_at = 21;
|
||||||
|
*/
|
||||||
|
createdAt: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 started_at = 22;
|
||||||
|
*/
|
||||||
|
startedAt: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 completed_at = 23;
|
||||||
|
*/
|
||||||
|
completedAt: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error information
|
||||||
|
*
|
||||||
|
* @generated from field: string error_message = 24;
|
||||||
|
*/
|
||||||
|
errorMessage: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 retry_count = 25;
|
||||||
|
*/
|
||||||
|
retryCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata
|
||||||
|
*
|
||||||
|
* @generated from field: string created_by = 26;
|
||||||
|
*/
|
||||||
|
createdBy: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.DownloadJobDetails.
|
||||||
|
* Use `create(DownloadJobDetailsSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const DownloadJobDetailsSchema: GenMessage<DownloadJobDetails> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 11);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message data_acquisition.DownloadJobSummary
|
||||||
|
*/
|
||||||
|
export type DownloadJobSummary = Message<"data_acquisition.DownloadJobSummary"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string job_id = 1;
|
||||||
|
*/
|
||||||
|
jobId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: data_acquisition.DownloadStatus status = 2;
|
||||||
|
*/
|
||||||
|
status: DownloadStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string dataset = 3;
|
||||||
|
*/
|
||||||
|
dataset: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: repeated string symbols = 4;
|
||||||
|
*/
|
||||||
|
symbols: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string start_date = 5;
|
||||||
|
*/
|
||||||
|
startDate: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string end_date = 6;
|
||||||
|
*/
|
||||||
|
endDate: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: float progress_percentage = 7;
|
||||||
|
*/
|
||||||
|
progressPercentage: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: double actual_cost_usd = 8;
|
||||||
|
*/
|
||||||
|
actualCostUsd: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 created_at = 9;
|
||||||
|
*/
|
||||||
|
createdAt: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 completed_at = 10;
|
||||||
|
*/
|
||||||
|
completedAt: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message data_acquisition.DownloadJobSummary.
|
||||||
|
* Use `create(DownloadJobSummarySchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const DownloadJobSummarySchema: GenMessage<DownloadJobSummary> = /*@__PURE__*/
|
||||||
|
messageDesc(file_data_acquisition, 12);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum data_acquisition.DownloadStatus
|
||||||
|
*/
|
||||||
|
export enum DownloadStatus {
|
||||||
|
/**
|
||||||
|
* @generated from enum value: DOWNLOAD_STATUS_UNKNOWN = 0;
|
||||||
|
*/
|
||||||
|
DOWNLOAD_STATUS_UNKNOWN = 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queued, waiting to start
|
||||||
|
*
|
||||||
|
* @generated from enum value: PENDING = 1;
|
||||||
|
*/
|
||||||
|
PENDING = 1,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actively downloading from Databento
|
||||||
|
*
|
||||||
|
* @generated from enum value: DOWNLOADING = 2;
|
||||||
|
*/
|
||||||
|
DOWNLOADING = 2,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validating data quality
|
||||||
|
*
|
||||||
|
* @generated from enum value: VALIDATING = 3;
|
||||||
|
*/
|
||||||
|
VALIDATING = 3,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploading to MinIO
|
||||||
|
*
|
||||||
|
* @generated from enum value: UPLOADING = 4;
|
||||||
|
*/
|
||||||
|
UPLOADING = 4,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successfully completed
|
||||||
|
*
|
||||||
|
* @generated from enum value: COMPLETED = 5;
|
||||||
|
*/
|
||||||
|
COMPLETED = 5,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failed with errors
|
||||||
|
*
|
||||||
|
* @generated from enum value: FAILED = 6;
|
||||||
|
*/
|
||||||
|
FAILED = 6,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelled by user
|
||||||
|
*
|
||||||
|
* @generated from enum value: CANCELLED = 7;
|
||||||
|
*/
|
||||||
|
CANCELLED = 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the enum data_acquisition.DownloadStatus.
|
||||||
|
*/
|
||||||
|
export const DownloadStatusSchema: GenEnum<DownloadStatus> = /*@__PURE__*/
|
||||||
|
enumDesc(file_data_acquisition, 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Acquisition Service provides automated Databento data downloading and storage.
|
||||||
|
* This service handles scheduled downloads, cost tracking, quality validation, and automatic MinIO upload.
|
||||||
|
*
|
||||||
|
* @generated from service data_acquisition.DataAcquisitionService
|
||||||
|
*/
|
||||||
|
export const DataAcquisitionService: GenService<{
|
||||||
|
/**
|
||||||
|
* Download Management
|
||||||
|
* Schedule a new data download from Databento
|
||||||
|
*
|
||||||
|
* @generated from rpc data_acquisition.DataAcquisitionService.ScheduleDownload
|
||||||
|
*/
|
||||||
|
scheduleDownload: {
|
||||||
|
methodKind: "unary";
|
||||||
|
input: typeof ScheduleDownloadRequestSchema;
|
||||||
|
output: typeof ScheduleDownloadResponseSchema;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get status of a download job
|
||||||
|
*
|
||||||
|
* @generated from rpc data_acquisition.DataAcquisitionService.GetDownloadStatus
|
||||||
|
*/
|
||||||
|
getDownloadStatus: {
|
||||||
|
methodKind: "unary";
|
||||||
|
input: typeof GetDownloadStatusRequestSchema;
|
||||||
|
output: typeof GetDownloadStatusResponseSchema;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Cancel a running or pending download job
|
||||||
|
*
|
||||||
|
* @generated from rpc data_acquisition.DataAcquisitionService.CancelDownload
|
||||||
|
*/
|
||||||
|
cancelDownload: {
|
||||||
|
methodKind: "unary";
|
||||||
|
input: typeof CancelDownloadRequestSchema;
|
||||||
|
output: typeof CancelDownloadResponseSchema;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* List all download jobs with optional filters
|
||||||
|
*
|
||||||
|
* @generated from rpc data_acquisition.DataAcquisitionService.ListDownloadJobs
|
||||||
|
*/
|
||||||
|
listDownloadJobs: {
|
||||||
|
methodKind: "unary";
|
||||||
|
input: typeof ListDownloadJobsRequestSchema;
|
||||||
|
output: typeof ListDownloadJobsResponseSchema;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Service Health and Status
|
||||||
|
* Check service health and resource availability
|
||||||
|
*
|
||||||
|
* @generated from rpc data_acquisition.DataAcquisitionService.HealthCheck
|
||||||
|
*/
|
||||||
|
healthCheck: {
|
||||||
|
methodKind: "unary";
|
||||||
|
input: typeof HealthCheckRequestSchema;
|
||||||
|
output: typeof HealthCheckResponseSchema;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Server-streaming: polls ListDownloadJobs at gateway level
|
||||||
|
*
|
||||||
|
* @generated from rpc data_acquisition.DataAcquisitionService.StreamDownloadStatus
|
||||||
|
*/
|
||||||
|
streamDownloadStatus: {
|
||||||
|
methodKind: "server_streaming";
|
||||||
|
input: typeof StreamDownloadStatusRequestSchema;
|
||||||
|
output: typeof ListDownloadJobsResponseSchema;
|
||||||
|
},
|
||||||
|
}> = /*@__PURE__*/
|
||||||
|
serviceDesc(file_data_acquisition, 0);
|
||||||
|
|
||||||
3900
web-dashboard/src/gen/fxt_trading_pb.ts
Normal file
3900
web-dashboard/src/gen/fxt_trading_pb.ts
Normal file
File diff suppressed because one or more lines are too long
120
web-dashboard/src/gen/health_pb.ts
Normal file
120
web-dashboard/src/gen/health_pb.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// @generated by protoc-gen-es v2.11.0 with parameter "target=ts"
|
||||||
|
// @generated from file health.proto (package grpc.health.v1, syntax proto3)
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import type { Message } from "@bufbuild/protobuf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the file health.proto.
|
||||||
|
*/
|
||||||
|
export const file_health: GenFile = /*@__PURE__*/
|
||||||
|
fileDesc("CgxoZWFsdGgucHJvdG8SDmdycGMuaGVhbHRoLnYxIiUKEkhlYWx0aENoZWNrUmVxdWVzdBIPCgdzZXJ2aWNlGAEgASgJIkQKE0hlYWx0aENoZWNrUmVzcG9uc2USLQoGc3RhdHVzGAEgASgOMh0uZ3JwYy5oZWFsdGgudjEuU2VydmluZ1N0YXR1cypPCg1TZXJ2aW5nU3RhdHVzEgsKB1VOS05PV04QABILCgdTRVJWSU5HEAESDwoLTk9UX1NFUlZJTkcQAhITCg9TRVJWSUNFX1VOS05PV04QAzKuAQoGSGVhbHRoElAKBUNoZWNrEiIuZ3JwYy5oZWFsdGgudjEuSGVhbHRoQ2hlY2tSZXF1ZXN0GiMuZ3JwYy5oZWFsdGgudjEuSGVhbHRoQ2hlY2tSZXNwb25zZRJSCgVXYXRjaBIiLmdycGMuaGVhbHRoLnYxLkhlYWx0aENoZWNrUmVxdWVzdBojLmdycGMuaGVhbHRoLnYxLkhlYWx0aENoZWNrUmVzcG9uc2UwAWIGcHJvdG8z");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check request
|
||||||
|
*
|
||||||
|
* @generated from message grpc.health.v1.HealthCheckRequest
|
||||||
|
*/
|
||||||
|
export type HealthCheckRequest = Message<"grpc.health.v1.HealthCheckRequest"> & {
|
||||||
|
/**
|
||||||
|
* Service name to check (empty for overall service health)
|
||||||
|
*
|
||||||
|
* @generated from field: string service = 1;
|
||||||
|
*/
|
||||||
|
service: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message grpc.health.v1.HealthCheckRequest.
|
||||||
|
* Use `create(HealthCheckRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const HealthCheckRequestSchema: GenMessage<HealthCheckRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_health, 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check response
|
||||||
|
*
|
||||||
|
* @generated from message grpc.health.v1.HealthCheckResponse
|
||||||
|
*/
|
||||||
|
export type HealthCheckResponse = Message<"grpc.health.v1.HealthCheckResponse"> & {
|
||||||
|
/**
|
||||||
|
* Health status
|
||||||
|
*
|
||||||
|
* @generated from field: grpc.health.v1.ServingStatus status = 1;
|
||||||
|
*/
|
||||||
|
status: ServingStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message grpc.health.v1.HealthCheckResponse.
|
||||||
|
* Use `create(HealthCheckResponseSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const HealthCheckResponseSchema: GenMessage<HealthCheckResponse> = /*@__PURE__*/
|
||||||
|
messageDesc(file_health, 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serving status enum
|
||||||
|
*
|
||||||
|
* @generated from enum grpc.health.v1.ServingStatus
|
||||||
|
*/
|
||||||
|
export enum ServingStatus {
|
||||||
|
/**
|
||||||
|
* @generated from enum value: UNKNOWN = 0;
|
||||||
|
*/
|
||||||
|
UNKNOWN = 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: SERVING = 1;
|
||||||
|
*/
|
||||||
|
SERVING = 1,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: NOT_SERVING = 2;
|
||||||
|
*/
|
||||||
|
NOT_SERVING = 2,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used when the requested service is unknown
|
||||||
|
*
|
||||||
|
* @generated from enum value: SERVICE_UNKNOWN = 3;
|
||||||
|
*/
|
||||||
|
SERVICE_UNKNOWN = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the enum grpc.health.v1.ServingStatus.
|
||||||
|
*/
|
||||||
|
export const ServingStatusSchema: GenEnum<ServingStatus> = /*@__PURE__*/
|
||||||
|
enumDesc(file_health, 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check service definition
|
||||||
|
*
|
||||||
|
* @generated from service grpc.health.v1.Health
|
||||||
|
*/
|
||||||
|
export const Health: GenService<{
|
||||||
|
/**
|
||||||
|
* Check the health status of the service
|
||||||
|
*
|
||||||
|
* @generated from rpc grpc.health.v1.Health.Check
|
||||||
|
*/
|
||||||
|
check: {
|
||||||
|
methodKind: "unary";
|
||||||
|
input: typeof HealthCheckRequestSchema;
|
||||||
|
output: typeof HealthCheckResponseSchema;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Watch for health status changes
|
||||||
|
*
|
||||||
|
* @generated from rpc grpc.health.v1.Health.Watch
|
||||||
|
*/
|
||||||
|
watch: {
|
||||||
|
methodKind: "server_streaming";
|
||||||
|
input: typeof HealthCheckRequestSchema;
|
||||||
|
output: typeof HealthCheckResponseSchema;
|
||||||
|
},
|
||||||
|
}> = /*@__PURE__*/
|
||||||
|
serviceDesc(file_health, 0);
|
||||||
|
|
||||||
1597
web-dashboard/src/gen/ml_pb.ts
Normal file
1597
web-dashboard/src/gen/ml_pb.ts
Normal file
File diff suppressed because one or more lines are too long
2929
web-dashboard/src/gen/ml_training_pb.ts
Normal file
2929
web-dashboard/src/gen/ml_training_pb.ts
Normal file
File diff suppressed because one or more lines are too long
2342
web-dashboard/src/gen/monitoring_pb.ts
Normal file
2342
web-dashboard/src/gen/monitoring_pb.ts
Normal file
File diff suppressed because one or more lines are too long
1435
web-dashboard/src/gen/risk_pb.ts
Normal file
1435
web-dashboard/src/gen/risk_pb.ts
Normal file
File diff suppressed because one or more lines are too long
2922
web-dashboard/src/gen/trading_agent_pb.ts
Normal file
2922
web-dashboard/src/gen/trading_agent_pb.ts
Normal file
File diff suppressed because one or more lines are too long
2375
web-dashboard/src/gen/trading_pb.ts
Normal file
2375
web-dashboard/src/gen/trading_pb.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -1,36 +1,179 @@
|
|||||||
import { useQuery, useMutation, type UseQueryOptions } from '@tanstack/react-query';
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from '../lib/api';
|
import {
|
||||||
|
tradingClient,
|
||||||
|
riskClient,
|
||||||
|
mlClient,
|
||||||
|
mlTrainingClient,
|
||||||
|
configClient,
|
||||||
|
monitoringClient,
|
||||||
|
backtestingClient,
|
||||||
|
} from "../lib/grpc";
|
||||||
|
|
||||||
/** Typed GET query hook */
|
// ---------------------------------------------------------------------------
|
||||||
export function useApiQuery<T>(
|
// Trading
|
||||||
key: string[],
|
// ---------------------------------------------------------------------------
|
||||||
path: string,
|
|
||||||
options?: Omit<UseQueryOptions<T>, 'queryKey' | 'queryFn'>,
|
export function usePositions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["positions"],
|
||||||
|
queryFn: () => tradingClient.getPositions({}),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrders() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["orders"],
|
||||||
|
queryFn: () => tradingClient.getOrderStatus({}),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrderBook(symbol: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["order-book", symbol],
|
||||||
|
queryFn: () => tradingClient.getOrderBook({ symbol }),
|
||||||
|
refetchInterval: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmitOrder() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (order: {
|
||||||
|
symbol: string;
|
||||||
|
side: string;
|
||||||
|
orderType: string;
|
||||||
|
quantity: number;
|
||||||
|
price?: number;
|
||||||
|
}) => tradingClient.submitOrder(order),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMlPredictions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ml-predictions"],
|
||||||
|
queryFn: () => tradingClient.getMLPredictions({}),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Risk
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useRiskMetrics() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["risk-metrics"],
|
||||||
|
queryFn: () => riskClient.getRiskMetrics({}),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEmergencyStop() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: { reason?: string }) =>
|
||||||
|
riskClient.emergencyStop(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ML
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useMlPrediction() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ml-prediction"],
|
||||||
|
queryFn: () => mlClient.getPrediction({}),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Training
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useTrainingJobs() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["training-jobs"],
|
||||||
|
queryFn: () => mlTrainingClient.listTrainingJobs({}),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Performance / Monitoring
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function usePerformanceMetrics() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["performance-metrics"],
|
||||||
|
queryFn: () => monitoringClient.getMetrics({}),
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSystemStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["system-status"],
|
||||||
|
queryFn: () => monitoringClient.getSystemStatus({}),
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useConfig() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["config"],
|
||||||
|
queryFn: () => configClient.getConfiguration({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateConfig() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
category?: string;
|
||||||
|
}) => configClient.updateConfiguration(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backtesting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useStartBacktest() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: {
|
||||||
|
strategy: string;
|
||||||
|
symbol: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}) => backtestingClient.startBacktest(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBacktestStatus(backtestId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["backtest-status", backtestId],
|
||||||
|
queryFn: () =>
|
||||||
|
backtestingClient.getBacktestStatus({ backtestId: backtestId! }),
|
||||||
|
enabled: backtestId !== null,
|
||||||
|
refetchInterval: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBacktestResults(
|
||||||
|
backtestId: string | null,
|
||||||
|
enabled: boolean,
|
||||||
) {
|
) {
|
||||||
return useQuery<T>({
|
return useQuery({
|
||||||
queryKey: key,
|
queryKey: ["backtest-results", backtestId],
|
||||||
queryFn: () => apiGet<T>(path),
|
queryFn: () =>
|
||||||
...options,
|
backtestingClient.getBacktestResults({ backtestId: backtestId! }),
|
||||||
});
|
enabled,
|
||||||
}
|
|
||||||
|
|
||||||
/** Typed POST mutation hook */
|
|
||||||
export function useApiPost<TResponse, TBody = unknown>(path: string) {
|
|
||||||
return useMutation<TResponse, Error, TBody>({
|
|
||||||
mutationFn: (body) => apiPost<TResponse>(path, body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Typed PUT mutation hook */
|
|
||||||
export function useApiPut<TResponse, TBody = unknown>(path: string) {
|
|
||||||
return useMutation<TResponse, Error, TBody>({
|
|
||||||
mutationFn: (body) => apiPut<TResponse>(path, body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Typed DELETE mutation hook */
|
|
||||||
export function useApiDelete<TResponse>(path: string) {
|
|
||||||
return useMutation<TResponse, Error, void>({
|
|
||||||
mutationFn: () => apiDelete<TResponse>(path),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import { useCallback, useSyncExternalStore } from 'react';
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
import { clearTokens, getToken, isAuthenticated, setTokens } from '../lib/auth';
|
import {
|
||||||
import { wsManager } from '../lib/websocket';
|
clearTokens,
|
||||||
|
getToken,
|
||||||
|
isAuthenticated,
|
||||||
|
setTokens,
|
||||||
|
} from "../lib/auth";
|
||||||
|
|
||||||
/** Simple external store for auth state reactivity */
|
/** Simple external store for auth state reactivity */
|
||||||
let authListeners: Set<() => void> = new Set();
|
const authListeners: Set<() => void> = new Set();
|
||||||
|
|
||||||
function notifyAuthChange() {
|
function notifyAuthChange() {
|
||||||
authListeners.forEach((l) => l());
|
authListeners.forEach((l) => l());
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribeAuth(listener: () => void) {
|
function subscribeAuth(listener: () => void) {
|
||||||
authListeners.add(listener);
|
authListeners.add(listener);
|
||||||
return () => authListeners.delete(listener);
|
return () => {
|
||||||
|
authListeners.delete(listener);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** React hook for authentication state and actions */
|
/** React hook for authentication state and actions */
|
||||||
@@ -18,15 +25,16 @@ export function useAuth() {
|
|||||||
const authenticated = useSyncExternalStore(subscribeAuth, isAuthenticated);
|
const authenticated = useSyncExternalStore(subscribeAuth, isAuthenticated);
|
||||||
const token = useSyncExternalStore(subscribeAuth, getToken);
|
const token = useSyncExternalStore(subscribeAuth, getToken);
|
||||||
|
|
||||||
const login = useCallback((accessToken: string, refreshToken?: string) => {
|
const login = useCallback(
|
||||||
setTokens(accessToken, refreshToken);
|
(accessToken: string, refreshToken?: string) => {
|
||||||
wsManager.connect();
|
setTokens(accessToken, refreshToken);
|
||||||
notifyAuthChange();
|
notifyAuthChange();
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
wsManager.disconnect();
|
|
||||||
notifyAuthChange();
|
notifyAuthChange();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
109
web-dashboard/src/hooks/useGrpcStream.ts
Normal file
109
web-dashboard/src/hooks/useGrpcStream.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
tradingClient,
|
||||||
|
riskClient,
|
||||||
|
monitoringClient,
|
||||||
|
mlClient,
|
||||||
|
mlTrainingClient,
|
||||||
|
configClient,
|
||||||
|
} from "../lib/grpc";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generic server-streaming hook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Hook that consumes a server-streaming gRPC RPC and accumulates messages. */
|
||||||
|
export function useServerStream<T>(
|
||||||
|
streamFn: (signal: AbortSignal) => AsyncIterable<T>,
|
||||||
|
deps: unknown[] = [],
|
||||||
|
) {
|
||||||
|
const [lastMessage, setLastMessage] = useState<T | null>(null);
|
||||||
|
const [messages, setMessages] = useState<T[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
setConnected(true);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
for await (const msg of streamFn(controller.signal)) {
|
||||||
|
if (!mountedRef.current) break;
|
||||||
|
setLastMessage(msg);
|
||||||
|
setMessages((prev) => [...prev.slice(-99), msg]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stream ended, aborted, or encountered an error
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setConnected(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return { lastMessage, messages, connected };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Typed stream hooks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useMarketDataStream() {
|
||||||
|
return useServerStream((signal) =>
|
||||||
|
tradingClient.streamMarketData({}, { signal }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrderUpdatesStream() {
|
||||||
|
return useServerStream((signal) =>
|
||||||
|
tradingClient.streamOrders({}, { signal }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePositionUpdatesStream() {
|
||||||
|
return useServerStream((signal) =>
|
||||||
|
tradingClient.streamPositions({}, { signal }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRiskAlertsStream() {
|
||||||
|
return useServerStream((signal) =>
|
||||||
|
riskClient.streamRiskAlerts({}, { signal }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRiskMetricsStream() {
|
||||||
|
return useServerStream((signal) =>
|
||||||
|
riskClient.streamRiskMetrics({}, { signal }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMetricsStream() {
|
||||||
|
return useServerStream((signal) =>
|
||||||
|
monitoringClient.streamMetrics({}, { signal }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMlPredictionStream() {
|
||||||
|
return useServerStream((signal) =>
|
||||||
|
mlClient.streamPredictions({}, { signal }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrainingStatusStream() {
|
||||||
|
return useServerStream((signal) =>
|
||||||
|
mlTrainingClient.subscribeToTrainingStatus({}, { signal }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfigChangesStream() {
|
||||||
|
return useServerStream((signal) =>
|
||||||
|
configClient.streamConfigChanges({}, { signal }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import type { ServerMessage, ServerMessageType } from '../lib/types';
|
|
||||||
import { wsManager } from '../lib/websocket';
|
|
||||||
|
|
||||||
/** React hook for WebSocket topic subscriptions and real-time messages */
|
|
||||||
export function useWebSocket(topics: string[]) {
|
|
||||||
const [messages, setMessages] = useState<ServerMessage[]>([]);
|
|
||||||
const [connected, setConnected] = useState(wsManager.connected);
|
|
||||||
const topicsRef = useRef(topics);
|
|
||||||
topicsRef.current = topics;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Subscribe to topics
|
|
||||||
if (topics.length > 0) {
|
|
||||||
wsManager.subscribe(topics);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for messages
|
|
||||||
const unsubscribe = wsManager.onMessage((msg) => {
|
|
||||||
if (topicsRef.current.includes(msg.type) || topicsRef.current.includes('*')) {
|
|
||||||
setMessages((prev) => [...prev.slice(-99), msg]); // Keep last 100
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track connection status
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setConnected(wsManager.connected);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
clearInterval(interval);
|
|
||||||
if (topics.length > 0) {
|
|
||||||
wsManager.unsubscribe(topics);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [topics.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
return { messages, connected, lastMessage: messages[messages.length - 1] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hook to get messages of a specific type */
|
|
||||||
export function useWebSocketTopic<T extends ServerMessage>(
|
|
||||||
topic: ServerMessageType,
|
|
||||||
) {
|
|
||||||
const { messages, connected } = useWebSocket([topic]);
|
|
||||||
const typed = messages.filter((m) => m.type === topic) as T[];
|
|
||||||
return { messages: typed, connected, lastMessage: typed[typed.length - 1] };
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { clearTokens, isTokenExpired, SESSION_EXPIRED_KEY } from './auth';
|
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
|
||||||
|
|
||||||
/** Margin in seconds before actual expiry to treat the token as expired */
|
|
||||||
const EXPIRY_MARGIN_SECONDS = 60;
|
|
||||||
|
|
||||||
/** Maximum time in ms to wait for an API response before aborting */
|
|
||||||
const REQUEST_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirect to login with a session-expired flag so the LoginPage can
|
|
||||||
* display a user-friendly message instead of a silent redirect.
|
|
||||||
*/
|
|
||||||
function redirectToLogin(): void {
|
|
||||||
sessionStorage.setItem(SESSION_EXPIRED_KEY, '1');
|
|
||||||
clearTokens();
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch wrapper that injects JWT Authorization header and handles 401 auto-logout */
|
|
||||||
async function apiFetch<T>(
|
|
||||||
path: string,
|
|
||||||
options: RequestInit = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const token = localStorage.getItem('foxhunt_token');
|
|
||||||
|
|
||||||
// Pre-flight: detect expired (or about-to-expire) tokens before making the request
|
|
||||||
if (token && isTokenExpired(token, EXPIRY_MARGIN_SECONDS)) {
|
|
||||||
redirectToLogin();
|
|
||||||
throw new Error('Session expired. Please log in again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(options.headers as Record<string, string> ?? {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
||||||
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(`${API_BASE}${path}`, {
|
|
||||||
...options,
|
|
||||||
headers,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
||||||
throw new Error('Request timed out. Please try again.');
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
// Server rejected the token — redirect gracefully
|
|
||||||
if (res.status === 401) {
|
|
||||||
redirectToLogin();
|
|
||||||
throw new Error('Session expired. Please log in again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
||||||
throw new Error(body.error ?? `API error: ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET request */
|
|
||||||
export function apiGet<T>(path: string): Promise<T> {
|
|
||||||
return apiFetch<T>(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST request */
|
|
||||||
export function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
|
||||||
return apiFetch<T>(path, {
|
|
||||||
method: 'POST',
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PUT request */
|
|
||||||
export function apiPut<T>(path: string, body?: unknown): Promise<T> {
|
|
||||||
return apiFetch<T>(path, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** DELETE request */
|
|
||||||
export function apiDelete<T>(path: string): Promise<T> {
|
|
||||||
return apiFetch<T>(path, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
80
web-dashboard/src/lib/grpc.ts
Normal file
80
web-dashboard/src/lib/grpc.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { createGrpcWebTransport } from "@connectrpc/connect-web";
|
||||||
|
import { createClient, type Interceptor } from "@connectrpc/connect";
|
||||||
|
import {
|
||||||
|
getToken,
|
||||||
|
isTokenExpired,
|
||||||
|
clearTokens,
|
||||||
|
SESSION_EXPIRED_KEY,
|
||||||
|
} from "./auth";
|
||||||
|
|
||||||
|
// Generated service definitions (protoc-gen-es v2 — services live in *_pb.ts)
|
||||||
|
import { TradingService } from "../gen/trading_pb";
|
||||||
|
import { RiskService } from "../gen/risk_pb";
|
||||||
|
import { MLService } from "../gen/ml_pb";
|
||||||
|
import { MLTrainingService } from "../gen/ml_training_pb";
|
||||||
|
import { ConfigService } from "../gen/config_pb";
|
||||||
|
import { MonitoringService } from "../gen/monitoring_pb";
|
||||||
|
import { BacktestingService } from "../gen/fxt_trading_pb";
|
||||||
|
import { AuthService } from "../gen/auth_pb";
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:50051";
|
||||||
|
|
||||||
|
/** Margin in seconds before actual expiry to treat the token as expired */
|
||||||
|
const EXPIRY_MARGIN_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interceptor that attaches the JWT Authorization header to every request
|
||||||
|
* and handles session expiry / UNAUTHENTICATED responses.
|
||||||
|
*/
|
||||||
|
const authInterceptor: Interceptor = (next) => async (req) => {
|
||||||
|
const token = getToken();
|
||||||
|
if (token) {
|
||||||
|
if (isTokenExpired(token, EXPIRY_MARGIN_SECONDS)) {
|
||||||
|
sessionStorage.setItem(SESSION_EXPIRED_KEY, "1");
|
||||||
|
clearTokens();
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new Error("Session expired");
|
||||||
|
}
|
||||||
|
req.header.set("authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await next(req);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// gRPC status 16 = UNAUTHENTICATED
|
||||||
|
if (
|
||||||
|
err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
"code" in err &&
|
||||||
|
(err as { code: number }).code === 16
|
||||||
|
) {
|
||||||
|
sessionStorage.setItem(SESSION_EXPIRED_KEY, "1");
|
||||||
|
clearTokens();
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Authenticated transport — used for all services except login */
|
||||||
|
const transport = createGrpcWebTransport({
|
||||||
|
baseUrl: API_URL,
|
||||||
|
interceptors: [authInterceptor],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Public transport — no auth header, used for login/refresh */
|
||||||
|
const publicTransport = createGrpcWebTransport({
|
||||||
|
baseUrl: API_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service clients
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const authClient = createClient(AuthService, publicTransport);
|
||||||
|
export const tradingClient = createClient(TradingService, transport);
|
||||||
|
export const riskClient = createClient(RiskService, transport);
|
||||||
|
export const mlClient = createClient(MLService, transport);
|
||||||
|
export const mlTrainingClient = createClient(MLTrainingService, transport);
|
||||||
|
export const configClient = createClient(ConfigService, transport);
|
||||||
|
export const monitoringClient = createClient(MonitoringService, transport);
|
||||||
|
export const backtestingClient = createClient(BacktestingService, transport);
|
||||||
@@ -1,37 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* UI adapter types.
|
||||||
|
*
|
||||||
|
* These are kept as lightweight interfaces for components that render data
|
||||||
|
* without depending directly on the generated proto message types.
|
||||||
|
* Pages and hooks now work with the proto-generated types from src/gen/*_pb.ts
|
||||||
|
* but child components may still accept these plain interfaces for flexibility.
|
||||||
|
*/
|
||||||
|
|
||||||
/** Position in a trading account */
|
/** Position in a trading account */
|
||||||
export interface Position {
|
export interface Position {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
entry_price: number;
|
averagePrice: number;
|
||||||
current_price: number;
|
marketValue: number;
|
||||||
unrealized_pnl: number;
|
unrealizedPnl: number;
|
||||||
side: 'long' | 'short';
|
side: 'long' | 'short';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Order submitted to the trading engine */
|
/** Order submitted to the trading engine */
|
||||||
export interface Order {
|
export interface Order {
|
||||||
order_id: string;
|
orderId: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
side: 'buy' | 'sell';
|
side: 'buy' | 'sell';
|
||||||
order_type: 'market' | 'limit' | 'stop';
|
orderType: 'market' | 'limit' | 'stop';
|
||||||
quantity: number;
|
quantity: number;
|
||||||
price?: number;
|
price?: number;
|
||||||
status: 'pending' | 'open' | 'partially_filled' | 'filled' | 'cancelled' | 'rejected';
|
status: 'pending' | 'open' | 'partially_filled' | 'filled' | 'cancelled' | 'rejected';
|
||||||
filled_quantity: number;
|
filledQuantity: number;
|
||||||
created_at: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Risk metrics snapshot */
|
/** ML model prediction (UI-facing) */
|
||||||
export interface RiskMetrics {
|
|
||||||
portfolio_var: number;
|
|
||||||
position_utilization: number;
|
|
||||||
max_drawdown: number;
|
|
||||||
current_drawdown: number;
|
|
||||||
sharpe_ratio: number;
|
|
||||||
sortino_ratio: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ML model prediction */
|
|
||||||
export interface MlPrediction {
|
export interface MlPrediction {
|
||||||
model: string;
|
model: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@@ -41,42 +40,25 @@ export interface MlPrediction {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Training job status */
|
/** Training job status (UI-facing) */
|
||||||
export interface TrainingJob {
|
export interface TrainingJob {
|
||||||
job_id: string;
|
jobId: string;
|
||||||
model_type: string;
|
modelType: string;
|
||||||
status: 'queued' | 'running' | 'completed' | 'failed' | 'stopped';
|
status: 'queued' | 'running' | 'completed' | 'failed' | 'stopped';
|
||||||
progress: number;
|
progress: number;
|
||||||
epoch: number;
|
epoch: number;
|
||||||
total_epochs: number;
|
totalEpochs: number;
|
||||||
loss: number;
|
loss: number;
|
||||||
started_at: string;
|
startedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Performance metrics */
|
/** Performance metrics (UI-facing) */
|
||||||
export interface PerformanceMetrics {
|
export interface PerformanceMetrics {
|
||||||
total_pnl: number;
|
totalPnl: number;
|
||||||
daily_pnl: number;
|
dailyPnl: number;
|
||||||
win_rate: number;
|
winRate: number;
|
||||||
total_trades: number;
|
totalTrades: number;
|
||||||
sharpe_ratio: number;
|
sharpeRatio: number;
|
||||||
sortino_ratio: number;
|
sortinoRatio: number;
|
||||||
max_drawdown: number;
|
maxDrawdown: number;
|
||||||
}
|
|
||||||
|
|
||||||
/** WebSocket message types (server -> client) */
|
|
||||||
export type ServerMessageType =
|
|
||||||
| 'market_data'
|
|
||||||
| 'order_update'
|
|
||||||
| 'risk_alert'
|
|
||||||
| 'position_update'
|
|
||||||
| 'ml_prediction'
|
|
||||||
| 'training_progress'
|
|
||||||
| 'metrics'
|
|
||||||
| 'config_update';
|
|
||||||
|
|
||||||
/** Generic server message envelope */
|
|
||||||
export interface ServerMessage {
|
|
||||||
type: ServerMessageType;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import type { ServerMessage } from './types';
|
|
||||||
|
|
||||||
type MessageHandler = (msg: ServerMessage) => void;
|
|
||||||
|
|
||||||
/** WebSocket connection manager with auto-reconnect and topic subscriptions */
|
|
||||||
export class WsManager {
|
|
||||||
private ws: WebSocket | null = null;
|
|
||||||
private url: string;
|
|
||||||
private handlers: Set<MessageHandler> = new Set();
|
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private backoff = 1000;
|
|
||||||
private maxBackoff = 30000;
|
|
||||||
private maxAttempts = 50;
|
|
||||||
private attempts = 0;
|
|
||||||
private subscribedTopics: Set<string> = new Set();
|
|
||||||
private _connected = false;
|
|
||||||
|
|
||||||
constructor(url?: string) {
|
|
||||||
const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined;
|
|
||||||
if (url) {
|
|
||||||
this.url = url;
|
|
||||||
} else if (envWsUrl) {
|
|
||||||
this.url = envWsUrl;
|
|
||||||
} else {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
this.url = `${protocol}//${window.location.host}/api/ws`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get connected(): boolean {
|
|
||||||
return this._connected;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Connect to the WebSocket server */
|
|
||||||
connect(): void {
|
|
||||||
const token = localStorage.getItem('foxhunt_token');
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const wsUrl = `${this.url}?token=${encodeURIComponent(token)}`;
|
|
||||||
this.ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this._connected = true;
|
|
||||||
this.backoff = 1000;
|
|
||||||
this.attempts = 0;
|
|
||||||
// Re-subscribe to previously subscribed topics
|
|
||||||
if (this.subscribedTopics.size > 0) {
|
|
||||||
this.send({ type: 'subscribe', topics: Array.from(this.subscribedTopics) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data) as ServerMessage;
|
|
||||||
this.handlers.forEach((h) => h(msg));
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed messages
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
|
||||||
this._connected = false;
|
|
||||||
this.scheduleReconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
this.ws?.close();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Disconnect and stop reconnecting */
|
|
||||||
disconnect(): void {
|
|
||||||
if (this.reconnectTimer) {
|
|
||||||
clearTimeout(this.reconnectTimer);
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
}
|
|
||||||
this.ws?.close();
|
|
||||||
this.ws = null;
|
|
||||||
this._connected = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Manually trigger reconnection (resets backoff) */
|
|
||||||
reconnect(): void {
|
|
||||||
this.disconnect();
|
|
||||||
this.attempts = 0;
|
|
||||||
this.backoff = 1000;
|
|
||||||
this.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
get reconnectAttempts(): number {
|
|
||||||
return this.attempts;
|
|
||||||
}
|
|
||||||
|
|
||||||
get maxReconnectAttempts(): number {
|
|
||||||
return this.maxAttempts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subscribe to data topics */
|
|
||||||
subscribe(topics: string[]): void {
|
|
||||||
topics.forEach((t) => this.subscribedTopics.add(t));
|
|
||||||
if (this._connected) {
|
|
||||||
this.send({ type: 'subscribe', topics });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unsubscribe from data topics */
|
|
||||||
unsubscribe(topics: string[]): void {
|
|
||||||
topics.forEach((t) => this.subscribedTopics.delete(t));
|
|
||||||
if (this._connected) {
|
|
||||||
this.send({ type: 'unsubscribe', topics });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Register a message handler */
|
|
||||||
onMessage(handler: MessageHandler): () => void {
|
|
||||||
this.handlers.add(handler);
|
|
||||||
return () => this.handlers.delete(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
private send(msg: unknown): void {
|
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.send(JSON.stringify(msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
|
||||||
if (this.reconnectTimer) return;
|
|
||||||
this.attempts += 1;
|
|
||||||
if (this.attempts > this.maxAttempts) {
|
|
||||||
// Stop reconnecting after too many failures
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const delay = this.backoff;
|
|
||||||
this.backoff = Math.min(this.backoff * 2, this.maxBackoff);
|
|
||||||
this.reconnectTimer = setTimeout(() => {
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
this.connect();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Singleton WebSocket manager instance */
|
|
||||||
export const wsManager = new WsManager();
|
|
||||||
@@ -1,36 +1,8 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { PnLChart } from '../components/charts/PnLChart';
|
import { PnLChart } from '../components/charts/PnLChart';
|
||||||
import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary';
|
import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary';
|
||||||
import { useApiPost, useApiQuery } from '../hooks/useApi';
|
import { useStartBacktest, useBacktestStatus, useBacktestResults } from '../hooks/useApi';
|
||||||
|
import { BacktestStatus } from '../gen/fxt_trading_pb';
|
||||||
interface BacktestParams {
|
|
||||||
strategy: string;
|
|
||||||
symbol: string;
|
|
||||||
start_date: string;
|
|
||||||
end_date: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BacktestStatus {
|
|
||||||
backtest_id: string;
|
|
||||||
status: 'running' | 'completed' | 'failed';
|
|
||||||
progress: number;
|
|
||||||
strategy: string;
|
|
||||||
symbol: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BacktestResult {
|
|
||||||
trades: BacktestTrade[];
|
|
||||||
equity_curve: { date: string; pnl: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BacktestTrade {
|
|
||||||
time: string;
|
|
||||||
symbol: string;
|
|
||||||
side: string;
|
|
||||||
entry_price: number;
|
|
||||||
exit_price: number;
|
|
||||||
pnl: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BacktestValidationErrors {
|
interface BacktestValidationErrors {
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
@@ -46,26 +18,12 @@ export function BacktestingDashboard() {
|
|||||||
|
|
||||||
const [activeBacktestId, setActiveBacktestId] = useState<string | null>(null);
|
const [activeBacktestId, setActiveBacktestId] = useState<string | null>(null);
|
||||||
|
|
||||||
const startBacktest = useApiPost<{ backtest_id: string }, BacktestParams>(
|
const startBacktest = useStartBacktest();
|
||||||
'/backtest/start',
|
|
||||||
);
|
|
||||||
|
|
||||||
const backtestStatus = useApiQuery<BacktestStatus>(
|
const backtestStatusQuery = useBacktestStatus(activeBacktestId);
|
||||||
['backtest-status', activeBacktestId ?? ''],
|
|
||||||
`/backtest/${activeBacktestId}/status`,
|
|
||||||
{
|
|
||||||
enabled: activeBacktestId !== null,
|
|
||||||
refetchInterval: 2000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const backtestResults = useApiQuery<BacktestResult>(
|
const isCompleted = backtestStatusQuery.data?.status === BacktestStatus.COMPLETED;
|
||||||
['backtest-results', activeBacktestId ?? ''],
|
const backtestResults = useBacktestResults(activeBacktestId, isCompleted);
|
||||||
`/backtest/${activeBacktestId}/results`,
|
|
||||||
{
|
|
||||||
enabled: backtestStatus.data?.status === 'completed',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasValidationErrors = Object.keys(validationErrors).length > 0;
|
const hasValidationErrors = Object.keys(validationErrors).length > 0;
|
||||||
|
|
||||||
@@ -96,11 +54,40 @@ export function BacktestingDashboard() {
|
|||||||
if (Object.keys(errors).length > 0) return;
|
if (Object.keys(errors).length > 0) return;
|
||||||
|
|
||||||
startBacktest.mutate(
|
startBacktest.mutate(
|
||||||
{ strategy, symbol, start_date: startDate, end_date: endDate },
|
{ strategy, symbol, startDate, endDate },
|
||||||
{ onSuccess: (data) => setActiveBacktestId(data.backtest_id) },
|
{ onSuccess: (data) => setActiveBacktestId(data.backtestId) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map BacktestStatus enum to display strings
|
||||||
|
function statusLabel(status: BacktestStatus | undefined): string {
|
||||||
|
switch (status) {
|
||||||
|
case BacktestStatus.RUNNING: return 'running';
|
||||||
|
case BacktestStatus.COMPLETED: return 'completed';
|
||||||
|
case BacktestStatus.FAILED: return 'failed';
|
||||||
|
case BacktestStatus.QUEUED: return 'queued';
|
||||||
|
case BacktestStatus.CANCELLED: return 'cancelled';
|
||||||
|
default: return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColorClass(status: BacktestStatus | undefined): string {
|
||||||
|
switch (status) {
|
||||||
|
case BacktestStatus.COMPLETED: return 'bg-green-500/20 text-[var(--color-green)]';
|
||||||
|
case BacktestStatus.FAILED: return 'bg-red-500/20 text-[var(--color-red)]';
|
||||||
|
default: return 'bg-blue-500/20 text-[var(--color-accent)]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapt equity curve from proto to PnLChart shape
|
||||||
|
const equityCurveData = backtestResults.data?.equityCurve?.map((p) => ({
|
||||||
|
date: new Date(Number(p.timestampUnixNanos) / 1_000_000).toISOString().slice(0, 10),
|
||||||
|
pnl: p.equity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Adapt trades from proto to display shape
|
||||||
|
const trades = backtestResults.data?.trades ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -201,7 +188,7 @@ export function BacktestingDashboard() {
|
|||||||
</button>
|
</button>
|
||||||
{startBacktest.isSuccess && (
|
{startBacktest.isSuccess && (
|
||||||
<p className="text-xs text-[var(--color-green)]">
|
<p className="text-xs text-[var(--color-green)]">
|
||||||
Backtest started: {startBacktest.data.backtest_id}
|
Backtest started: {startBacktest.data.backtestId}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{startBacktest.isError && (
|
{startBacktest.isError && (
|
||||||
@@ -217,28 +204,24 @@ export function BacktestingDashboard() {
|
|||||||
<div className="px-3 py-1.5 border-b border-[var(--color-border)]">
|
<div className="px-3 py-1.5 border-b border-[var(--color-border)]">
|
||||||
<span className="text-sm font-medium">Active Backtests</span>
|
<span className="text-sm font-medium">Active Backtests</span>
|
||||||
</div>
|
</div>
|
||||||
{backtestStatus.data ? (
|
{backtestStatusQuery.data ? (
|
||||||
<div className="p-3 space-y-2">
|
<div className="p-3 space-y-2">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="font-mono">{backtestStatus.data.backtest_id.slice(0, 8)}</span>
|
<span className="font-mono">{backtestStatusQuery.data.backtestId.slice(0, 8)}</span>
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
<span className={`text-xs px-1.5 py-0.5 rounded ${statusColorClass(backtestStatusQuery.data.status)}`}>
|
||||||
backtestStatus.data.status === 'completed' ? 'bg-green-500/20 text-[var(--color-green)]' :
|
{statusLabel(backtestStatusQuery.data.status)}
|
||||||
backtestStatus.data.status === 'failed' ? 'bg-red-500/20 text-[var(--color-red)]' :
|
|
||||||
'bg-blue-500/20 text-[var(--color-accent)]'
|
|
||||||
}`}>
|
|
||||||
{backtestStatus.data.status}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--color-text-secondary)]">
|
<div className="text-xs text-[var(--color-text-secondary)]">
|
||||||
{backtestStatus.data.strategy} / {backtestStatus.data.symbol}
|
{strategy} / {symbol}
|
||||||
</div>
|
</div>
|
||||||
{backtestStatus.data.status === 'running' && (
|
{backtestStatusQuery.data.status === BacktestStatus.RUNNING && (
|
||||||
<div className="w-full bg-[var(--color-bg-primary)] rounded-full h-1.5">
|
<div className="w-full bg-[var(--color-bg-primary)] rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
className="bg-[var(--color-accent)] h-1.5 rounded-full transition-all"
|
className="bg-[var(--color-accent)] h-1.5 rounded-full transition-all"
|
||||||
style={{ width: `${(backtestStatus.data.progress * 100).toFixed(0)}%` }}
|
style={{ width: `${backtestStatusQuery.data.progressPercentage.toFixed(0)}%` }}
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-valuenow={Math.round(backtestStatus.data.progress * 100)}
|
aria-valuenow={Math.round(backtestStatusQuery.data.progressPercentage)}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={100}
|
aria-valuemax={100}
|
||||||
/>
|
/>
|
||||||
@@ -256,7 +239,7 @@ export function BacktestingDashboard() {
|
|||||||
{/* Equity Curve */}
|
{/* Equity Curve */}
|
||||||
<div className="h-64 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="h-64 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<ComponentErrorBoundary name="Equity Curve">
|
<ComponentErrorBoundary name="Equity Curve">
|
||||||
<PnLChart title="Equity Curve" data={backtestResults.data?.equity_curve} />
|
<PnLChart title="Equity Curve" data={equityCurveData} />
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -278,21 +261,26 @@ export function BacktestingDashboard() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{backtestResults.data?.trades && backtestResults.data.trades.length > 0 ? (
|
{trades.length > 0 ? (
|
||||||
backtestResults.data.trades.map((trade, i) => (
|
trades.map((trade, i) => {
|
||||||
<tr key={i} className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-primary)]">
|
const sideStr = trade.side === 1 ? 'BUY' : trade.side === 2 ? 'SELL' : 'UNKNOWN';
|
||||||
<td className="px-3 py-1.5">{trade.time}</td>
|
const isBuy = trade.side === 1;
|
||||||
<td className="px-3 py-1.5">{trade.symbol}</td>
|
const entryTime = new Date(Number(trade.entryTimeUnixNanos) / 1_000_000).toISOString();
|
||||||
<td className={`px-3 py-1.5 ${trade.side === 'buy' ? 'text-[var(--color-green)]' : 'text-[var(--color-red)]'}`}>
|
return (
|
||||||
{trade.side.toUpperCase()}
|
<tr key={i} className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-primary)]">
|
||||||
</td>
|
<td className="px-3 py-1.5">{entryTime.slice(0, 19).replace('T', ' ')}</td>
|
||||||
<td className="px-3 py-1.5 text-right">{trade.entry_price.toFixed(2)}</td>
|
<td className="px-3 py-1.5">{trade.symbol}</td>
|
||||||
<td className="px-3 py-1.5 text-right">{trade.exit_price.toFixed(2)}</td>
|
<td className={`px-3 py-1.5 ${isBuy ? 'text-[var(--color-green)]' : 'text-[var(--color-red)]'}`}>
|
||||||
<td className={`px-3 py-1.5 text-right ${trade.pnl >= 0 ? 'text-[var(--color-green)]' : 'text-[var(--color-red)]'}`}>
|
{sideStr}
|
||||||
${trade.pnl.toFixed(2)}
|
</td>
|
||||||
</td>
|
<td className="px-3 py-1.5 text-right">{trade.entryPrice.toFixed(2)}</td>
|
||||||
</tr>
|
<td className="px-3 py-1.5 text-right">{trade.exitPrice.toFixed(2)}</td>
|
||||||
))
|
<td className={`px-3 py-1.5 text-right ${trade.pnl >= 0 ? 'text-[var(--color-green)]' : 'text-[var(--color-red)]'}`}>
|
||||||
|
${trade.pnl.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="text-center py-8 text-[var(--color-text-secondary)]">
|
<td colSpan={6} className="text-center py-8 text-[var(--color-text-secondary)]">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary';
|
import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary';
|
||||||
import { useApiQuery, useApiPut } from '../hooks/useApi';
|
import { useConfig, useUpdateConfig } from '../hooks/useApi';
|
||||||
|
|
||||||
type ConfigCategory = 'trading' | 'risk' | 'ml' | 'system';
|
type ConfigCategory = 'trading' | 'risk' | 'ml' | 'system';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigFieldDef {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -13,7 +13,7 @@ interface ConfigEntry {
|
|||||||
export function ConfigDashboard() {
|
export function ConfigDashboard() {
|
||||||
const [activeTab, setActiveTab] = useState<ConfigCategory>('trading');
|
const [activeTab, setActiveTab] = useState<ConfigCategory>('trading');
|
||||||
|
|
||||||
const config = useApiQuery<Record<string, unknown>>(['config'], '/config');
|
const config = useConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -46,7 +46,7 @@ export function ConfigDashboard() {
|
|||||||
{/* Config form */}
|
{/* Config form */}
|
||||||
<ComponentErrorBoundary name="Configuration Form">
|
<ComponentErrorBoundary name="Configuration Form">
|
||||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<ConfigSection category={activeTab} config={config.data} />
|
<ConfigSection category={activeTab} settings={config.data?.settings} />
|
||||||
</div>
|
</div>
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
|
|
||||||
@@ -65,12 +65,12 @@ export function ConfigDashboard() {
|
|||||||
|
|
||||||
function ConfigSection({
|
function ConfigSection({
|
||||||
category,
|
category,
|
||||||
config,
|
settings,
|
||||||
}: {
|
}: {
|
||||||
category: ConfigCategory;
|
category: ConfigCategory;
|
||||||
config?: Record<string, unknown>;
|
settings?: { key: string; value: string; category: string }[];
|
||||||
}) {
|
}) {
|
||||||
const defaults: Record<ConfigCategory, ConfigEntry[]> = {
|
const defaults: Record<ConfigCategory, ConfigFieldDef[]> = {
|
||||||
trading: [
|
trading: [
|
||||||
{ key: 'max_position_size', value: '1000', description: 'Maximum position size per symbol' },
|
{ key: 'max_position_size', value: '1000', description: 'Maximum position size per symbol' },
|
||||||
{ key: 'max_order_value', value: '100000', description: 'Maximum order value in USD' },
|
{ key: 'max_order_value', value: '100000', description: 'Maximum order value in USD' },
|
||||||
@@ -92,21 +92,23 @@ function ConfigSection({
|
|||||||
system: [
|
system: [
|
||||||
{ key: 'log_level', value: 'info', description: 'Logging level (trace/debug/info/warn/error)' },
|
{ key: 'log_level', value: 'info', description: 'Logging level (trace/debug/info/warn/error)' },
|
||||||
{ key: 'grpc_timeout_ms', value: '10000', description: 'gRPC client timeout' },
|
{ key: 'grpc_timeout_ms', value: '10000', description: 'gRPC client timeout' },
|
||||||
{ key: 'ws_heartbeat_interval', value: '30', description: 'WebSocket heartbeat interval (s)' },
|
{ key: 'stream_heartbeat_interval', value: '30', description: 'Stream heartbeat interval (s)' },
|
||||||
{ key: 'metrics_push_interval', value: '5', description: 'Metrics push interval (s)' },
|
{ key: 'metrics_push_interval', value: '5', description: 'Metrics push interval (s)' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Merge API values over defaults
|
// Merge server values over defaults
|
||||||
const fields = defaults[category].map((field) => {
|
const fields = defaults[category].map((field) => {
|
||||||
const serverValue = config?.[field.key];
|
const serverSetting = settings?.find(
|
||||||
|
(s) => s.key === field.key && s.category === category,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
value: serverValue !== undefined ? String(serverValue) : field.value,
|
value: serverSetting?.value ?? field.value,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateConfig = useApiPut<unknown, Record<string, string>>('/config');
|
const updateConfig = useUpdateConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="divide-y divide-[var(--color-border)]">
|
<div className="divide-y divide-[var(--color-border)]">
|
||||||
@@ -125,7 +127,11 @@ function ConfigSection({
|
|||||||
className="w-32 px-2 py-1 text-sm text-right rounded border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-accent)]"
|
className="w-32 px-2 py-1 text-sm text-right rounded border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-accent)]"
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
if (e.target.value !== field.value) {
|
if (e.target.value !== field.value) {
|
||||||
updateConfig.mutate({ [field.key]: e.target.value });
|
updateConfig.mutate({
|
||||||
|
key: field.key,
|
||||||
|
value: e.target.value,
|
||||||
|
category,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { apiPost } from '../lib/api';
|
import { authClient } from '../lib/grpc';
|
||||||
import { SESSION_EXPIRED_KEY } from '../lib/auth';
|
import { SESSION_EXPIRED_KEY } from '../lib/auth';
|
||||||
|
|
||||||
interface LoginResponse {
|
/** Read and clear the session-expired flag set by the gRPC interceptor on UNAUTHENTICATED / token expiry */
|
||||||
token: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read and clear the session-expired flag set by the API client on 401 / token expiry */
|
|
||||||
function consumeSessionExpiredFlag(): boolean {
|
function consumeSessionExpiredFlag(): boolean {
|
||||||
const expired = sessionStorage.getItem(SESSION_EXPIRED_KEY) === '1';
|
const expired = sessionStorage.getItem(SESSION_EXPIRED_KEY) === '1';
|
||||||
if (expired) sessionStorage.removeItem(SESSION_EXPIRED_KEY);
|
if (expired) sessionStorage.removeItem(SESSION_EXPIRED_KEY);
|
||||||
@@ -36,11 +31,8 @@ export function LoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiPost<LoginResponse>('/auth/login', {
|
const response = await authClient.login({ username, password });
|
||||||
username,
|
login(response.accessToken, response.refreshToken);
|
||||||
password,
|
|
||||||
});
|
|
||||||
login(response.token, response.refresh_token);
|
|
||||||
navigate('/trading', { replace: true });
|
navigate('/trading', { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Login failed');
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
|
|||||||
@@ -2,16 +2,8 @@ import { ComponentErrorBoundary } from '../components/common/ComponentErrorBound
|
|||||||
import { ModelCard } from '../components/ml/ModelCard';
|
import { ModelCard } from '../components/ml/ModelCard';
|
||||||
import { EnsemblePanel } from '../components/ml/EnsemblePanel';
|
import { EnsemblePanel } from '../components/ml/EnsemblePanel';
|
||||||
import { TrainingProgress } from '../components/ml/TrainingProgress';
|
import { TrainingProgress } from '../components/ml/TrainingProgress';
|
||||||
import { useApiQuery } from '../hooks/useApi';
|
import { useMlPredictions, useTrainingJobs } from '../hooks/useApi';
|
||||||
import { useWebSocketTopic } from '../hooks/useWebSocket';
|
import { useMetricsStream } from '../hooks/useGrpcStream';
|
||||||
import type { MlPrediction, TrainingJob } from '../lib/types';
|
|
||||||
|
|
||||||
interface RegimeData {
|
|
||||||
regime?: string;
|
|
||||||
volatility?: string;
|
|
||||||
confidence?: number;
|
|
||||||
duration?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODEL_GROUPS = [
|
const MODEL_GROUPS = [
|
||||||
{ label: 'Reinforcement Learning', models: ['DQN', 'PPO'] },
|
{ label: 'Reinforcement Learning', models: ['DQN', 'PPO'] },
|
||||||
@@ -21,26 +13,27 @@ const MODEL_GROUPS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function MLDashboard() {
|
export function MLDashboard() {
|
||||||
const predictions = useApiQuery<MlPrediction[]>(
|
const predictions = useMlPredictions();
|
||||||
['ml-predictions'],
|
const trainingJobs = useTrainingJobs();
|
||||||
'/ml/predictions',
|
const { messages: metricsMessages } = useMetricsStream();
|
||||||
{ refetchInterval: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const trainingJobs = useApiQuery<TrainingJob[]>(
|
// Extract regime data from most recent metrics stream message
|
||||||
['training-jobs'],
|
|
||||||
'/training/jobs',
|
|
||||||
{ refetchInterval: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { messages: metricsMessages } = useWebSocketTopic('metrics');
|
|
||||||
|
|
||||||
// Extract regime data from most recent metrics message
|
|
||||||
const latestMetrics = metricsMessages.length > 0
|
const latestMetrics = metricsMessages.length > 0
|
||||||
? (metricsMessages.at(-1) as { data?: RegimeData })?.data
|
? metricsMessages.at(-1)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const preds = predictions.data ?? [];
|
// The gRPC response wraps predictions in a `predictions` field
|
||||||
|
const preds = predictions.data?.predictions ?? [];
|
||||||
|
|
||||||
|
// Adapt proto MLPrediction to the shape the ModelCard / EnsemblePanel expects
|
||||||
|
const adaptedPreds = preds.map((p) => ({
|
||||||
|
model: p.ensembleAction, // model name not in proto; use ensembleAction for signal
|
||||||
|
symbol: p.symbol,
|
||||||
|
signal: p.ensembleAction?.toLowerCase() as 'buy' | 'sell' | 'hold' | undefined,
|
||||||
|
confidence: p.ensembleConfidence,
|
||||||
|
predicted_return: p.ensembleSignal,
|
||||||
|
timestamp: '',
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -61,7 +54,7 @@ export function MLDashboard() {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4">
|
||||||
{group.models.map((model) => {
|
{group.models.map((model) => {
|
||||||
const pred = preds.find((p) => p.model === model);
|
const pred = adaptedPreds.find((p) => p.model === model);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={model}
|
key={model}
|
||||||
@@ -86,7 +79,7 @@ export function MLDashboard() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<ComponentErrorBoundary name="Ensemble Panel">
|
<ComponentErrorBoundary name="Ensemble Panel">
|
||||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<EnsemblePanel predictions={preds} />
|
<EnsemblePanel predictions={adaptedPreds} />
|
||||||
</div>
|
</div>
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
@@ -96,19 +89,19 @@ export function MLDashboard() {
|
|||||||
<div className="p-3 space-y-2">
|
<div className="p-3 space-y-2">
|
||||||
<div className="flex justify-between text-xs">
|
<div className="flex justify-between text-xs">
|
||||||
<span className="text-[var(--color-text-secondary)]">Current Regime</span>
|
<span className="text-[var(--color-text-secondary)]">Current Regime</span>
|
||||||
<span className="text-[var(--color-accent)]">{latestMetrics?.regime ?? '--'}</span>
|
<span className="text-[var(--color-accent)]">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs">
|
<div className="flex justify-between text-xs">
|
||||||
<span className="text-[var(--color-text-secondary)]">Volatility</span>
|
<span className="text-[var(--color-text-secondary)]">Volatility</span>
|
||||||
<span>{latestMetrics?.volatility ?? '--'}</span>
|
<span>--</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs">
|
<div className="flex justify-between text-xs">
|
||||||
<span className="text-[var(--color-text-secondary)]">Regime Confidence</span>
|
<span className="text-[var(--color-text-secondary)]">Regime Confidence</span>
|
||||||
<span>{latestMetrics?.confidence !== undefined ? `${(latestMetrics.confidence * 100).toFixed(1)}%` : '--'}</span>
|
<span>--</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs">
|
<div className="flex justify-between text-xs">
|
||||||
<span className="text-[var(--color-text-secondary)]">Regime Duration</span>
|
<span className="text-[var(--color-text-secondary)]">Regime Duration</span>
|
||||||
<span>{latestMetrics?.duration ?? '--'}</span>
|
<span>{latestMetrics ? 'streaming' : '--'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +111,7 @@ export function MLDashboard() {
|
|||||||
<ComponentErrorBoundary name="Training Progress">
|
<ComponentErrorBoundary name="Training Progress">
|
||||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<TrainingProgress
|
<TrainingProgress
|
||||||
jobs={trainingJobs.data}
|
jobs={trainingJobs.data?.jobs}
|
||||||
loading={trainingJobs.isLoading}
|
loading={trainingJobs.isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,69 +1,63 @@
|
|||||||
import { PnLChart } from '../components/charts/PnLChart';
|
import { PnLChart } from '../components/charts/PnLChart';
|
||||||
import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary';
|
import { ComponentErrorBoundary } from '../components/common/ComponentErrorBoundary';
|
||||||
import { MetricCard } from '../components/charts/MetricCard';
|
import { MetricCard } from '../components/charts/MetricCard';
|
||||||
import { useApiQuery } from '../hooks/useApi';
|
import { usePerformanceMetrics } from '../hooks/useApi';
|
||||||
import type { PerformanceMetrics } from '../lib/types';
|
|
||||||
|
|
||||||
interface PnLPoint {
|
|
||||||
date: string;
|
|
||||||
pnl: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PerformanceDashboard() {
|
export function PerformanceDashboard() {
|
||||||
const metrics = useApiQuery<PerformanceMetrics>(
|
const metricsQuery = usePerformanceMetrics();
|
||||||
['performance-metrics'],
|
|
||||||
'/performance/metrics',
|
|
||||||
{ refetchInterval: 10000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const pnlData = useApiQuery<PnLPoint[]>(
|
// The gRPC response is GetMetricsResponse with a `metrics` array of Metric objects.
|
||||||
['performance-pnl'],
|
// We try to find known metric names in the array for display.
|
||||||
'/performance/pnl',
|
const metricsList = metricsQuery.data?.metrics ?? [];
|
||||||
{ refetchInterval: 30000 },
|
const findMetric = (name: string) =>
|
||||||
);
|
metricsList.find((m) => m.name === name);
|
||||||
|
|
||||||
const m = metrics.data;
|
const sharpe = findMetric('sharpe_ratio');
|
||||||
|
const sortino = findMetric('sortino_ratio');
|
||||||
|
const maxDD = findMetric('max_drawdown');
|
||||||
|
const winRate = findMetric('win_rate');
|
||||||
|
const totalTrades = findMetric('total_trades');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{(metrics.isError || pnlData.isError) && (
|
{metricsQuery.isError && (
|
||||||
<div className="px-3 py-2 rounded border border-[var(--color-red)] bg-red-500/10 text-sm text-[var(--color-red)] flex items-center justify-between">
|
<div className="px-3 py-2 rounded border border-[var(--color-red)] bg-red-500/10 text-sm text-[var(--color-red)] flex items-center justify-between">
|
||||||
<span>Failed to load {metrics.isError ? 'metrics' : 'P&L data'}: {(metrics.error ?? pnlData.error)?.message}</span>
|
<span>Failed to load metrics: {metricsQuery.error.message}</span>
|
||||||
<button onClick={() => { metrics.refetch(); pnlData.refetch(); }} className="text-xs underline">Retry</button>
|
<button onClick={() => metricsQuery.refetch()} className="text-xs underline">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top: Metric cards */}
|
{/* Top: Metric cards */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg: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)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<MetricCard label="Sharpe Ratio" value={m?.sharpe_ratio?.toFixed(2) ?? '--'} />
|
<MetricCard label="Sharpe Ratio" value={sharpe?.value?.toFixed(2) ?? '--'} />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<MetricCard label="Sortino Ratio" value={m?.sortino_ratio?.toFixed(2) ?? '--'} />
|
<MetricCard label="Sortino Ratio" value={sortino?.value?.toFixed(2) ?? '--'} />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Max Drawdown"
|
label="Max Drawdown"
|
||||||
value={m?.max_drawdown?.toFixed(2) ?? '--'}
|
value={maxDD?.value?.toFixed(2) ?? '--'}
|
||||||
suffix="%"
|
suffix="%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Win Rate"
|
label="Win Rate"
|
||||||
value={m?.win_rate !== undefined ? (m.win_rate * 100).toFixed(1) : '--'}
|
value={winRate?.value !== undefined ? (winRate.value * 100).toFixed(1) : '--'}
|
||||||
suffix="%"
|
suffix="%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<MetricCard label="Total Trades" value={m?.total_trades ?? '--'} />
|
<MetricCard label="Total Trades" value={totalTrades?.value ?? '--'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Middle: P&L Chart */}
|
{/* Middle: P&L Chart */}
|
||||||
<div className="h-64 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="h-64 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<ComponentErrorBoundary name="P&L Chart">
|
<ComponentErrorBoundary name="P&L Chart">
|
||||||
<PnLChart title="Cumulative P&L" data={pnlData.data} />
|
<PnLChart title="Cumulative P&L" data={undefined} />
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,7 +65,7 @@ export function PerformanceDashboard() {
|
|||||||
<div className="grid grid-cols-1 md: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)]">
|
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<ComponentErrorBoundary name="Daily Returns Chart">
|
<ComponentErrorBoundary name="Daily Returns Chart">
|
||||||
<PnLChart title="Daily Returns" data={pnlData.data} />
|
<PnLChart title="Daily Returns" data={undefined} />
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
|
|||||||
@@ -2,18 +2,15 @@ import { ComponentErrorBoundary } from '../components/common/ComponentErrorBound
|
|||||||
import { RiskGauge } from '../components/risk/RiskGauge';
|
import { RiskGauge } from '../components/risk/RiskGauge';
|
||||||
import { DrawdownChart } from '../components/risk/DrawdownChart';
|
import { DrawdownChart } from '../components/risk/DrawdownChart';
|
||||||
import { EmergencyControls } from '../components/risk/EmergencyControls';
|
import { EmergencyControls } from '../components/risk/EmergencyControls';
|
||||||
import { useApiQuery } from '../hooks/useApi';
|
import { useRiskMetrics } from '../hooks/useApi';
|
||||||
import { useWebSocketTopic } from '../hooks/useWebSocket';
|
import { useRiskAlertsStream } from '../hooks/useGrpcStream';
|
||||||
import type { RiskMetrics } from '../lib/types';
|
import { RiskAlertSeverity } from '../gen/risk_pb';
|
||||||
|
|
||||||
export function RiskDashboard() {
|
export function RiskDashboard() {
|
||||||
const riskMetrics = useApiQuery<RiskMetrics>(['risk-metrics'], '/risk/metrics', {
|
const riskMetrics = useRiskMetrics();
|
||||||
refetchInterval: 5000,
|
const { messages: alerts } = useRiskAlertsStream();
|
||||||
});
|
|
||||||
|
|
||||||
const { messages: alerts } = useWebSocketTopic('risk_alert');
|
const metrics = riskMetrics.data?.metrics;
|
||||||
|
|
||||||
const metrics = riskMetrics.data;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -30,15 +27,15 @@ export function RiskDashboard() {
|
|||||||
<ComponentErrorBoundary name="VaR Gauge">
|
<ComponentErrorBoundary name="VaR Gauge">
|
||||||
<RiskGauge
|
<RiskGauge
|
||||||
label="VaR Utilization"
|
label="VaR Utilization"
|
||||||
value={metrics?.portfolio_var ?? 0}
|
value={metrics?.portfolioVar1d ?? 0}
|
||||||
/>
|
/>
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<ComponentErrorBoundary name="Position Gauge">
|
<ComponentErrorBoundary name="Volatility Gauge">
|
||||||
<RiskGauge
|
<RiskGauge
|
||||||
label="Position Utilization"
|
label="Volatility"
|
||||||
value={metrics?.position_utilization ?? 0}
|
value={metrics?.volatility ?? 0}
|
||||||
/>
|
/>
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +43,7 @@ export function RiskDashboard() {
|
|||||||
<ComponentErrorBoundary name="Drawdown Gauge">
|
<ComponentErrorBoundary name="Drawdown Gauge">
|
||||||
<RiskGauge
|
<RiskGauge
|
||||||
label="Drawdown"
|
label="Drawdown"
|
||||||
value={Math.abs(metrics?.current_drawdown ?? 0)}
|
value={Math.abs(metrics?.currentDrawdown ?? 0)}
|
||||||
thresholds={{ warn: 5, danger: 10 }}
|
thresholds={{ warn: 5, danger: 10 }}
|
||||||
max={20}
|
max={20}
|
||||||
/>
|
/>
|
||||||
@@ -74,20 +71,33 @@ export function RiskDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-[var(--color-border)]">
|
<div className="divide-y divide-[var(--color-border)]">
|
||||||
{alerts.slice(-20).reverse().map((alert, i) => (
|
{alerts.slice(-20).reverse().map((alert, i) => {
|
||||||
<div key={`alert-${alerts.length - i}`} className="px-3 py-2 text-xs">
|
const severityLabel =
|
||||||
<span
|
alert.severity === RiskAlertSeverity.CRITICAL
|
||||||
className={
|
? 'CRITICAL'
|
||||||
(alert as { severity?: string }).severity === 'critical'
|
: alert.severity === RiskAlertSeverity.EMERGENCY
|
||||||
? 'text-[var(--color-red)]'
|
? 'EMERGENCY'
|
||||||
: 'text-[var(--color-yellow)]'
|
: alert.severity === RiskAlertSeverity.WARNING
|
||||||
}
|
? 'WARNING'
|
||||||
>
|
: 'INFO';
|
||||||
[{((alert as { severity?: string }).severity ?? 'info').toUpperCase()}]
|
const isCritical =
|
||||||
</span>{' '}
|
alert.severity === RiskAlertSeverity.CRITICAL ||
|
||||||
{JSON.stringify((alert as { data?: unknown }).data ?? alert)}
|
alert.severity === RiskAlertSeverity.EMERGENCY;
|
||||||
</div>
|
return (
|
||||||
))}
|
<div key={`alert-${alerts.length - i}`} className="px-3 py-2 text-xs">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isCritical
|
||||||
|
? 'text-[var(--color-red)]'
|
||||||
|
: 'text-[var(--color-yellow)]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
[{severityLabel}]
|
||||||
|
</span>{' '}
|
||||||
|
{alert.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,37 +5,35 @@ import { OrderBook } from '../components/trading/OrderBook';
|
|||||||
import { PositionsTable } from '../components/trading/PositionsTable';
|
import { PositionsTable } from '../components/trading/PositionsTable';
|
||||||
import { OrderForm } from '../components/trading/OrderForm';
|
import { OrderForm } from '../components/trading/OrderForm';
|
||||||
import { OrdersTable } from '../components/trading/OrdersTable';
|
import { OrdersTable } from '../components/trading/OrdersTable';
|
||||||
import { useApiQuery } from '../hooks/useApi';
|
import { usePositions, useOrders, useOrderBook } from '../hooks/useApi';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useMarketDataStream } from '../hooks/useGrpcStream';
|
||||||
import type { Position, Order } from '../lib/types';
|
|
||||||
|
|
||||||
interface OrderBookData {
|
|
||||||
bids: { price: number; size: number }[];
|
|
||||||
asks: { price: number; size: number }[];
|
|
||||||
spread?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MARKET_DATA_TOPICS = ['market_data'];
|
|
||||||
|
|
||||||
export function TradingDashboard() {
|
export function TradingDashboard() {
|
||||||
const positions = useApiQuery<Position[]>(['positions'], '/trading/positions', {
|
const positions = usePositions();
|
||||||
refetchInterval: 5000,
|
const orders = useOrders();
|
||||||
});
|
const orderBook = useOrderBook('ES.FUT');
|
||||||
|
const { lastMessage: marketDataEvent } = useMarketDataStream();
|
||||||
|
|
||||||
const orders = useApiQuery<Order[]>(['orders'], '/trading/orders', {
|
// Adapt proto OrderBook response to the component's expected shape
|
||||||
refetchInterval: 5000,
|
const orderBookData = useMemo(() => {
|
||||||
});
|
const ob = orderBook.data?.orderBook;
|
||||||
|
|
||||||
const { lastMessage } = useWebSocket(MARKET_DATA_TOPICS);
|
|
||||||
|
|
||||||
const orderBookData = useMemo<OrderBookData>(() => {
|
|
||||||
const data = lastMessage?.data as OrderBookData | undefined;
|
|
||||||
return {
|
return {
|
||||||
bids: data?.bids ?? [],
|
bids: ob?.bids?.map((l) => ({ price: l.price, size: l.quantity })) ?? [],
|
||||||
asks: data?.asks ?? [],
|
asks: ob?.asks?.map((l) => ({ price: l.price, size: l.quantity })) ?? [],
|
||||||
spread: data?.spread,
|
spread: undefined as number | undefined,
|
||||||
};
|
};
|
||||||
}, [lastMessage]);
|
}, [orderBook.data]);
|
||||||
|
|
||||||
|
// Extract positions array from the gRPC response message
|
||||||
|
const positionsList = positions.data?.positions;
|
||||||
|
// The order response wraps a single Order; for the orders list we use
|
||||||
|
// streamOrders or getOrderStatus which returns a single order.
|
||||||
|
// Since the REST API returned Order[] and the proto returns a single Order,
|
||||||
|
// we wrap it into an array for backward compatibility with OrdersTable.
|
||||||
|
const ordersList = orders.data?.order ? [orders.data.order] : undefined;
|
||||||
|
|
||||||
|
// We still expose lastMessage for components that might use market data
|
||||||
|
void marketDataEvent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -65,7 +63,7 @@ export function TradingDashboard() {
|
|||||||
{/* Middle: Positions */}
|
{/* Middle: Positions */}
|
||||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<PositionsTable
|
<PositionsTable
|
||||||
positions={positions.data}
|
positions={positionsList}
|
||||||
loading={positions.isLoading}
|
loading={positions.isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +71,7 @@ export function TradingDashboard() {
|
|||||||
{/* Bottom: Orders + Order Form */}
|
{/* Bottom: Orders + Order Form */}
|
||||||
<div className="grid grid-cols-1 lg: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)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<OrdersTable orders={orders.data} loading={orders.isLoading} />
|
<OrdersTable orders={ordersList} loading={orders.isLoading} />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">
|
||||||
<OrderForm />
|
<OrderForm />
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ export default defineConfig({
|
|||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
// Proxy grpc-web requests to the tonic-web gateway
|
||||||
target: 'http://localhost:3000',
|
'/auth.AuthService': { target: 'http://localhost:50051' },
|
||||||
ws: true,
|
'/trading.TradingService': { target: 'http://localhost:50051' },
|
||||||
},
|
'/risk.RiskService': { target: 'http://localhost:50051' },
|
||||||
|
'/ml.MLService': { target: 'http://localhost:50051' },
|
||||||
|
'/ml_training.MLTrainingService': { target: 'http://localhost:50051' },
|
||||||
|
'/config.ConfigService': { target: 'http://localhost:50051' },
|
||||||
|
'/monitoring.MonitoringService': { target: 'http://localhost:50051' },
|
||||||
|
'/foxhunt.tli.BacktestingService': { target: 'http://localhost:50051' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -20,6 +25,7 @@ export default defineConfig({
|
|||||||
vendor: ['react', 'react-dom', 'react-router-dom'],
|
vendor: ['react', 'react-dom', 'react-router-dom'],
|
||||||
charts: ['lightweight-charts', 'recharts'],
|
charts: ['lightweight-charts', 'recharts'],
|
||||||
query: ['@tanstack/react-query'],
|
query: ['@tanstack/react-query'],
|
||||||
|
grpc: ['@connectrpc/connect', '@connectrpc/connect-web', '@bufbuild/protobuf'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user