per-source polling, initial client state, webhook, preview, client tracking
- per-source poll intervals: spawn task per source with own interval,
manager re-checks sources every 30s for add/remove
- initial screen update on TCP connect: send layout + widget states
- client tracking: ClientRegistry port, GET /api/clients, dashboard list
- webhook adapter: POST /api/webhook/{source_id} feeds data into projection
- widget preview: GET /api/widgets/{id}/preview returns current state
- serve SPA from Axum: ServeDir + index.html fallback via KFRAME_SPA_DIR
- layout builder delete confirmation with AlertDialog
- form validation: required fields disable save button
- guide page at /guide
- fix architecture: ClientDto to api-types, ClientRegistry + WidgetStateReader
ports in domain, DataProjection has internal Mutex, no adapter cross-deps
- ESP32: full screen clear on layout change (stale pixel fix)
This commit is contained in:
31
Cargo.lock
generated
31
Cargo.lock
generated
@@ -782,6 +782,12 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range-header"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -1136,6 +1142,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -2277,10 +2293,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -2373,6 +2398,12 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ http-api = { path = "crates/adapters/http-api" }
|
|||||||
media-adapter = { path = "crates/adapters/media" }
|
media-adapter = { path = "crates/adapters/media" }
|
||||||
rss-adapter = { path = "crates/adapters/rss" }
|
rss-adapter = { path = "crates/adapters/rss" }
|
||||||
axum = { version = "0.8", features = ["macros"] }
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
tower-http = { version = "0.6", features = ["cors"] }
|
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||||
api-types = { path = "crates/api-types" }
|
api-types = { path = "crates/api-types" }
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ tower.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
config-memory.workspace = true
|
config-memory.workspace = true
|
||||||
tcp-server.workspace = true
|
tcp-server.workspace = true
|
||||||
|
application.workspace = true
|
||||||
|
|||||||
@@ -1,43 +1,72 @@
|
|||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, WidgetStateReader};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
|
||||||
pub struct AppState<C, E> {
|
pub struct AppState<C, E, W, B, R> {
|
||||||
pub config: Arc<C>,
|
pub config: Arc<C>,
|
||||||
pub events: Arc<E>,
|
pub events: Arc<E>,
|
||||||
|
pub widget_states: Arc<W>,
|
||||||
|
pub broadcaster: Arc<B>,
|
||||||
|
pub clients: Arc<R>,
|
||||||
|
pub spa_dir: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C, E> Clone for AppState<C, E> {
|
impl<C, E, W, B, R> Clone for AppState<C, E, W, B, R> {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
config: self.config.clone(),
|
config: self.config.clone(),
|
||||||
events: self.events.clone(),
|
events: self.events.clone(),
|
||||||
|
widget_states: self.widget_states.clone(),
|
||||||
|
broadcaster: self.broadcaster.clone(),
|
||||||
|
clients: self.clients.clone(),
|
||||||
|
spa_dir: self.spa_dir.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router<C, E>(state: AppState<C, E>) -> Router
|
pub fn router<C, E, W, B, R>(state: AppState<C, E, W, B, R>) -> Router
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + Send + Sync + 'static,
|
||||||
C::Error: std::fmt::Debug + Send,
|
C::Error: std::fmt::Debug + Send,
|
||||||
E: EventPublisher + Send + Sync + 'static,
|
E: EventPublisher + Send + Sync + 'static,
|
||||||
E::Error: std::fmt::Debug + Send,
|
E::Error: std::fmt::Debug + Send,
|
||||||
|
W: WidgetStateReader + Send + Sync + 'static,
|
||||||
|
B: BroadcastPort + Send + Sync + 'static,
|
||||||
|
B::Error: std::fmt::Debug + Send,
|
||||||
|
R: ClientRegistry + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
Router::new()
|
let spa_dir = state.spa_dir.clone();
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
.nest("/api", routes::api_routes())
|
.nest("/api", routes::api_routes())
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.with_state(state)
|
.with_state(state);
|
||||||
|
|
||||||
|
if let Some(dir) = spa_dir {
|
||||||
|
let index = format!("{dir}/index.html");
|
||||||
|
app.fallback_service(ServeDir::new(&dir).fallback(ServeFile::new(index)))
|
||||||
|
} else {
|
||||||
|
app
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serve<C, E>(addr: &str, state: AppState<C, E>) -> Result<(), std::io::Error>
|
pub async fn serve<C, E, W, B, R>(
|
||||||
|
addr: &str,
|
||||||
|
state: AppState<C, E, W, B, R>,
|
||||||
|
) -> Result<(), std::io::Error>
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + Send + Sync + 'static,
|
||||||
C::Error: std::fmt::Debug + Send,
|
C::Error: std::fmt::Debug + Send,
|
||||||
E: EventPublisher + Send + Sync + 'static,
|
E: EventPublisher + Send + Sync + 'static,
|
||||||
E::Error: std::fmt::Debug + Send,
|
E::Error: std::fmt::Debug + Send,
|
||||||
|
W: WidgetStateReader + Send + Sync + 'static,
|
||||||
|
B: BroadcastPort + Send + Sync + 'static,
|
||||||
|
B::Error: std::fmt::Debug + Send,
|
||||||
|
R: ClientRegistry + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let app = router(state);
|
let app = router(state);
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
|||||||
25
crates/adapters/http-api/src/routes/clients.rs
Normal file
25
crates/adapters/http-api/src/routes/clients.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use crate::AppState;
|
||||||
|
use api_types::ClientDto;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::Json;
|
||||||
|
use domain::{ClientRegistry, ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
|
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||||
|
|
||||||
|
pub async fn list_clients<C, E, W, B, R>(State(state): S<C, E, W, B, R>) -> Json<Vec<ClientDto>>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
|
R: ClientRegistry,
|
||||||
|
{
|
||||||
|
Json(
|
||||||
|
state
|
||||||
|
.clients
|
||||||
|
.list_clients()
|
||||||
|
.iter()
|
||||||
|
.map(ClientDto::from)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,10 +8,10 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
type S<C, E> = State<AppState<C, E>>;
|
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||||
|
|
||||||
pub async fn list_data_sources<C, E>(
|
pub async fn list_data_sources<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
|
) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: ConfigRepository,
|
||||||
@@ -27,8 +27,8 @@ where
|
|||||||
Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
|
Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_data_source<C, E>(
|
pub async fn get_data_source<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<Json<DataSourceDto>, StatusCode>
|
) -> Result<Json<DataSourceDto>, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -48,8 +48,8 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_data_source<C, E>(
|
pub async fn create_data_source<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Json(body): Json<DataSourceDto>,
|
Json(body): Json<DataSourceDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
@@ -68,8 +68,8 @@ where
|
|||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_data_source<C, E>(
|
pub async fn update_data_source<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Path(_id): Path<u16>,
|
Path(_id): Path<u16>,
|
||||||
Json(body): Json<DataSourceDto>,
|
Json(body): Json<DataSourceDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
@@ -89,8 +89,8 @@ where
|
|||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_data_source<C, E>(
|
pub async fn delete_data_source<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<StatusCode, StatusCode>
|
) -> Result<StatusCode, StatusCode>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ use application::ConfigService;
|
|||||||
use axum::{extract::State, http::StatusCode, response::Json};
|
use axum::{extract::State, http::StatusCode, response::Json};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
type S<C, E> = State<AppState<C, E>>;
|
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||||
|
|
||||||
pub async fn get_layout<C, E>(State(state): S<C, E>) -> Result<Json<Option<LayoutDto>>, StatusCode>
|
pub async fn get_layout<C, E, W, B, R>(
|
||||||
|
State(state): S<C, E, W, B, R>,
|
||||||
|
) -> Result<Json<Option<LayoutDto>>, StatusCode>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: ConfigRepository,
|
||||||
C::Error: std::fmt::Debug,
|
C::Error: std::fmt::Debug,
|
||||||
@@ -21,8 +23,8 @@ where
|
|||||||
Ok(Json(layout.as_ref().map(LayoutDto::from)))
|
Ok(Json(layout.as_ref().map(LayoutDto::from)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_layout<C, E>(
|
pub async fn update_layout<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Json(body): Json<LayoutDto>,
|
Json(body): Json<LayoutDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -1,53 +1,74 @@
|
|||||||
|
mod clients;
|
||||||
mod data_sources;
|
mod data_sources;
|
||||||
mod layout;
|
mod layout;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
mod webhook;
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, WidgetStateReader};
|
||||||
|
|
||||||
pub fn api_routes<C, E>() -> Router<AppState<C, E>>
|
pub fn api_routes<C, E, W, B, R>() -> Router<AppState<C, E, W, B, R>>
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + Send + Sync + 'static,
|
||||||
C::Error: std::fmt::Debug + Send,
|
C::Error: std::fmt::Debug + Send,
|
||||||
E: EventPublisher + Send + Sync + 'static,
|
E: EventPublisher + Send + Sync + 'static,
|
||||||
E::Error: std::fmt::Debug + Send,
|
E::Error: std::fmt::Debug + Send,
|
||||||
|
W: WidgetStateReader + Send + Sync + 'static,
|
||||||
|
B: BroadcastPort + Send + Sync + 'static,
|
||||||
|
B::Error: std::fmt::Debug + Send,
|
||||||
|
R: ClientRegistry + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/widgets",
|
"/widgets",
|
||||||
get(widgets::list_widgets::<C, E>).post(widgets::create_widget::<C, E>),
|
get(widgets::list_widgets::<C, E, W, B, R>)
|
||||||
|
.post(widgets::create_widget::<C, E, W, B, R>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/widgets/{id}",
|
"/widgets/{id}",
|
||||||
get(widgets::get_widget::<C, E>)
|
get(widgets::get_widget::<C, E, W, B, R>)
|
||||||
.put(widgets::update_widget::<C, E>)
|
.put(widgets::update_widget::<C, E, W, B, R>)
|
||||||
.delete(widgets::delete_widget::<C, E>),
|
.delete(widgets::delete_widget::<C, E, W, B, R>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/widgets/{id}/preview",
|
||||||
|
get(widgets::preview_widget::<C, E, W, B, R>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/data-sources",
|
"/data-sources",
|
||||||
get(data_sources::list_data_sources::<C, E>)
|
get(data_sources::list_data_sources::<C, E, W, B, R>)
|
||||||
.post(data_sources::create_data_source::<C, E>),
|
.post(data_sources::create_data_source::<C, E, W, B, R>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/data-sources/{id}",
|
"/data-sources/{id}",
|
||||||
get(data_sources::get_data_source::<C, E>)
|
get(data_sources::get_data_source::<C, E, W, B, R>)
|
||||||
.put(data_sources::update_data_source::<C, E>)
|
.put(data_sources::update_data_source::<C, E, W, B, R>)
|
||||||
.delete(data_sources::delete_data_source::<C, E>),
|
.delete(data_sources::delete_data_source::<C, E, W, B, R>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/layout",
|
"/layout",
|
||||||
get(layout::get_layout::<C, E>).put(layout::update_layout::<C, E>),
|
get(layout::get_layout::<C, E, W, B, R>).put(layout::update_layout::<C, E, W, B, R>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/presets",
|
"/presets",
|
||||||
get(presets::list_presets::<C, E>).post(presets::create_preset::<C, E>),
|
get(presets::list_presets::<C, E, W, B, R>)
|
||||||
|
.post(presets::create_preset::<C, E, W, B, R>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/presets/{id}",
|
"/presets/{id}",
|
||||||
get(presets::get_preset::<C, E>).delete(presets::delete_preset::<C, E>),
|
get(presets::get_preset::<C, E, W, B, R>)
|
||||||
|
.delete(presets::delete_preset::<C, E, W, B, R>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/presets/{id}/load",
|
||||||
|
post(presets::load_preset::<C, E, W, B, R>),
|
||||||
|
)
|
||||||
|
.route("/clients", get(clients::list_clients::<C, E, W, B, R>))
|
||||||
|
.route(
|
||||||
|
"/webhook/{source_id}",
|
||||||
|
post(webhook::receive_webhook::<C, E, W, B, R>),
|
||||||
)
|
)
|
||||||
.route("/presets/{id}/load", post(presets::load_preset::<C, E>))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
type S<C, E> = State<AppState<C, E>>;
|
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||||
|
|
||||||
pub async fn list_presets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<PresetDto>>, StatusCode>
|
pub async fn list_presets<C, E, W, B, R>(
|
||||||
|
State(state): S<C, E, W, B, R>,
|
||||||
|
) -> Result<Json<Vec<PresetDto>>, StatusCode>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: ConfigRepository,
|
||||||
C::Error: std::fmt::Debug,
|
C::Error: std::fmt::Debug,
|
||||||
@@ -25,8 +27,8 @@ where
|
|||||||
Ok(Json(presets.iter().map(PresetDto::from).collect()))
|
Ok(Json(presets.iter().map(PresetDto::from).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_preset<C, E>(
|
pub async fn get_preset<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<Json<PresetDto>, StatusCode>
|
) -> Result<Json<PresetDto>, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -46,8 +48,8 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_preset<C, E>(
|
pub async fn create_preset<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Json(body): Json<CreatePresetDto>,
|
Json(body): Json<CreatePresetDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
@@ -66,8 +68,8 @@ where
|
|||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_preset<C, E>(
|
pub async fn delete_preset<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<StatusCode, StatusCode>
|
) -> Result<StatusCode, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -83,8 +85,8 @@ where
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_preset<C, E>(
|
pub async fn load_preset<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
|
|||||||
81
crates/adapters/http-api/src/routes/webhook.rs
Normal file
81
crates/adapters/http-api/src/routes/webhook.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use crate::AppState;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Json;
|
||||||
|
use domain::{BroadcastPort, ConfigRepository, EventPublisher, WidgetStateReader};
|
||||||
|
|
||||||
|
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||||
|
|
||||||
|
pub async fn receive_webhook<C, E, W, B, R>(
|
||||||
|
State(state): S<C, E, W, B, R>,
|
||||||
|
Path(source_id): Path<u16>,
|
||||||
|
Json(body): Json<serde_json::Value>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
|
W: WidgetStateReader,
|
||||||
|
B: BroadcastPort,
|
||||||
|
B::Error: std::fmt::Debug,
|
||||||
|
{
|
||||||
|
let source = state
|
||||||
|
.config
|
||||||
|
.get_data_source(source_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "data source not found".into()))?;
|
||||||
|
|
||||||
|
if source.source_type != domain::DataSourceType::Webhook {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"data source is not a webhook type".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = json_to_domain_value(body);
|
||||||
|
let widgets = state
|
||||||
|
.config
|
||||||
|
.list_widgets()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||||
|
|
||||||
|
let layout = state
|
||||||
|
.config
|
||||||
|
.get_layout()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||||
|
|
||||||
|
let changed = state
|
||||||
|
.widget_states
|
||||||
|
.apply_raw_data(source_id, &raw, &widgets)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if !changed.is_empty()
|
||||||
|
&& let Some(l) = &layout
|
||||||
|
{
|
||||||
|
let _ = state.broadcaster.push_screen_update(l, &changed).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_domain_value(json: serde_json::Value) -> domain::Value {
|
||||||
|
match json {
|
||||||
|
serde_json::Value::Null => domain::Value::Null,
|
||||||
|
serde_json::Value::Bool(b) => domain::Value::Bool(b),
|
||||||
|
serde_json::Value::Number(n) => domain::Value::Number(n.as_f64().unwrap_or(0.0)),
|
||||||
|
serde_json::Value::String(s) => domain::Value::String(s),
|
||||||
|
serde_json::Value::Array(arr) => {
|
||||||
|
domain::Value::Array(arr.into_iter().map(json_to_domain_value).collect())
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(obj) => {
|
||||||
|
let map = obj
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k, json_to_domain_value(v)))
|
||||||
|
.collect();
|
||||||
|
domain::Value::Object(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,13 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher, WidgetStateReader};
|
||||||
|
|
||||||
type S<C, E> = State<AppState<C, E>>;
|
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||||
|
|
||||||
pub async fn list_widgets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<WidgetDto>>, StatusCode>
|
pub async fn list_widgets<C, E, W, B, R>(
|
||||||
|
State(state): S<C, E, W, B, R>,
|
||||||
|
) -> Result<Json<Vec<WidgetDto>>, StatusCode>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: ConfigRepository,
|
||||||
C::Error: std::fmt::Debug,
|
C::Error: std::fmt::Debug,
|
||||||
@@ -25,8 +27,8 @@ where
|
|||||||
Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
|
Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_widget<C, E>(
|
pub async fn get_widget<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<Json<WidgetDto>, StatusCode>
|
) -> Result<Json<WidgetDto>, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -46,8 +48,8 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_widget<C, E>(
|
pub async fn create_widget<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Json(body): Json<CreateWidgetDto>,
|
Json(body): Json<CreateWidgetDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
@@ -66,8 +68,8 @@ where
|
|||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_widget<C, E>(
|
pub async fn update_widget<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Path(_id): Path<u16>,
|
Path(_id): Path<u16>,
|
||||||
Json(body): Json<CreateWidgetDto>,
|
Json(body): Json<CreateWidgetDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
@@ -87,8 +89,8 @@ where
|
|||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_widget<C, E>(
|
pub async fn delete_widget<C, E, W, B, R>(
|
||||||
State(state): S<C, E>,
|
State(state): S<C, E, W, B, R>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<StatusCode, StatusCode>
|
) -> Result<StatusCode, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -103,3 +105,46 @@ where
|
|||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn preview_widget<C, E, W, B, R>(
|
||||||
|
State(state): S<C, E, W, B, R>,
|
||||||
|
Path(id): Path<u16>,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
|
W: WidgetStateReader,
|
||||||
|
{
|
||||||
|
match state.widget_states.get_widget_state(id).await {
|
||||||
|
Some(ws) => {
|
||||||
|
let map: serde_json::Map<String, serde_json::Value> = ws
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
|
||||||
|
.collect();
|
||||||
|
Ok(Json(serde_json::Value::Object(map)))
|
||||||
|
}
|
||||||
|
None => Err(StatusCode::NOT_FOUND),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain_value_to_json(v: &domain::Value) -> serde_json::Value {
|
||||||
|
match v {
|
||||||
|
domain::Value::Null => serde_json::Value::Null,
|
||||||
|
domain::Value::Bool(b) => serde_json::Value::Bool(*b),
|
||||||
|
domain::Value::Number(n) => serde_json::json!(n),
|
||||||
|
domain::Value::String(s) => serde_json::Value::String(s.clone()),
|
||||||
|
domain::Value::Array(arr) => {
|
||||||
|
serde_json::Value::Array(arr.iter().map(domain_value_to_json).collect())
|
||||||
|
}
|
||||||
|
domain::Value::Object(obj) => {
|
||||||
|
let map = obj
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
|
||||||
|
.collect();
|
||||||
|
serde_json::Value::Object(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
|
use application::DataProjection;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use config_memory::MemoryConfigStore;
|
use config_memory::MemoryConfigStore;
|
||||||
use http_api::{AppState, router};
|
use http_api::{AppState, router};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tcp_server::TcpEventBus;
|
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
fn test_app() -> axum::Router {
|
fn test_app() -> axum::Router {
|
||||||
let config = Arc::new(MemoryConfigStore::new());
|
let state = AppState {
|
||||||
let events = Arc::new(TcpEventBus::new(16));
|
config: Arc::new(MemoryConfigStore::new()),
|
||||||
let state = AppState { config, events };
|
events: Arc::new(TcpEventBus::new(16)),
|
||||||
|
widget_states: Arc::new(DataProjection::new()),
|
||||||
|
broadcaster: Arc::new(TcpBroadcaster::new(16)),
|
||||||
|
clients: Arc::new(ClientTracker::new()),
|
||||||
|
spa_dir: None,
|
||||||
|
};
|
||||||
router(state)
|
router(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
48
crates/adapters/tcp-server/src/client_tracker.rs
Normal file
48
crates/adapters/tcp-server/src/client_tracker.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use domain::{ClientRegistry, ConnectedClient};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ClientTracker {
|
||||||
|
clients: Mutex<Vec<ConnectedClient>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientTracker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&self, addr: SocketAddr) {
|
||||||
|
let info = ConnectedClient {
|
||||||
|
addr: addr.to_string(),
|
||||||
|
connected_at: SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs(),
|
||||||
|
};
|
||||||
|
self.clients.lock().unwrap().push(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, addr: SocketAddr) {
|
||||||
|
let addr_str = addr.to_string();
|
||||||
|
self.clients.lock().unwrap().retain(|c| c.addr != addr_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientRegistry for ClientTracker {
|
||||||
|
fn add_client(&self, addr: &str, connected_at: u64) {
|
||||||
|
self.clients.lock().unwrap().push(ConnectedClient {
|
||||||
|
addr: addr.to_string(),
|
||||||
|
connected_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_client(&self, addr: &str) {
|
||||||
|
self.clients.lock().unwrap().retain(|c| c.addr != addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_clients(&self) -> Vec<ConnectedClient> {
|
||||||
|
self.clients.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
mod broadcaster;
|
mod broadcaster;
|
||||||
|
mod client_tracker;
|
||||||
mod error;
|
mod error;
|
||||||
mod event_bus;
|
mod event_bus;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
pub use broadcaster::TcpBroadcaster;
|
pub use broadcaster::TcpBroadcaster;
|
||||||
|
pub use client_tracker::ClientTracker;
|
||||||
pub use error::TcpServerError;
|
pub use error::TcpServerError;
|
||||||
pub use event_bus::TcpEventBus;
|
pub use event_bus::TcpEventBus;
|
||||||
pub use server::run_tcp_server;
|
pub use server::run_tcp_server;
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
use crate::broadcaster::TcpBroadcaster;
|
use crate::client_tracker::ClientTracker;
|
||||||
use crate::error::TcpServerError;
|
use crate::error::TcpServerError;
|
||||||
|
use domain::{ConfigRepository, WidgetStateReader};
|
||||||
|
use protocol::{ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode, encode};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing::{info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
pub async fn run_tcp_server(
|
use crate::broadcaster::TcpBroadcaster;
|
||||||
|
|
||||||
|
pub async fn run_tcp_server<C, W>(
|
||||||
addr: &str,
|
addr: &str,
|
||||||
broadcaster: Arc<TcpBroadcaster>,
|
broadcaster: Arc<TcpBroadcaster>,
|
||||||
) -> Result<(), TcpServerError> {
|
tracker: Arc<ClientTracker>,
|
||||||
|
config: Arc<C>,
|
||||||
|
widget_states: Arc<W>,
|
||||||
|
) -> Result<(), TcpServerError>
|
||||||
|
where
|
||||||
|
C: ConfigRepository + Send + Sync + 'static,
|
||||||
|
C::Error: std::fmt::Debug + Send,
|
||||||
|
W: WidgetStateReader + Send + Sync + 'static,
|
||||||
|
{
|
||||||
let listener = TcpListener::bind(addr).await.map_err(TcpServerError::Io)?;
|
let listener = TcpListener::bind(addr).await.map_err(TcpServerError::Io)?;
|
||||||
info!(addr, "TCP server listening");
|
info!(addr, "TCP server listening");
|
||||||
|
|
||||||
@@ -17,9 +29,21 @@ pub async fn run_tcp_server(
|
|||||||
let (mut socket, peer) = listener.accept().await.map_err(TcpServerError::Io)?;
|
let (mut socket, peer) = listener.accept().await.map_err(TcpServerError::Io)?;
|
||||||
info!(%peer, "client connected");
|
info!(%peer, "client connected");
|
||||||
|
|
||||||
|
tracker.add(peer);
|
||||||
|
let tracker = tracker.clone();
|
||||||
let mut rx = broadcaster.subscribe();
|
let mut rx = broadcaster.subscribe();
|
||||||
|
|
||||||
|
let initial_frame = build_initial_frame(&*config, &*widget_states).await;
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
if let Some(frame) = initial_frame
|
||||||
|
&& socket.write_all(&frame).await.is_err()
|
||||||
|
{
|
||||||
|
info!(%peer, "client disconnected during initial send");
|
||||||
|
tracker.remove(peer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
@@ -34,6 +58,56 @@ pub async fn run_tcp_server(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracker.remove(peer);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn build_initial_frame<C, W>(config: &C, widget_states: &W) -> Option<Vec<u8>>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
W: WidgetStateReader,
|
||||||
|
{
|
||||||
|
let layout = match config.get_layout().await {
|
||||||
|
Ok(Some(l)) => l,
|
||||||
|
Ok(None) => return None,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = ?e, "failed to fetch layout for initial send");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let widgets = match config.list_widgets().await {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = ?e, "failed to fetch widgets for initial send");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let wire_layout: WireLayoutNode = (&layout.root).into();
|
||||||
|
let mut wire_widgets = Vec::new();
|
||||||
|
for w in &widgets {
|
||||||
|
if let Some(s) = widget_states.get_widget_state(w.id).await {
|
||||||
|
wire_widgets.push(WidgetDescriptor {
|
||||||
|
id: w.id,
|
||||||
|
display_hint: WireDisplayHint::IconValue,
|
||||||
|
state: (&s).into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = ServerMessage::ScreenUpdate {
|
||||||
|
layout: wire_layout,
|
||||||
|
widgets: wire_widgets,
|
||||||
|
};
|
||||||
|
|
||||||
|
match encode(&msg) {
|
||||||
|
Ok(frame) => Some(frame),
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "failed to encode initial screen update");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
17
crates/api-types/src/client.rs
Normal file
17
crates/api-types/src/client.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use domain::ConnectedClient;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ClientDto {
|
||||||
|
pub addr: String,
|
||||||
|
pub connected_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ConnectedClient> for ClientDto {
|
||||||
|
fn from(c: &ConnectedClient) -> Self {
|
||||||
|
Self {
|
||||||
|
addr: c.addr.clone(),
|
||||||
|
connected_at: c.connected_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
pub mod client;
|
||||||
pub mod data_source;
|
pub mod data_source;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod preset;
|
pub mod preset;
|
||||||
pub mod widget;
|
pub mod widget;
|
||||||
|
|
||||||
|
pub use client::ClientDto;
|
||||||
pub use data_source::DataSourceDto;
|
pub use data_source::DataSourceDto;
|
||||||
pub use layout::{LayoutChildDto, LayoutDto, LayoutNodeDto, SizingDto};
|
pub use layout::{LayoutChildDto, LayoutDto, LayoutNodeDto, SizingDto};
|
||||||
pub use preset::{CreatePresetDto, PresetDto};
|
pub use preset::{CreatePresetDto, PresetDto};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
domain.workspace = true
|
domain.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
use domain::{DataSourceId, Value, WidgetConfig, WidgetId, WidgetState};
|
use domain::{DataSourceId, Value, WidgetConfig, WidgetId, WidgetState, WidgetStateReader};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct DataProjection {
|
pub struct DataProjection {
|
||||||
current: HashMap<WidgetId, WidgetState>,
|
current: Mutex<HashMap<WidgetId, WidgetState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DataProjection {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
current: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DataProjection {
|
impl DataProjection {
|
||||||
@@ -11,16 +19,17 @@ impl DataProjection {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_state(&self, widget_id: WidgetId) -> Option<&WidgetState> {
|
pub async fn get_state(&self, widget_id: WidgetId) -> Option<WidgetState> {
|
||||||
self.current.get(&widget_id)
|
self.current.lock().await.get(&widget_id).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_poll_result(
|
pub async fn apply_poll_result(
|
||||||
&mut self,
|
&self,
|
||||||
data_source_id: DataSourceId,
|
data_source_id: DataSourceId,
|
||||||
raw: &Value,
|
raw: &Value,
|
||||||
widget_configs: &[WidgetConfig],
|
widget_configs: &[WidgetConfig],
|
||||||
) -> Vec<(WidgetId, WidgetState)> {
|
) -> Vec<(WidgetId, WidgetState)> {
|
||||||
|
let mut current = self.current.lock().await;
|
||||||
let mut changed = Vec::new();
|
let mut changed = Vec::new();
|
||||||
|
|
||||||
for config in widget_configs {
|
for config in widget_configs {
|
||||||
@@ -30,13 +39,12 @@ impl DataProjection {
|
|||||||
|
|
||||||
let new_state = config.extract(raw);
|
let new_state = config.extract(raw);
|
||||||
|
|
||||||
let is_changed = self
|
let is_changed = current
|
||||||
.current
|
|
||||||
.get(&config.id)
|
.get(&config.id)
|
||||||
.is_none_or(|prev| *prev != new_state);
|
.is_none_or(|prev| *prev != new_state);
|
||||||
|
|
||||||
if is_changed {
|
if is_changed {
|
||||||
self.current.insert(config.id, new_state.clone());
|
current.insert(config.id, new_state.clone());
|
||||||
changed.push((config.id, new_state));
|
changed.push((config.id, new_state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,3 +52,18 @@ impl DataProjection {
|
|||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl WidgetStateReader for DataProjection {
|
||||||
|
async fn get_widget_state(&self, id: WidgetId) -> Option<WidgetState> {
|
||||||
|
self.get_state(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_raw_data(
|
||||||
|
&self,
|
||||||
|
source_id: u16,
|
||||||
|
raw: &Value,
|
||||||
|
widgets: &[WidgetConfig],
|
||||||
|
) -> Vec<(WidgetId, WidgetState)> {
|
||||||
|
self.apply_poll_result(source_id, raw, widgets).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use application::DataProjection;
|
use application::DataProjection;
|
||||||
use domain::{DisplayHint, KeyMapping, Value, WidgetConfig, WidgetId, WidgetState};
|
use domain::{DisplayHint, KeyMapping, Value, WidgetConfig};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
fn weather_widget() -> WidgetConfig {
|
fn weather_widget() -> WidgetConfig {
|
||||||
@@ -28,12 +28,14 @@ fn weather_response(temp: f64) -> Value {
|
|||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn apply_poll_result_detects_new_widget_state() {
|
async fn apply_poll_result_detects_new_widget_state() {
|
||||||
let mut projection = DataProjection::new();
|
let projection = DataProjection::new();
|
||||||
let widgets = vec![weather_widget()];
|
let widgets = vec![weather_widget()];
|
||||||
|
|
||||||
let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets);
|
let changed = projection
|
||||||
|
.apply_poll_result(10, &weather_response(5.4), &widgets)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(changed.len(), 1);
|
assert_eq!(changed.len(), 1);
|
||||||
assert_eq!(changed[0].0, 1);
|
assert_eq!(changed[0].0, 1);
|
||||||
@@ -43,24 +45,32 @@ fn apply_poll_result_detects_new_widget_state() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn apply_poll_result_returns_empty_when_nothing_changed() {
|
async fn apply_poll_result_returns_empty_when_nothing_changed() {
|
||||||
let mut projection = DataProjection::new();
|
let projection = DataProjection::new();
|
||||||
let widgets = vec![weather_widget()];
|
let widgets = vec![weather_widget()];
|
||||||
|
|
||||||
projection.apply_poll_result(10, &weather_response(5.4), &widgets);
|
projection
|
||||||
let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets);
|
.apply_poll_result(10, &weather_response(5.4), &widgets)
|
||||||
|
.await;
|
||||||
|
let changed = projection
|
||||||
|
.apply_poll_result(10, &weather_response(5.4), &widgets)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert!(changed.is_empty());
|
assert!(changed.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn apply_poll_result_detects_changed_value() {
|
async fn apply_poll_result_detects_changed_value() {
|
||||||
let mut projection = DataProjection::new();
|
let projection = DataProjection::new();
|
||||||
let widgets = vec![weather_widget()];
|
let widgets = vec![weather_widget()];
|
||||||
|
|
||||||
projection.apply_poll_result(10, &weather_response(5.4), &widgets);
|
projection
|
||||||
let changed = projection.apply_poll_result(10, &weather_response(6.1), &widgets);
|
.apply_poll_result(10, &weather_response(5.4), &widgets)
|
||||||
|
.await;
|
||||||
|
let changed = projection
|
||||||
|
.apply_poll_result(10, &weather_response(6.1), &widgets)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(changed.len(), 1);
|
assert_eq!(changed.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -69,9 +79,9 @@ fn apply_poll_result_detects_changed_value() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn apply_poll_result_only_updates_widgets_bound_to_source() {
|
async fn apply_poll_result_only_updates_widgets_bound_to_source() {
|
||||||
let mut projection = DataProjection::new();
|
let projection = DataProjection::new();
|
||||||
let widgets = vec![
|
let widgets = vec![
|
||||||
weather_widget(),
|
weather_widget(),
|
||||||
WidgetConfig::new(
|
WidgetConfig::new(
|
||||||
@@ -86,7 +96,9 @@ fn apply_poll_result_only_updates_widgets_bound_to_source() {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets);
|
let changed = projection
|
||||||
|
.apply_poll_result(10, &weather_response(5.4), &widgets)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(changed.len(), 1);
|
assert_eq!(changed.len(), 1);
|
||||||
assert_eq!(changed[0].0, 1);
|
assert_eq!(changed[0].0, 1);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub struct ServerConfig {
|
|||||||
pub tcp_addr: String,
|
pub tcp_addr: String,
|
||||||
pub http_addr: String,
|
pub http_addr: String,
|
||||||
pub poll_interval_secs: u64,
|
pub poll_interval_secs: u64,
|
||||||
|
pub spa_dir: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerConfig {
|
impl ServerConfig {
|
||||||
@@ -18,6 +19,7 @@ impl ServerConfig {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(5),
|
.unwrap_or(5),
|
||||||
|
spa_dir: env::var("KFRAME_SPA_DIR").ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ use config_sqlite::SqliteConfigStore;
|
|||||||
use domain::{BroadcastPort, ConfigRepository, DomainEvent};
|
use domain::{BroadcastPort, ConfigRepository, DomainEvent};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tcp_server::{TcpBroadcaster, TcpEventBus};
|
use tcp_server::{TcpBroadcaster, TcpEventBus};
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
event_bus: Arc<TcpEventBus>,
|
event_bus: Arc<TcpEventBus>,
|
||||||
config: Arc<SqliteConfigStore>,
|
config: Arc<SqliteConfigStore>,
|
||||||
broadcaster: Arc<TcpBroadcaster>,
|
broadcaster: Arc<TcpBroadcaster>,
|
||||||
projection: Arc<Mutex<DataProjection>>,
|
projection: Arc<DataProjection>,
|
||||||
) {
|
) {
|
||||||
let mut rx = event_bus.subscribe();
|
let mut rx = event_bus.subscribe();
|
||||||
|
|
||||||
@@ -25,11 +24,12 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let proj = projection.lock().await;
|
let mut widget_states = Vec::new();
|
||||||
let widget_states: Vec<_> = widgets
|
for w in &widgets {
|
||||||
.iter()
|
if let Some(s) = projection.get_state(w.id).await {
|
||||||
.filter_map(|w| proj.get_state(w.id).map(|s| (w.id, s.clone())))
|
widget_states.push((w.id, s));
|
||||||
.collect();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(e) = broadcaster
|
if let Err(e) = broadcaster
|
||||||
.push_screen_update(&layout, &widget_states)
|
.push_screen_update(&layout, &widget_states)
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ use application::DataProjection;
|
|||||||
use config_sqlite::SqliteConfigStore;
|
use config_sqlite::SqliteConfigStore;
|
||||||
use http_api::AppState;
|
use http_api::AppState;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tcp_server::{TcpBroadcaster, TcpEventBus, run_tcp_server};
|
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server};
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -29,12 +28,16 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let event_bus = Arc::new(TcpEventBus::new(64));
|
let event_bus = Arc::new(TcpEventBus::new(64));
|
||||||
let broadcaster = Arc::new(TcpBroadcaster::new(64));
|
let broadcaster = Arc::new(TcpBroadcaster::new(64));
|
||||||
let projection = Arc::new(Mutex::new(DataProjection::new()));
|
let projection = Arc::new(DataProjection::new());
|
||||||
|
let tracker = Arc::new(ClientTracker::new());
|
||||||
|
|
||||||
let tcp_addr = cfg.tcp_addr.clone();
|
let tcp_addr = cfg.tcp_addr.clone();
|
||||||
let tcp_bc = broadcaster.clone();
|
let tcp_bc = broadcaster.clone();
|
||||||
|
let tcp_tracker = tracker.clone();
|
||||||
|
let tcp_config = config_store.clone();
|
||||||
|
let tcp_proj = projection.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = run_tcp_server(&tcp_addr, tcp_bc).await {
|
if let Err(e) = run_tcp_server(&tcp_addr, tcp_bc, tcp_tracker, tcp_config, tcp_proj).await {
|
||||||
error!(error = %e, "tcp server failed");
|
error!(error = %e, "tcp server failed");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -44,6 +47,10 @@ async fn main() -> Result<()> {
|
|||||||
let http_state = AppState {
|
let http_state = AppState {
|
||||||
config: config_store.clone(),
|
config: config_store.clone(),
|
||||||
events: event_bus.clone(),
|
events: event_bus.clone(),
|
||||||
|
widget_states: projection.clone(),
|
||||||
|
broadcaster: broadcaster.clone(),
|
||||||
|
clients: tracker.clone(),
|
||||||
|
spa_dir: cfg.spa_dir,
|
||||||
};
|
};
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = http_api::serve(&http_addr, http_state).await {
|
if let Err(e) = http_api::serve(&http_addr, http_state).await {
|
||||||
|
|||||||
@@ -7,71 +7,135 @@ use domain::{
|
|||||||
use http_json::HttpJsonAdapter;
|
use http_json::HttpJsonAdapter;
|
||||||
use media_adapter::MediaAdapter;
|
use media_adapter::MediaAdapter;
|
||||||
use rss_adapter::RssAdapter;
|
use rss_adapter::RssAdapter;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tcp_server::TcpBroadcaster;
|
use tcp_server::TcpBroadcaster;
|
||||||
use tokio::sync::Mutex;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
config: Arc<SqliteConfigStore>,
|
config: Arc<SqliteConfigStore>,
|
||||||
broadcaster: Arc<TcpBroadcaster>,
|
broadcaster: Arc<TcpBroadcaster>,
|
||||||
projection: Arc<Mutex<DataProjection>>,
|
projection: Arc<DataProjection>,
|
||||||
poll_interval_secs: u64,
|
_poll_interval_secs: u64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let http_adapter = HttpJsonAdapter::new();
|
let http_adapter = Arc::new(HttpJsonAdapter::new());
|
||||||
let media_adapter = MediaAdapter::new();
|
let media_adapter = Arc::new(MediaAdapter::new());
|
||||||
let rss_adapter = RssAdapter::new();
|
let rss_adapter = Arc::new(RssAdapter::new());
|
||||||
let interval = Duration::from_secs(poll_interval_secs);
|
|
||||||
|
|
||||||
info!(interval_secs = poll_interval_secs, "polling loop started");
|
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
|
||||||
|
|
||||||
|
info!("polling manager started");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(interval).await;
|
|
||||||
|
|
||||||
let sources = config
|
let sources = config
|
||||||
.list_data_sources()
|
.list_data_sources()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
let widgets = config
|
|
||||||
.list_widgets()
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
||||||
let layout = config
|
|
||||||
.get_layout()
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
||||||
|
|
||||||
if sources.is_empty() || widgets.is_empty() {
|
let current_ids: Vec<u16> = sources.iter().map(|s| s.id).collect();
|
||||||
debug!("no sources or widgets configured, skipping poll");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut all_changed: Vec<(u16, WidgetState)> = Vec::new();
|
running.retain(|id, handle| {
|
||||||
|
if !current_ids.contains(id) {
|
||||||
|
info!(source_id = id, "stopping poll for removed source");
|
||||||
|
handle.abort();
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
for source in &sources {
|
for source in &sources {
|
||||||
let result =
|
if source.source_type == DataSourceType::Webhook {
|
||||||
match poll_source(&http_adapter, &media_adapter, &rss_adapter, source).await {
|
continue;
|
||||||
Ok(v) => v,
|
}
|
||||||
Err(e) => {
|
if running.contains_key(&source.id) {
|
||||||
warn!(source = %source.name, error = %e, "poll failed");
|
continue;
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut proj = projection.lock().await;
|
let source_id = source.id;
|
||||||
let changed = proj.apply_poll_result(source.id, &result, &widgets);
|
let source = source.clone();
|
||||||
all_changed.extend(changed);
|
let config = config.clone();
|
||||||
|
let broadcaster = broadcaster.clone();
|
||||||
|
let projection = projection.clone();
|
||||||
|
let http = http_adapter.clone();
|
||||||
|
let media = media_adapter.clone();
|
||||||
|
let rss = rss_adapter.clone();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
source_id = source.id,
|
||||||
|
name = %source.name,
|
||||||
|
interval_secs = source.poll_interval.as_secs(),
|
||||||
|
"starting poll task"
|
||||||
|
);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
poll_loop(source, config, broadcaster, projection, http, media, rss).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
running.insert(source_id, handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !all_changed.is_empty() {
|
if running.is_empty() {
|
||||||
if let Some(l) = &layout {
|
debug!("no pollable sources, waiting");
|
||||||
broadcaster
|
}
|
||||||
.push_screen_update(l, &all_changed)
|
|
||||||
.await
|
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll_loop(
|
||||||
|
source: DataSource,
|
||||||
|
config: Arc<SqliteConfigStore>,
|
||||||
|
broadcaster: Arc<TcpBroadcaster>,
|
||||||
|
projection: Arc<DataProjection>,
|
||||||
|
http_adapter: Arc<HttpJsonAdapter>,
|
||||||
|
media_adapter: Arc<MediaAdapter>,
|
||||||
|
rss_adapter: Arc<RssAdapter>,
|
||||||
|
) {
|
||||||
|
let interval = source.poll_interval;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
|
||||||
|
let result = match poll_source(&http_adapter, &media_adapter, &rss_adapter, &source).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(source = %source.name, error = %e, "poll failed");
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
info!(count = all_changed.len(), "pushed widget updates");
|
};
|
||||||
|
|
||||||
|
let widgets = match config.list_widgets().await {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "failed to fetch widgets");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let layout = match config.get_layout().await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "failed to fetch layout");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let changed: Vec<(u16, WidgetState)> = projection
|
||||||
|
.apply_poll_result(source.id, &result, &widgets)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if !changed.is_empty() {
|
||||||
|
if let Some(l) = &layout
|
||||||
|
&& let Err(e) = broadcaster.push_screen_update(l, &changed).await
|
||||||
|
{
|
||||||
|
warn!(error = %e, "failed to push update");
|
||||||
|
}
|
||||||
|
info!(source = %source.name, count = changed.len(), "pushed widget updates");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ pub fn run(
|
|||||||
loop {
|
loop {
|
||||||
match rx.recv_timeout(RENDER_POLL_INTERVAL) {
|
match rx.recv_timeout(RENDER_POLL_INTERVAL) {
|
||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
|
let is_screen_update = matches!(msg, ServerMessage::ScreenUpdate { .. });
|
||||||
let repaints = app.handle_message(msg);
|
let repaints = app.handle_message(msg);
|
||||||
|
|
||||||
if !repaints.is_empty() && first_update {
|
if !repaints.is_empty() && (first_update || is_screen_update) {
|
||||||
display.fill_background(SCREEN).unwrap();
|
display.fill_background(SCREEN).unwrap();
|
||||||
first_update = false;
|
first_update = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ pub use entities::{
|
|||||||
LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
pub use events::DomainEvent;
|
pub use events::DomainEvent;
|
||||||
pub use ports::{BroadcastPort, ConfigRepository, DataSourcePort, EventPublisher};
|
pub use ports::{
|
||||||
|
BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
|
||||||
|
EventPublisher, WidgetStateReader,
|
||||||
|
};
|
||||||
pub use value_objects::{
|
pub use value_objects::{
|
||||||
ContainerNode, Direction, DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode,
|
ContainerNode, Direction, DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode,
|
||||||
LayoutValidationError, Sizing, Value, WidgetError, WidgetState,
|
LayoutValidationError, Sizing, Value, WidgetError, WidgetState,
|
||||||
|
|||||||
11
crates/domain/src/ports/client_registry.rs
Normal file
11
crates/domain/src/ports/client_registry.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ConnectedClient {
|
||||||
|
pub addr: String,
|
||||||
|
pub connected_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ClientRegistry {
|
||||||
|
fn add_client(&self, addr: &str, connected_at: u64);
|
||||||
|
fn remove_client(&self, addr: &str);
|
||||||
|
fn list_clients(&self) -> Vec<ConnectedClient>;
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
mod broadcast;
|
mod broadcast;
|
||||||
|
mod client_registry;
|
||||||
mod config_repository;
|
mod config_repository;
|
||||||
mod data_source_port;
|
mod data_source_port;
|
||||||
mod event;
|
mod event;
|
||||||
|
mod widget_state_reader;
|
||||||
|
|
||||||
pub use broadcast::BroadcastPort;
|
pub use broadcast::BroadcastPort;
|
||||||
|
pub use client_registry::{ClientRegistry, ConnectedClient};
|
||||||
pub use config_repository::ConfigRepository;
|
pub use config_repository::ConfigRepository;
|
||||||
pub use data_source_port::DataSourcePort;
|
pub use data_source_port::DataSourcePort;
|
||||||
pub use event::EventPublisher;
|
pub use event::EventPublisher;
|
||||||
|
pub use widget_state_reader::WidgetStateReader;
|
||||||
|
|||||||
14
crates/domain/src/ports/widget_state_reader.rs
Normal file
14
crates/domain/src/ports/widget_state_reader.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use crate::entities::WidgetId;
|
||||||
|
use crate::value_objects::WidgetState;
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
pub trait WidgetStateReader {
|
||||||
|
fn get_widget_state(&self, id: WidgetId) -> impl Future<Output = Option<WidgetState>> + Send;
|
||||||
|
|
||||||
|
fn apply_raw_data(
|
||||||
|
&self,
|
||||||
|
source_id: u16,
|
||||||
|
raw: &crate::value_objects::Value,
|
||||||
|
widgets: &[crate::entities::WidgetConfig],
|
||||||
|
) -> impl Future<Output = Vec<(WidgetId, WidgetState)>> + Send;
|
||||||
|
}
|
||||||
11
spa/src/api/clients.ts
Normal file
11
spa/src/api/clients.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { api } from "./client"
|
||||||
|
import type { ClientInfo } from "./types"
|
||||||
|
|
||||||
|
export function useClients() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["clients"],
|
||||||
|
queryFn: () => api.get<ClientInfo[]>("/clients"),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -57,3 +57,8 @@ export interface Preset {
|
|||||||
name: string
|
name: string
|
||||||
layout: Layout
|
layout: Layout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClientInfo {
|
||||||
|
addr: string
|
||||||
|
connected_at: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ export function useUpdateWidget() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useWidgetPreview(id: number, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["widget-preview", id],
|
||||||
|
queryFn: () => api.get<Record<string, unknown>>(`/widgets/${id}/preview`),
|
||||||
|
enabled,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteWidget() {
|
export function useDeleteWidget() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Layers,
|
Layers,
|
||||||
Save,
|
Save,
|
||||||
|
BookOpen,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
@@ -26,6 +27,7 @@ const NAV = [
|
|||||||
{ to: "/widgets", label: "Widgets", icon: Box },
|
{ to: "/widgets", label: "Widgets", icon: Box },
|
||||||
{ to: "/layout", label: "Layout", icon: Layers },
|
{ to: "/layout", label: "Layout", icon: Layers },
|
||||||
{ to: "/presets", label: "Presets", icon: Save },
|
{ to: "/presets", label: "Presets", icon: Save },
|
||||||
|
{ to: "/guide", label: "Guide", icon: BookOpen },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -9,11 +9,24 @@ import { useDataSources } from "@/api/data-sources"
|
|||||||
import { useWidgets } from "@/api/widgets"
|
import { useWidgets } from "@/api/widgets"
|
||||||
import { useLayout } from "@/api/layout"
|
import { useLayout } from "@/api/layout"
|
||||||
import { usePresets } from "@/api/presets"
|
import { usePresets } from "@/api/presets"
|
||||||
import { Activity, Box, Layers, Database } from "lucide-react"
|
import { useClients } from "@/api/clients"
|
||||||
|
import { Activity, Box, Layers, Database, Monitor } from "lucide-react"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
function countNodes(node: { children?: { node: unknown }[] }): number {
|
function countNodes(node: { children?: { node: unknown }[] }): number {
|
||||||
if (!node.children) return 1
|
if (!node.children) return 1
|
||||||
return 1 + node.children.reduce((sum, c) => sum + countNodes(c.node as typeof node), 0)
|
return (
|
||||||
|
1 +
|
||||||
|
node.children.reduce(
|
||||||
|
(sum, c) => sum + countNodes(c.node as typeof node),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConnectedAt(ts: number): string {
|
||||||
|
const d = new Date(ts * 1000)
|
||||||
|
return d.toLocaleTimeString()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
@@ -21,8 +34,15 @@ export function DashboardPage() {
|
|||||||
const widgets = useWidgets()
|
const widgets = useWidgets()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const presets = usePresets()
|
const presets = usePresets()
|
||||||
|
const clients = useClients()
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
|
{
|
||||||
|
label: "Clients",
|
||||||
|
value: clients.data?.length ?? "—",
|
||||||
|
icon: Monitor,
|
||||||
|
desc: "Connected displays",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Data Sources",
|
label: "Data Sources",
|
||||||
value: sources.data?.length ?? "—",
|
value: sources.data?.length ?? "—",
|
||||||
@@ -56,7 +76,7 @@ export function DashboardPage() {
|
|||||||
<p className="text-muted-foreground text-sm">K-Frame system overview</p>
|
<p className="text-muted-foreground text-sm">K-Frame system overview</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
{stats.map((s) => (
|
{stats.map((s) => (
|
||||||
<Card key={s.label}>
|
<Card key={s.label}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
@@ -70,6 +90,29 @@ export function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{clients.data && clients.data.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Connected Clients</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{clients.data.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.addr}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs">{c.addr}</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
since {formatConnectedAt(c.connected_at)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,15 @@ export function DataSourcesPage() {
|
|||||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={save} disabled={!editing?.name}>
|
<Button
|
||||||
|
onClick={save}
|
||||||
|
disabled={
|
||||||
|
!editing?.name ||
|
||||||
|
(editing.source_type !== "webhook" &&
|
||||||
|
editing.poll_interval_secs <= 0) ||
|
||||||
|
(editing.source_type !== "webhook" && !editing.url)
|
||||||
|
}
|
||||||
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
350
spa/src/pages/guide.tsx
Normal file
350
spa/src/pages/guide.tsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
export function GuidePage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Guide</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
How to set up K-Frame from scratch
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>How K-Frame Works</CardTitle>
|
||||||
|
<CardDescription>The data pipeline at a glance</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
K-Frame is an IoT dashboard system. The server polls external data
|
||||||
|
sources (weather APIs, Navidrome, RSS feeds, etc.), extracts values,
|
||||||
|
and pushes them to connected display clients (ESP32 screens) over
|
||||||
|
TCP.
|
||||||
|
</p>
|
||||||
|
<div className="bg-muted rounded-md p-3 font-mono text-xs">
|
||||||
|
Data Source → poll → raw JSON → Widget mappings → Widget State →
|
||||||
|
Layout → ESP32 display
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
You configure everything through this UI. Changes to layout are
|
||||||
|
pushed to clients immediately.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step 1 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Step 1: Add a Data Source</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Where to pull data from
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
Go to <strong>Data Sources → Add Source</strong>. A data source is
|
||||||
|
an external feed that the server polls at a regular interval.
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="py-2 pr-4 font-medium">Field</th>
|
||||||
|
<th className="py-2 font-medium">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-muted-foreground">
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">name</td>
|
||||||
|
<td className="py-2">Human-readable label (e.g. "weather", "navidrome")</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">source_type</td>
|
||||||
|
<td className="py-2">Determines which adapter handles polling (see reference below)</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">url</td>
|
||||||
|
<td className="py-2">Base URL of the API to poll</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">api_key</td>
|
||||||
|
<td className="py-2">Optional API key (masked in the UI)</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">poll_interval</td>
|
||||||
|
<td className="py-2">How often to fetch data, in seconds</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">headers</td>
|
||||||
|
<td className="py-2">
|
||||||
|
Key-value pairs for authentication or custom config. For
|
||||||
|
example, the media adapter reads{" "}
|
||||||
|
<code className="bg-muted rounded px-1">username</code> and{" "}
|
||||||
|
<code className="bg-muted rounded px-1">password</code> from
|
||||||
|
headers
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step 2 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Step 2: Create a Widget</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Extract and name the data you want to display
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
Go to <strong>Widgets → Add Widget</strong>. A widget is a display
|
||||||
|
primitive — it extracts specific values from a data source's raw
|
||||||
|
response and gives them names the client can render.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-medium">Display Hints</h4>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge variant="secondary">icon_value</Badge>
|
||||||
|
<Badge variant="secondary">text_block</Badge>
|
||||||
|
<Badge variant="secondary">key_value</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Hints tell the client how to render the widget. The client decides
|
||||||
|
the actual visual treatment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<h4 className="font-medium">Key Mappings</h4>
|
||||||
|
<p>
|
||||||
|
Mappings define how to extract data from the raw API response.
|
||||||
|
Each mapping has two fields:
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="py-2 pr-4 font-medium">Field</th>
|
||||||
|
<th className="py-2 font-medium">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-muted-foreground">
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">source_path</td>
|
||||||
|
<td className="py-2">
|
||||||
|
JSONPath expression into the raw response (e.g.{" "}
|
||||||
|
<code className="bg-muted rounded px-1">$.title</code>,{" "}
|
||||||
|
<code className="bg-muted rounded px-1">$.main.temp</code>,{" "}
|
||||||
|
<code className="bg-muted rounded px-1">$.weather[0].icon</code>)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">target_key</td>
|
||||||
|
<td className="py-2">
|
||||||
|
The name the extracted value gets in the widget's state.
|
||||||
|
This is what the client sees (e.g.{" "}
|
||||||
|
<code className="bg-muted rounded px-1">value</code>,{" "}
|
||||||
|
<code className="bg-muted rounded px-1">label</code>,{" "}
|
||||||
|
<code className="bg-muted rounded px-1">icon</code>)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="bg-muted rounded-md p-3 font-mono text-xs">
|
||||||
|
Example: Navidrome "now playing"<br />
|
||||||
|
<span className="text-muted-foreground">source_path</span> $.title → <span className="text-muted-foreground">target_key</span> value<br />
|
||||||
|
<span className="text-muted-foreground">source_path</span> $.artist → <span className="text-muted-foreground">target_key</span> label
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step 3 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Step 3: Build a Layout</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Arrange widgets on the display
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
Go to <strong>Layout</strong>. The layout is a recursive tree of
|
||||||
|
containers and widgets.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="font-medium">Node Types</h4>
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="py-2 pr-4 font-medium">Type</th>
|
||||||
|
<th className="py-2 font-medium">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-muted-foreground">
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4">Container (row)</td>
|
||||||
|
<td className="py-2">Children laid out horizontally</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4">Container (column)</td>
|
||||||
|
<td className="py-2">Children laid out vertically</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4">Leaf (widget)</td>
|
||||||
|
<td className="py-2">Renders a specific widget's data</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h4 className="font-medium">Sizing</h4>
|
||||||
|
<p>Each child in a container has a sizing mode:</p>
|
||||||
|
<ul className="text-muted-foreground list-inside list-disc space-y-1">
|
||||||
|
<li>
|
||||||
|
<strong>Flex(weight)</strong> — proportional share of remaining
|
||||||
|
space. Two children with flex 1 and 2 get 1/3 and 2/3 of the
|
||||||
|
space.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Fixed(pixels)</strong> — exact pixel width (in rows) or
|
||||||
|
height (in columns).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 className="font-medium">Gap & Padding</h4>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
<strong>Gap</strong> adds uniform spacing between children.{" "}
|
||||||
|
<strong>Padding</strong> insets the container's content area on all
|
||||||
|
sides. Both are in pixels. Typically use padding on the root
|
||||||
|
container to keep content off screen edges.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step 4 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Step 4: Save & Push</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Changes go live immediately
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
Click <strong>Save Layout</strong> to persist the layout and push
|
||||||
|
it to all connected clients immediately. The ESP32 will re-render
|
||||||
|
with the new layout without needing a restart.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Data updates are pushed automatically whenever a poll detects
|
||||||
|
changed values — no action needed from you.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Presets</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Save and restore layout snapshots
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
Go to <strong>Presets</strong> to save the current layout as a
|
||||||
|
named preset. You can load a preset later to restore its layout,
|
||||||
|
or delete presets you no longer need.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Loading a preset replaces the active layout and pushes to clients
|
||||||
|
immediately.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Source Types Reference */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Source Type Reference</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
What each adapter expects
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="py-2 pr-4 font-medium">Type</th>
|
||||||
|
<th className="py-2 pr-4 font-medium">Direction</th>
|
||||||
|
<th className="py-2 font-medium">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-muted-foreground">
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">weather</td>
|
||||||
|
<td className="py-2 pr-4">poll</td>
|
||||||
|
<td className="py-2">
|
||||||
|
OpenWeather-compatible. Set URL with API key in query params.
|
||||||
|
Returns nested JSON — use paths like{" "}
|
||||||
|
<code className="bg-muted rounded px-1">$.main.temp</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">media</td>
|
||||||
|
<td className="py-2 pr-4">poll</td>
|
||||||
|
<td className="py-2">
|
||||||
|
Navidrome/Subsonic. Set base URL, add{" "}
|
||||||
|
<code className="bg-muted rounded px-1">username</code> and{" "}
|
||||||
|
<code className="bg-muted rounded px-1">password</code>{" "}
|
||||||
|
as headers. Returns{" "}
|
||||||
|
<code className="bg-muted rounded px-1">$.playing</code>,{" "}
|
||||||
|
<code className="bg-muted rounded px-1">$.title</code>,{" "}
|
||||||
|
<code className="bg-muted rounded px-1">$.artist</code>,{" "}
|
||||||
|
<code className="bg-muted rounded px-1">$.album</code>,{" "}
|
||||||
|
<code className="bg-muted rounded px-1">$.duration</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">rss</td>
|
||||||
|
<td className="py-2 pr-4">poll</td>
|
||||||
|
<td className="py-2">
|
||||||
|
Any RSS feed URL. Returns{" "}
|
||||||
|
<code className="bg-muted rounded px-1">$.title</code>,{" "}
|
||||||
|
<code className="bg-muted rounded px-1">$.items</code> (array
|
||||||
|
of items with title, link, description)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">http_json</td>
|
||||||
|
<td className="py-2 pr-4">poll</td>
|
||||||
|
<td className="py-2">
|
||||||
|
Generic — polls any URL, returns raw JSON. Use JSONPath in
|
||||||
|
mappings to extract what you need.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">webhook</td>
|
||||||
|
<td className="py-2 pr-4">push</td>
|
||||||
|
<td className="py-2">
|
||||||
|
Receives incoming HTTP POSTs. Poll interval must be 0.
|
||||||
|
Not yet wired in the polling loop.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,16 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -110,6 +120,7 @@ export function LayoutBuilderPage() {
|
|||||||
const [root, setRoot] = useState<LayoutNode | null>(null)
|
const [root, setRoot] = useState<LayoutNode | null>(null)
|
||||||
const [selected, setSelected] = useState<Path | null>(null)
|
const [selected, setSelected] = useState<Path | null>(null)
|
||||||
const [initialized, setInitialized] = useState(false)
|
const [initialized, setInitialized] = useState(false)
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<Path | null>(null)
|
||||||
|
|
||||||
if (!initialized && currentLayout?.root) {
|
if (!initialized && currentLayout?.root) {
|
||||||
setRoot(structuredClone(currentLayout.root))
|
setRoot(structuredClone(currentLayout.root))
|
||||||
@@ -255,7 +266,7 @@ export function LayoutBuilderPage() {
|
|||||||
onAddContainer={(path, dir) =>
|
onAddContainer={(path, dir) =>
|
||||||
addChild(path, makeContainerChild(dir))
|
addChild(path, makeContainerChild(dir))
|
||||||
}
|
}
|
||||||
onRemove={() => removeChild(selected)}
|
onRemove={() => setPendingDelete(selected)}
|
||||||
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
||||||
isRoot={selected.length === 0}
|
isRoot={selected.length === 0}
|
||||||
widgets={widgets}
|
widgets={widgets}
|
||||||
@@ -264,7 +275,7 @@ export function LayoutBuilderPage() {
|
|||||||
<LeafProps
|
<LeafProps
|
||||||
path={selected}
|
path={selected}
|
||||||
widgetId={selectedNode?.widget_id ?? 0}
|
widgetId={selectedNode?.widget_id ?? 0}
|
||||||
onRemove={() => removeChild(selected)}
|
onRemove={() => setPendingDelete(selected)}
|
||||||
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
||||||
widgets={widgets}
|
widgets={widgets}
|
||||||
sizing={
|
sizing={
|
||||||
@@ -284,6 +295,39 @@ export function LayoutBuilderPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={pendingDelete !== null}
|
||||||
|
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{pendingDelete?.length === 0
|
||||||
|
? "Clear entire layout?"
|
||||||
|
: "Remove this node?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{pendingDelete?.length === 0
|
||||||
|
? "This will remove the entire layout tree. You can rebuild it afterward."
|
||||||
|
: "This will remove the selected node and all its children."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingDelete !== null) {
|
||||||
|
removeChild(pendingDelete)
|
||||||
|
setPendingDelete(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pendingDelete?.length === 0 ? "Clear" : "Remove"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
useCreateWidget,
|
useCreateWidget,
|
||||||
useUpdateWidget,
|
useUpdateWidget,
|
||||||
useDeleteWidget,
|
useDeleteWidget,
|
||||||
|
useWidgetPreview,
|
||||||
} from "@/api/widgets"
|
} from "@/api/widgets"
|
||||||
import { useDataSources } from "@/api/data-sources"
|
import { useDataSources } from "@/api/data-sources"
|
||||||
import type { Widget, DisplayHint, KeyMapping } from "@/api/types"
|
import type { Widget, DisplayHint, KeyMapping } from "@/api/types"
|
||||||
@@ -42,7 +43,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Plus, Pencil, Trash2, X } from "lucide-react"
|
import { Plus, Pencil, Trash2, X, Eye } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
const DISPLAY_HINTS: DisplayHint[] = ["icon_value", "text_block", "key_value"]
|
const DISPLAY_HINTS: DisplayHint[] = ["icon_value", "text_block", "key_value"]
|
||||||
@@ -66,6 +67,7 @@ export function WidgetsPage() {
|
|||||||
|
|
||||||
const [editing, setEditing] = useState<Widget | null>(null)
|
const [editing, setEditing] = useState<Widget | null>(null)
|
||||||
const [deleting, setDeleting] = useState<number | null>(null)
|
const [deleting, setDeleting] = useState<number | null>(null)
|
||||||
|
const [previewing, setPreviewing] = useState<number | null>(null)
|
||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
const nextId =
|
const nextId =
|
||||||
@@ -145,6 +147,14 @@ export function WidgetsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setPreviewing(w.id)}
|
||||||
|
title="Preview current data"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -186,7 +196,14 @@ export function WidgetsPage() {
|
|||||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={save} disabled={!editing?.name}>
|
<Button
|
||||||
|
onClick={save}
|
||||||
|
disabled={
|
||||||
|
!editing?.name ||
|
||||||
|
!editing.data_source_id ||
|
||||||
|
editing.mappings.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -213,10 +230,59 @@ export function WidgetsPage() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{previewing != null && (
|
||||||
|
<WidgetPreviewDialog
|
||||||
|
widgetId={previewing}
|
||||||
|
widgetName={widgets.find((w) => w.id === previewing)?.name ?? ""}
|
||||||
|
onClose={() => setPreviewing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WidgetPreviewDialog({
|
||||||
|
widgetId,
|
||||||
|
widgetName,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
widgetId: number
|
||||||
|
widgetName: string
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { data, isLoading, isError } = useWidgetPreview(widgetId, true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Preview: {widgetName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
{isLoading && (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No data yet — widget hasn't been polled
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data && (
|
||||||
|
<pre className="bg-muted rounded-md p-3 text-xs">
|
||||||
|
{JSON.stringify(data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function WidgetForm({
|
function WidgetForm({
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DataSourcesPage } from "@/pages/data-sources"
|
|||||||
import { WidgetsPage } from "@/pages/widgets"
|
import { WidgetsPage } from "@/pages/widgets"
|
||||||
import { LayoutBuilderPage } from "@/pages/layout-builder"
|
import { LayoutBuilderPage } from "@/pages/layout-builder"
|
||||||
import { PresetsPage } from "@/pages/presets"
|
import { PresetsPage } from "@/pages/presets"
|
||||||
|
import { GuidePage } from "@/pages/guide"
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
@@ -49,12 +50,19 @@ const presetsRoute = createRoute({
|
|||||||
component: PresetsPage,
|
component: PresetsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const guideRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/guide",
|
||||||
|
component: GuidePage,
|
||||||
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
dataSourcesRoute,
|
dataSourcesRoute,
|
||||||
widgetsRoute,
|
widgetsRoute,
|
||||||
layoutRoute,
|
layoutRoute,
|
||||||
presetsRoute,
|
presetsRoute,
|
||||||
|
guideRoute,
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|||||||
Reference in New Issue
Block a user