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:
2026-06-19 00:42:31 +02:00
parent 26ebfad3a2
commit 1d7b5324d6
39 changed files with 1232 additions and 158 deletions

31
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -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?;

View 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(),
)
}

View File

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

View File

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

View File

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

View File

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

View 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)
}
}
}

View File

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

View File

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

View 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()
}
}

View File

@@ -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;

View File

@@ -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
}
}
}

View 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,
}
}
}

View File

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

View File

@@ -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 }

View File

@@ -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
}
}

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View 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>;
}

View File

@@ -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;

View 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
View 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,
})
}

View File

@@ -57,3 +57,8 @@ export interface Preset {
name: string name: string
layout: Layout layout: Layout
} }
export interface ClientInfo {
addr: string
connected_at: number
}

View File

@@ -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({

View File

@@ -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 }) {

View File

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

View File

@@ -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
View 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>
)
}

View File

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

View File

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

View File

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