Skip to content

Commit a054a01

Browse files
authored
feat: rudimentary dashboard for session state (#175)
* Add status crate to create a home for status-related endpoints * Add status service to example app * Flip dependency structure so that hotfix-status depends on hotfix as an add-on * Add endpoint to get session info * Add endpoint for static assets * Add dashboard endpoint with static HTML * Refactor status API and pages structure * Make UI functionality properly dependent on UI feature flag * Show session info in dashboard * Improve dashboard style * Add custom app error struct
1 parent 3a4c008 commit a054a01

17 files changed

Lines changed: 666 additions & 15 deletions

File tree

Cargo.lock

Lines changed: 266 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,18 @@ categories = ["finance", "encoding", "parsing"]
1818

1919
[workspace.dependencies]
2020
anyhow = "^1.0.75"
21+
askama = "0.14"
22+
async-trait = "0.1.89"
23+
axum = "^0.8"
2124
chrono = "^0.4"
2225
chrono-tz = "^0.10"
26+
displaydoc = "0.2"
27+
mime_guess = "2.0.5"
28+
rust-embed = "8.7"
2329
serde = "^1.0.177"
2430
thiserror = "1"
31+
tokio = { version = "^1" }
32+
tokio-rustls = "^0.26"
2533
toml = "^0.8.8"
2634
tracing = "^0.1.37"
2735

crates/hotfix-status/Cargo.toml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[package]
2+
name = "hotfix-status"
3+
description = "Status endpoints and an option web-based dashboard for the HotFIX engine"
4+
version.workspace = true
5+
authors.workspace = true
6+
edition.workspace = true
7+
license.workspace = true
8+
readme.workspace = true
9+
homepage.workspace = true
10+
repository.workspace = true
11+
keywords.workspace = true
12+
categories.workspace = true
13+
14+
[lints]
15+
workspace = true
16+
17+
[features]
18+
ui = ["askama", "mime_guess", "rust-embed"]
19+
20+
[dependencies]
21+
askama = { workspace = true, features = ["serde_json"], optional = true }
22+
async-trait = { workspace = true }
23+
axum = { workspace = true }
24+
chrono = { workspace = true }
25+
displaydoc = { workspace = true }
26+
hotfix = { version = "0.0.25", path = "../hotfix" }
27+
mime_guess = { workspace = true, optional = true }
28+
rust-embed = { workspace = true, features = ["axum-ex"], optional = true }
29+
serde = { workspace = true, features = ["derive"] }
30+
thiserror = { workspace = true }

crates/hotfix-status/assets/tailwind.js

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/hotfix-status/src/api.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
use crate::AppState;
2+
use crate::data_provider::DataProvider;
3+
use axum::extract::State;
4+
use axum::routing::get;
5+
use axum::{Json, Router};
6+
use hotfix::session::SessionInfo;
7+
use serde::Serialize;
8+
9+
pub fn build_api_router<P: DataProvider + 'static>() -> Router<AppState<P>> {
10+
Router::new()
11+
.route("/health", get(get_health))
12+
.route("/session-info", get(get_session_info))
13+
}
14+
15+
#[derive(Debug, Serialize)]
16+
struct HealthStatusResponse {
17+
status: String,
18+
}
19+
20+
async fn get_health() -> Json<HealthStatusResponse> {
21+
Json(HealthStatusResponse {
22+
status: "healthy".to_string(),
23+
})
24+
}
25+
26+
#[derive(Debug, Serialize)]
27+
struct SessionInfoResponse {
28+
session_info: SessionInfo,
29+
}
30+
31+
async fn get_session_info<P: DataProvider>(
32+
State(state): State<AppState<P>>,
33+
) -> Json<SessionInfoResponse> {
34+
let session_info = state.data_provider.get_session_info().await;
35+
36+
Json(SessionInfoResponse { session_info })
37+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
use hotfix::message::FixMessage;
2+
use hotfix::session::{SessionInfo, SessionRef};
3+
4+
#[async_trait::async_trait]
5+
pub trait DataProvider: Clone + Send + Sync {
6+
async fn get_session_info(&self) -> SessionInfo;
7+
}
8+
9+
#[derive(Clone)]
10+
pub struct SessionDataProvider<M> {
11+
pub(crate) session_ref: SessionRef<M>,
12+
}
13+
14+
#[async_trait::async_trait]
15+
impl<M: FixMessage> DataProvider for SessionDataProvider<M> {
16+
async fn get_session_info(&self) -> SessionInfo {
17+
self.session_ref.get_session_info().await
18+
}
19+
}

crates/hotfix-status/src/error.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
use axum::http::StatusCode;
2+
use axum::response::{IntoResponse, Response};
3+
4+
#[derive(Debug, displaydoc::Display, thiserror::Error)]
5+
pub enum AppError {
6+
#[cfg(feature = "ui")]
7+
/// could not render the template
8+
Render(#[from] askama::Error),
9+
}
10+
11+
pub type AppResult<T> = Result<T, AppError>;
12+
13+
impl IntoResponse for AppError {
14+
fn into_response(self) -> Response {
15+
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
16+
}
17+
}

crates/hotfix-status/src/lib.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
mod api;
2+
mod data_provider;
3+
mod error;
4+
#[cfg(feature = "ui")]
5+
mod ui;
6+
7+
use crate::api::build_api_router;
8+
use crate::data_provider::SessionDataProvider;
9+
use axum::Router;
10+
use hotfix::message::FixMessage;
11+
use hotfix::session::SessionRef;
12+
13+
#[derive(Clone)]
14+
struct AppState<P> {
15+
data_provider: P,
16+
}
17+
18+
#[cfg(feature = "ui")]
19+
pub fn build_router<M: FixMessage>(session_ref: SessionRef<M>) -> Router {
20+
let data_provider = SessionDataProvider { session_ref };
21+
let state = AppState { data_provider };
22+
Router::new()
23+
.nest("/api", build_api_router())
24+
.merge(ui::builder_ui_router())
25+
.with_state(state)
26+
}
27+
28+
#[cfg(not(feature = "ui"))]
29+
pub fn build_router<M: FixMessage>(session_ref: SessionRef<M>) -> Router {
30+
let data_provider = SessionDataProvider { session_ref };
31+
let state = AppState { data_provider };
32+
Router::new().nest("/api", build_api_router(state))
33+
}

crates/hotfix-status/src/ui.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
use crate::AppState;
2+
use crate::data_provider::DataProvider;
3+
use crate::ui::assets::static_handler;
4+
use crate::ui::dashboard::dashboard_handler;
5+
use axum::Router;
6+
use axum::routing::get;
7+
8+
mod assets;
9+
mod dashboard;
10+
11+
pub fn builder_ui_router<P: DataProvider + 'static>() -> Router<AppState<P>> {
12+
Router::new()
13+
.route("/", get(dashboard_handler))
14+
.route("/static/{*file}", get(static_handler))
15+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use axum::http::{StatusCode, Uri, header};
2+
use axum::response::{IntoResponse, Response};
3+
use rust_embed::Embed;
4+
5+
#[derive(Embed)]
6+
#[folder = "assets/"]
7+
struct Assets;
8+
9+
pub(crate) async fn static_handler(uri: Uri) -> impl IntoResponse {
10+
let mut path = uri.path().trim_start_matches('/').to_string();
11+
12+
if path.starts_with("static/") {
13+
path = path.replace("static/", "");
14+
}
15+
16+
StaticFile(path)
17+
}
18+
19+
pub(crate) struct StaticFile<T>(pub T);
20+
21+
impl<T> IntoResponse for StaticFile<T>
22+
where
23+
T: Into<String>,
24+
{
25+
fn into_response(self) -> Response {
26+
let path = self.0.into();
27+
28+
match Assets::get(path.as_str()) {
29+
Some(content) => {
30+
let mime = mime_guess::from_path(path).first_or_octet_stream();
31+
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
32+
}
33+
None => (StatusCode::NOT_FOUND, "404 Not Found").into_response(),
34+
}
35+
}
36+
}

0 commit comments

Comments
 (0)