add SPA config UI, wire media/rss adapters, event-driven layout push
- React SPA: dashboard, data sources CRUD, widgets CRUD, layout builder, presets. TanStack Router + Query, shadcn/ui, Vite proxy to :3000 - wire media + rss adapters into polling loop, remove xtb source type - media adapter: read username/password from headers, proper subsonic auth - event handler: subscribe to LayoutChanged, push screen update to clients - fix clippy warnings across workspace (Default impls, collapsible ifs, redundant closures, is_none_or, unused imports)
This commit is contained in:
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -166,6 +166,8 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"http-api",
|
"http-api",
|
||||||
"http-json",
|
"http-json",
|
||||||
|
"media-adapter",
|
||||||
|
"rss-adapter",
|
||||||
"tcp-server",
|
"tcp-server",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -1102,12 +1104,20 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md5"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "media-adapter"
|
name = "media-adapter"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"domain",
|
"domain",
|
||||||
|
"fastrand",
|
||||||
|
"md5",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ display-terminal = { path = "crates/adapters/display-terminal" }
|
|||||||
config-sqlite = { path = "crates/adapters/config-sqlite" }
|
config-sqlite = { path = "crates/adapters/config-sqlite" }
|
||||||
http-json = { path = "crates/adapters/http-json" }
|
http-json = { path = "crates/adapters/http-json" }
|
||||||
http-api = { path = "crates/adapters/http-api" }
|
http-api = { path = "crates/adapters/http-api" }
|
||||||
|
media-adapter = { path = "crates/adapters/media" }
|
||||||
|
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"] }
|
||||||
api-types = { path = "crates/api-types" }
|
api-types = { path = "crates/api-types" }
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
use domain::{
|
||||||
|
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig,
|
||||||
|
WidgetId,
|
||||||
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
use domain::{
|
|
||||||
ConfigRepository,
|
|
||||||
DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId,
|
|
||||||
WidgetConfig, WidgetId,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum MemoryConfigError {
|
pub enum MemoryConfigError {
|
||||||
@@ -19,8 +18,8 @@ pub struct MemoryConfigStore {
|
|||||||
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
|
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryConfigStore {
|
impl Default for MemoryConfigStore {
|
||||||
pub fn new() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
widgets: RwLock::new(HashMap::new()),
|
widgets: RwLock::new(HashMap::new()),
|
||||||
data_sources: RwLock::new(HashMap::new()),
|
data_sources: RwLock::new(HashMap::new()),
|
||||||
@@ -30,82 +29,130 @@ impl MemoryConfigStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl MemoryConfigStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ConfigRepository for MemoryConfigStore {
|
impl ConfigRepository for MemoryConfigStore {
|
||||||
type Error = MemoryConfigError;
|
type Error = MemoryConfigError;
|
||||||
|
|
||||||
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> {
|
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> {
|
||||||
let guard = self.widgets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let guard = self
|
||||||
|
.widgets
|
||||||
|
.read()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
Ok(guard.get(&id).cloned())
|
Ok(guard.get(&id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
|
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
|
||||||
let guard = self.widgets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let guard = self
|
||||||
|
.widgets
|
||||||
|
.read()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
Ok(guard.values().cloned().collect())
|
Ok(guard.values().cloned().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
|
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
|
||||||
let mut guard = self.widgets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let mut guard = self
|
||||||
|
.widgets
|
||||||
|
.write()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
guard.insert(config.id, config.clone());
|
guard.insert(config.id, config.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
|
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
|
||||||
let mut guard = self.widgets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let mut guard = self
|
||||||
|
.widgets
|
||||||
|
.write()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
guard.remove(&id);
|
guard.remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
|
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
|
||||||
let guard = self.data_sources.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let guard = self
|
||||||
|
.data_sources
|
||||||
|
.read()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
Ok(guard.get(&id).cloned())
|
Ok(guard.get(&id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
|
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
|
||||||
let guard = self.data_sources.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let guard = self
|
||||||
|
.data_sources
|
||||||
|
.read()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
Ok(guard.values().cloned().collect())
|
Ok(guard.values().cloned().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
|
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
|
||||||
let mut guard = self.data_sources.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let mut guard = self
|
||||||
|
.data_sources
|
||||||
|
.write()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
guard.insert(source.id, source.clone());
|
guard.insert(source.id, source.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
|
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
|
||||||
let mut guard = self.data_sources.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let mut guard = self
|
||||||
|
.data_sources
|
||||||
|
.write()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
guard.remove(&id);
|
guard.remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
|
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
|
||||||
let guard = self.layout.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let guard = self
|
||||||
|
.layout
|
||||||
|
.read()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
Ok(guard.clone())
|
Ok(guard.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
|
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
|
||||||
let mut guard = self.layout.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let mut guard = self
|
||||||
|
.layout
|
||||||
|
.write()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
*guard = Some(layout.clone());
|
*guard = Some(layout.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
||||||
let guard = self.presets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let guard = self
|
||||||
|
.presets
|
||||||
|
.read()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
Ok(guard.get(&id).cloned())
|
Ok(guard.get(&id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
|
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
|
||||||
let guard = self.presets.read().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let guard = self
|
||||||
|
.presets
|
||||||
|
.read()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
Ok(guard.values().cloned().collect())
|
Ok(guard.values().cloned().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
|
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
|
||||||
let mut guard = self.presets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let mut guard = self
|
||||||
|
.presets
|
||||||
|
.write()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
guard.insert(preset.id, preset.clone());
|
guard.insert(preset.id, preset.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
||||||
let mut guard = self.presets.write().map_err(|_| MemoryConfigError::LockPoisoned)?;
|
let mut guard = self
|
||||||
|
.presets
|
||||||
|
.write()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
guard.remove(&id);
|
guard.remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
mod serialization;
|
|
||||||
mod repository;
|
mod repository;
|
||||||
|
mod serialization;
|
||||||
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
@@ -27,8 +27,10 @@ impl SqliteConfigStore {
|
|||||||
data_source_id INTEGER NOT NULL,
|
data_source_id INTEGER NOT NULL,
|
||||||
mappings TEXT NOT NULL,
|
mappings TEXT NOT NULL,
|
||||||
max_data_size INTEGER NOT NULL
|
max_data_size INTEGER NOT NULL
|
||||||
)"
|
)",
|
||||||
).execute(&self.pool).await?;
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS data_sources (
|
"CREATE TABLE IF NOT EXISTS data_sources (
|
||||||
@@ -37,23 +39,29 @@ impl SqliteConfigStore {
|
|||||||
source_type TEXT NOT NULL,
|
source_type TEXT NOT NULL,
|
||||||
poll_interval_secs INTEGER NOT NULL,
|
poll_interval_secs INTEGER NOT NULL,
|
||||||
config TEXT NOT NULL
|
config TEXT NOT NULL
|
||||||
)"
|
)",
|
||||||
).execute(&self.pool).await?;
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS layout (
|
"CREATE TABLE IF NOT EXISTS layout (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
data TEXT NOT NULL
|
data TEXT NOT NULL
|
||||||
)"
|
)",
|
||||||
).execute(&self.pool).await?;
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS presets (
|
"CREATE TABLE IF NOT EXISTS presets (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
layout_data TEXT NOT NULL
|
layout_data TEXT NOT NULL
|
||||||
)"
|
)",
|
||||||
).execute(&self.pool).await?;
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
use domain::{DataSource, DataSourceId};
|
|
||||||
use crate::SqliteConfigStore;
|
use crate::SqliteConfigStore;
|
||||||
use crate::error::SqliteConfigError;
|
use crate::error::SqliteConfigError;
|
||||||
use crate::serialization::data_source as ser;
|
use crate::serialization::data_source as ser;
|
||||||
|
use domain::{DataSource, DataSourceId};
|
||||||
|
|
||||||
impl SqliteConfigStore {
|
impl SqliteConfigStore {
|
||||||
pub(crate) async fn get_data_source_impl(&self, id: DataSourceId) -> Result<Option<DataSource>, SqliteConfigError> {
|
pub(crate) async fn get_data_source_impl(
|
||||||
|
&self,
|
||||||
|
id: DataSourceId,
|
||||||
|
) -> Result<Option<DataSource>, SqliteConfigError> {
|
||||||
let row = sqlx::query("SELECT * FROM data_sources WHERE id = ?")
|
let row = sqlx::query("SELECT * FROM data_sources WHERE id = ?")
|
||||||
.bind(id as i64)
|
.bind(id as i64)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -17,16 +20,21 @@ impl SqliteConfigStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn list_data_sources_impl(&self) -> Result<Vec<DataSource>, SqliteConfigError> {
|
pub(crate) async fn list_data_sources_impl(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<DataSource>, SqliteConfigError> {
|
||||||
let rows = sqlx::query("SELECT * FROM data_sources")
|
let rows = sqlx::query("SELECT * FROM data_sources")
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(SqliteConfigError::Sql)?;
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
rows.iter().map(|r| ser::data_source_from_row(r)).collect()
|
rows.iter().map(ser::data_source_from_row).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn save_data_source_impl(&self, source: &DataSource) -> Result<(), SqliteConfigError> {
|
pub(crate) async fn save_data_source_impl(
|
||||||
|
&self,
|
||||||
|
source: &DataSource,
|
||||||
|
) -> Result<(), SqliteConfigError> {
|
||||||
let config_json = ser::data_source_config_to_json(&source.config)?;
|
let config_json = ser::data_source_config_to_json(&source.config)?;
|
||||||
let type_str = ser::data_source_type_to_str(&source.source_type);
|
let type_str = ser::data_source_type_to_str(&source.source_type);
|
||||||
|
|
||||||
@@ -46,7 +54,10 @@ impl SqliteConfigStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn delete_data_source_impl(&self, id: DataSourceId) -> Result<(), SqliteConfigError> {
|
pub(crate) async fn delete_data_source_impl(
|
||||||
|
&self,
|
||||||
|
id: DataSourceId,
|
||||||
|
) -> Result<(), SqliteConfigError> {
|
||||||
sqlx::query("DELETE FROM data_sources WHERE id = ?")
|
sqlx::query("DELETE FROM data_sources WHERE id = ?")
|
||||||
.bind(id as i64)
|
.bind(id as i64)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use sqlx::Row;
|
|
||||||
use domain::Layout;
|
|
||||||
use crate::SqliteConfigStore;
|
use crate::SqliteConfigStore;
|
||||||
use crate::error::SqliteConfigError;
|
use crate::error::SqliteConfigError;
|
||||||
use crate::serialization::layout as ser;
|
use crate::serialization::layout as ser;
|
||||||
|
use domain::Layout;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
impl SqliteConfigStore {
|
impl SqliteConfigStore {
|
||||||
pub(crate) async fn get_layout_impl(&self) -> Result<Option<Layout>, SqliteConfigError> {
|
pub(crate) async fn get_layout_impl(&self) -> Result<Option<Layout>, SqliteConfigError> {
|
||||||
@@ -23,9 +23,7 @@ impl SqliteConfigStore {
|
|||||||
pub(crate) async fn save_layout_impl(&self, layout: &Layout) -> Result<(), SqliteConfigError> {
|
pub(crate) async fn save_layout_impl(&self, layout: &Layout) -> Result<(), SqliteConfigError> {
|
||||||
let json = ser::layout_to_json(layout)?;
|
let json = ser::layout_to_json(layout)?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query("INSERT OR REPLACE INTO layout (id, data) VALUES (1, ?)")
|
||||||
"INSERT OR REPLACE INTO layout (id, data) VALUES (1, ?)"
|
|
||||||
)
|
|
||||||
.bind(&json)
|
.bind(&json)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
mod widgets;
|
|
||||||
mod data_sources;
|
mod data_sources;
|
||||||
mod layout;
|
mod layout;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
mod widgets;
|
||||||
|
|
||||||
use domain::{
|
|
||||||
ConfigRepository,
|
|
||||||
DataSource, DataSourceId,
|
|
||||||
Layout, LayoutPreset, LayoutPresetId,
|
|
||||||
WidgetConfig, WidgetId,
|
|
||||||
};
|
|
||||||
use crate::SqliteConfigStore;
|
use crate::SqliteConfigStore;
|
||||||
use crate::error::SqliteConfigError;
|
use crate::error::SqliteConfigError;
|
||||||
|
use domain::{
|
||||||
|
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig,
|
||||||
|
WidgetId,
|
||||||
|
};
|
||||||
|
|
||||||
impl ConfigRepository for SqliteConfigStore {
|
impl ConfigRepository for SqliteConfigStore {
|
||||||
type Error = SqliteConfigError;
|
type Error = SqliteConfigError;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
use domain::{LayoutPreset, LayoutPresetId};
|
|
||||||
use crate::SqliteConfigStore;
|
use crate::SqliteConfigStore;
|
||||||
use crate::error::SqliteConfigError;
|
use crate::error::SqliteConfigError;
|
||||||
use crate::serialization::{layout as layout_ser, preset as ser};
|
use crate::serialization::{layout as layout_ser, preset as ser};
|
||||||
|
use domain::{LayoutPreset, LayoutPresetId};
|
||||||
|
|
||||||
impl SqliteConfigStore {
|
impl SqliteConfigStore {
|
||||||
pub(crate) async fn get_preset_impl(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, SqliteConfigError> {
|
pub(crate) async fn get_preset_impl(
|
||||||
|
&self,
|
||||||
|
id: LayoutPresetId,
|
||||||
|
) -> Result<Option<LayoutPreset>, SqliteConfigError> {
|
||||||
let row = sqlx::query("SELECT * FROM presets WHERE id = ?")
|
let row = sqlx::query("SELECT * FROM presets WHERE id = ?")
|
||||||
.bind(id as i64)
|
.bind(id as i64)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -23,15 +26,16 @@ impl SqliteConfigStore {
|
|||||||
.await
|
.await
|
||||||
.map_err(SqliteConfigError::Sql)?;
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
rows.iter().map(|r| ser::preset_from_row(r)).collect()
|
rows.iter().map(ser::preset_from_row).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn save_preset_impl(&self, preset: &LayoutPreset) -> Result<(), SqliteConfigError> {
|
pub(crate) async fn save_preset_impl(
|
||||||
|
&self,
|
||||||
|
preset: &LayoutPreset,
|
||||||
|
) -> Result<(), SqliteConfigError> {
|
||||||
let layout_json = layout_ser::layout_to_json(&preset.layout)?;
|
let layout_json = layout_ser::layout_to_json(&preset.layout)?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query("INSERT OR REPLACE INTO presets (id, name, layout_data) VALUES (?, ?, ?)")
|
||||||
"INSERT OR REPLACE INTO presets (id, name, layout_data) VALUES (?, ?, ?)"
|
|
||||||
)
|
|
||||||
.bind(preset.id as i64)
|
.bind(preset.id as i64)
|
||||||
.bind(&preset.name)
|
.bind(&preset.name)
|
||||||
.bind(&layout_json)
|
.bind(&layout_json)
|
||||||
@@ -42,7 +46,10 @@ impl SqliteConfigStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn delete_preset_impl(&self, id: LayoutPresetId) -> Result<(), SqliteConfigError> {
|
pub(crate) async fn delete_preset_impl(
|
||||||
|
&self,
|
||||||
|
id: LayoutPresetId,
|
||||||
|
) -> Result<(), SqliteConfigError> {
|
||||||
sqlx::query("DELETE FROM presets WHERE id = ?")
|
sqlx::query("DELETE FROM presets WHERE id = ?")
|
||||||
.bind(id as i64)
|
.bind(id as i64)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
use domain::{WidgetConfig, WidgetId};
|
|
||||||
use crate::SqliteConfigStore;
|
use crate::SqliteConfigStore;
|
||||||
use crate::error::SqliteConfigError;
|
use crate::error::SqliteConfigError;
|
||||||
use crate::serialization::widget as ser;
|
use crate::serialization::widget as ser;
|
||||||
|
use domain::{WidgetConfig, WidgetId};
|
||||||
|
|
||||||
impl SqliteConfigStore {
|
impl SqliteConfigStore {
|
||||||
pub(crate) async fn get_widget_impl(&self, id: WidgetId) -> Result<Option<WidgetConfig>, SqliteConfigError> {
|
pub(crate) async fn get_widget_impl(
|
||||||
|
&self,
|
||||||
|
id: WidgetId,
|
||||||
|
) -> Result<Option<WidgetConfig>, SqliteConfigError> {
|
||||||
let row = sqlx::query("SELECT * FROM widgets WHERE id = ?")
|
let row = sqlx::query("SELECT * FROM widgets WHERE id = ?")
|
||||||
.bind(id as i64)
|
.bind(id as i64)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -23,10 +26,13 @@ impl SqliteConfigStore {
|
|||||||
.await
|
.await
|
||||||
.map_err(SqliteConfigError::Sql)?;
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
rows.iter().map(|r| ser::widget_from_row(r)).collect()
|
rows.iter().map(ser::widget_from_row).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn save_widget_impl(&self, config: &WidgetConfig) -> Result<(), SqliteConfigError> {
|
pub(crate) async fn save_widget_impl(
|
||||||
|
&self,
|
||||||
|
config: &WidgetConfig,
|
||||||
|
) -> Result<(), SqliteConfigError> {
|
||||||
let mappings_json = ser::mappings_to_json(&config.mappings)?;
|
let mappings_json = ser::mappings_to_json(&config.mappings)?;
|
||||||
let hint_str = ser::display_hint_to_str(&config.display_hint);
|
let hint_str = ser::display_hint_to_str(&config.display_hint);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use std::time::Duration;
|
use crate::error::SqliteConfigError;
|
||||||
|
use domain::{DataSource, DataSourceConfig, DataSourceType};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use sqlx::sqlite::SqliteRow;
|
use sqlx::sqlite::SqliteRow;
|
||||||
use domain::{DataSource, DataSourceConfig, DataSourceType};
|
use std::time::Duration;
|
||||||
use crate::error::SqliteConfigError;
|
|
||||||
|
|
||||||
pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str {
|
pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str {
|
||||||
match t {
|
match t {
|
||||||
DataSourceType::Weather => "weather",
|
DataSourceType::Weather => "weather",
|
||||||
DataSourceType::Media => "media",
|
DataSourceType::Media => "media",
|
||||||
DataSourceType::Xtb => "xtb",
|
|
||||||
DataSourceType::Rss => "rss",
|
DataSourceType::Rss => "rss",
|
||||||
DataSourceType::HttpJson => "http_json",
|
DataSourceType::HttpJson => "http_json",
|
||||||
DataSourceType::Webhook => "webhook",
|
DataSourceType::Webhook => "webhook",
|
||||||
@@ -19,11 +18,12 @@ fn data_source_type_from_str(s: &str) -> Result<DataSourceType, SqliteConfigErro
|
|||||||
match s {
|
match s {
|
||||||
"weather" => Ok(DataSourceType::Weather),
|
"weather" => Ok(DataSourceType::Weather),
|
||||||
"media" => Ok(DataSourceType::Media),
|
"media" => Ok(DataSourceType::Media),
|
||||||
"xtb" => Ok(DataSourceType::Xtb),
|
|
||||||
"rss" => Ok(DataSourceType::Rss),
|
"rss" => Ok(DataSourceType::Rss),
|
||||||
"http_json" => Ok(DataSourceType::HttpJson),
|
"http_json" => Ok(DataSourceType::HttpJson),
|
||||||
"webhook" => Ok(DataSourceType::Webhook),
|
"webhook" => Ok(DataSourceType::Webhook),
|
||||||
_ => Err(SqliteConfigError::Serialization(format!("unknown source type: {s}"))),
|
_ => Err(SqliteConfigError::Serialization(format!(
|
||||||
|
"unknown source type: {s}"
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,20 +37,27 @@ pub fn data_source_config_to_json(config: &DataSourceConfig) -> Result<String, S
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteConfigError> {
|
fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteConfigError> {
|
||||||
let v: serde_json::Value = serde_json::from_str(json)
|
let v: serde_json::Value =
|
||||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
let url = v["url"].as_str().map(String::from);
|
let url = v["url"].as_str().map(String::from);
|
||||||
let api_key = v["api_key"].as_str().map(String::from);
|
let api_key = v["api_key"].as_str().map(String::from);
|
||||||
let headers = match v["headers"].as_array() {
|
let headers = match v["headers"].as_array() {
|
||||||
Some(arr) => arr.iter().filter_map(|h| {
|
Some(arr) => arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|h| {
|
||||||
let pair = h.as_array()?;
|
let pair = h.as_array()?;
|
||||||
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into()))
|
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into()))
|
||||||
}).collect(),
|
})
|
||||||
|
.collect(),
|
||||||
None => vec![],
|
None => vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(DataSourceConfig { url, headers, api_key })
|
Ok(DataSourceConfig {
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
api_key,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigError> {
|
pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigError> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use domain::{ContainerNode, Direction, Layout, LayoutChild, LayoutNode, Sizing};
|
|
||||||
use crate::error::SqliteConfigError;
|
use crate::error::SqliteConfigError;
|
||||||
|
use domain::{ContainerNode, Direction, Layout, LayoutChild, LayoutNode, Sizing};
|
||||||
|
|
||||||
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
|
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
|
||||||
let v = node_to_json(&layout.root);
|
let v = node_to_json(&layout.root);
|
||||||
@@ -7,8 +7,8 @@ pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn layout_from_json(json: &str) -> Result<Layout, SqliteConfigError> {
|
pub fn layout_from_json(json: &str) -> Result<Layout, SqliteConfigError> {
|
||||||
let v: serde_json::Value = serde_json::from_str(json)
|
let v: serde_json::Value =
|
||||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||||
let root = node_from_json(&v)?;
|
let root = node_from_json(&v)?;
|
||||||
Ok(Layout { root })
|
Ok(Layout { root })
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,10 @@ fn node_to_json(node: &LayoutNode) -> serde_json::Value {
|
|||||||
match node {
|
match node {
|
||||||
LayoutNode::Leaf(id) => serde_json::json!({ "type": "leaf", "widget_id": id }),
|
LayoutNode::Leaf(id) => serde_json::json!({ "type": "leaf", "widget_id": id }),
|
||||||
LayoutNode::Container(c) => {
|
LayoutNode::Container(c) => {
|
||||||
let children: Vec<serde_json::Value> = c.children.iter().map(|ch| {
|
let children: Vec<serde_json::Value> = c
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.map(|ch| {
|
||||||
let sizing = match &ch.sizing {
|
let sizing = match &ch.sizing {
|
||||||
Sizing::Fixed(px) => serde_json::json!({ "type": "fixed", "value": px }),
|
Sizing::Fixed(px) => serde_json::json!({ "type": "fixed", "value": px }),
|
||||||
Sizing::Flex(w) => serde_json::json!({ "type": "flex", "value": w }),
|
Sizing::Flex(w) => serde_json::json!({ "type": "flex", "value": w }),
|
||||||
@@ -26,7 +29,8 @@ fn node_to_json(node: &LayoutNode) -> serde_json::Value {
|
|||||||
"sizing": sizing,
|
"sizing": sizing,
|
||||||
"node": node_to_json(&ch.node),
|
"node": node_to_json(&ch.node),
|
||||||
})
|
})
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"type": "container",
|
"type": "container",
|
||||||
@@ -44,25 +48,44 @@ fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError
|
|||||||
|
|
||||||
match v["type"].as_str().ok_or_else(|| err("missing node type"))? {
|
match v["type"].as_str().ok_or_else(|| err("missing node type"))? {
|
||||||
"leaf" => {
|
"leaf" => {
|
||||||
let id = v["widget_id"].as_u64().ok_or_else(|| err("missing widget_id"))? as u16;
|
let id = v["widget_id"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| err("missing widget_id"))? as u16;
|
||||||
Ok(LayoutNode::Leaf(id))
|
Ok(LayoutNode::Leaf(id))
|
||||||
}
|
}
|
||||||
"container" => {
|
"container" => {
|
||||||
let direction = match v["direction"].as_str().ok_or_else(|| err("missing direction"))? {
|
let direction = match v["direction"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| err("missing direction"))?
|
||||||
|
{
|
||||||
"row" => Direction::Row,
|
"row" => Direction::Row,
|
||||||
"column" => Direction::Column,
|
"column" => Direction::Column,
|
||||||
d => return Err(err(&format!("unknown direction: {d}"))),
|
d => return Err(err(&format!("unknown direction: {d}"))),
|
||||||
};
|
};
|
||||||
let gap = v["gap"].as_u64().unwrap_or(0) as u8;
|
let gap = v["gap"].as_u64().unwrap_or(0) as u8;
|
||||||
let padding = v["padding"].as_u64().unwrap_or(0) as u8;
|
let padding = v["padding"].as_u64().unwrap_or(0) as u8;
|
||||||
let children = v["children"].as_array()
|
let children = v["children"]
|
||||||
|
.as_array()
|
||||||
.ok_or_else(|| err("missing children"))?
|
.ok_or_else(|| err("missing children"))?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|ch| {
|
.map(|ch| {
|
||||||
let sizing_v = &ch["sizing"];
|
let sizing_v = &ch["sizing"];
|
||||||
let sizing = match sizing_v["type"].as_str().ok_or_else(|| err("missing sizing type"))? {
|
let sizing = match sizing_v["type"]
|
||||||
"fixed" => Sizing::Fixed(sizing_v["value"].as_u64().ok_or_else(|| err("missing fixed value"))? as u16),
|
.as_str()
|
||||||
"flex" => Sizing::Flex(sizing_v["value"].as_u64().ok_or_else(|| err("missing flex value"))? as u8),
|
.ok_or_else(|| err("missing sizing type"))?
|
||||||
|
{
|
||||||
|
"fixed" => Sizing::Fixed(
|
||||||
|
sizing_v["value"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| err("missing fixed value"))?
|
||||||
|
as u16,
|
||||||
|
),
|
||||||
|
"flex" => Sizing::Flex(
|
||||||
|
sizing_v["value"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| err("missing flex value"))?
|
||||||
|
as u8,
|
||||||
|
),
|
||||||
s => return Err(err(&format!("unknown sizing: {s}"))),
|
s => return Err(err(&format!("unknown sizing: {s}"))),
|
||||||
};
|
};
|
||||||
let node = node_from_json(&ch["node"])?;
|
let node = node_from_json(&ch["node"])?;
|
||||||
@@ -70,7 +93,12 @@ fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
Ok(LayoutNode::Container(ContainerNode { direction, gap, padding, children }))
|
Ok(LayoutNode::Container(ContainerNode {
|
||||||
|
direction,
|
||||||
|
gap,
|
||||||
|
padding,
|
||||||
|
children,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
t => Err(err(&format!("unknown node type: {t}"))),
|
t => Err(err(&format!("unknown node type: {t}"))),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
pub mod widget;
|
|
||||||
pub mod data_source;
|
pub mod data_source;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod preset;
|
pub mod preset;
|
||||||
|
pub mod widget;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
use super::layout::layout_from_json;
|
||||||
|
use crate::error::SqliteConfigError;
|
||||||
|
use domain::LayoutPreset;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use sqlx::sqlite::SqliteRow;
|
use sqlx::sqlite::SqliteRow;
|
||||||
use domain::LayoutPreset;
|
|
||||||
use crate::error::SqliteConfigError;
|
|
||||||
use super::layout::layout_from_json;
|
|
||||||
|
|
||||||
pub fn preset_from_row(row: &SqliteRow) -> Result<LayoutPreset, SqliteConfigError> {
|
pub fn preset_from_row(row: &SqliteRow) -> Result<LayoutPreset, SqliteConfigError> {
|
||||||
let id: i64 = row.get("id");
|
let id: i64 = row.get("id");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
use crate::error::SqliteConfigError;
|
||||||
|
use domain::{DisplayHint, KeyMapping, WidgetConfig};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use sqlx::sqlite::SqliteRow;
|
use sqlx::sqlite::SqliteRow;
|
||||||
use domain::{DisplayHint, KeyMapping, WidgetConfig};
|
|
||||||
use crate::error::SqliteConfigError;
|
|
||||||
|
|
||||||
pub fn display_hint_to_str(hint: &DisplayHint) -> &'static str {
|
pub fn display_hint_to_str(hint: &DisplayHint) -> &'static str {
|
||||||
match hint {
|
match hint {
|
||||||
@@ -16,32 +16,44 @@ fn display_hint_from_str(s: &str) -> Result<DisplayHint, SqliteConfigError> {
|
|||||||
"icon_value" => Ok(DisplayHint::IconValue),
|
"icon_value" => Ok(DisplayHint::IconValue),
|
||||||
"text_block" => Ok(DisplayHint::TextBlock),
|
"text_block" => Ok(DisplayHint::TextBlock),
|
||||||
"key_value" => Ok(DisplayHint::KeyValue),
|
"key_value" => Ok(DisplayHint::KeyValue),
|
||||||
_ => Err(SqliteConfigError::Serialization(format!("unknown display hint: {s}"))),
|
_ => Err(SqliteConfigError::Serialization(format!(
|
||||||
|
"unknown display hint: {s}"
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mappings_to_json(mappings: &[KeyMapping]) -> Result<String, SqliteConfigError> {
|
pub fn mappings_to_json(mappings: &[KeyMapping]) -> Result<String, SqliteConfigError> {
|
||||||
let entries: Vec<serde_json::Value> = mappings.iter().map(|m| {
|
let entries: Vec<serde_json::Value> = mappings
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"source_path": m.source_path,
|
"source_path": m.source_path,
|
||||||
"target_key": m.target_key,
|
"target_key": m.target_key,
|
||||||
})
|
})
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
serde_json::to_string(&entries).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
serde_json::to_string(&entries).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mappings_from_json(json: &str) -> Result<Vec<KeyMapping>, SqliteConfigError> {
|
fn mappings_from_json(json: &str) -> Result<Vec<KeyMapping>, SqliteConfigError> {
|
||||||
let entries: Vec<serde_json::Value> = serde_json::from_str(json)
|
let entries: Vec<serde_json::Value> =
|
||||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
entries.iter().map(|v| {
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|v| {
|
||||||
Ok(KeyMapping {
|
Ok(KeyMapping {
|
||||||
source_path: v["source_path"].as_str()
|
source_path: v["source_path"]
|
||||||
.ok_or_else(|| SqliteConfigError::Serialization("missing source_path".into()))?.into(),
|
.as_str()
|
||||||
target_key: v["target_key"].as_str()
|
.ok_or_else(|| SqliteConfigError::Serialization("missing source_path".into()))?
|
||||||
.ok_or_else(|| SqliteConfigError::Serialization("missing target_key".into()))?.into(),
|
.into(),
|
||||||
|
target_key: v["target_key"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| SqliteConfigError::Serialization("missing target_key".into()))?
|
||||||
|
.into(),
|
||||||
})
|
})
|
||||||
}).collect()
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigError> {
|
pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigError> {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
use std::time::Duration;
|
|
||||||
use domain::{
|
|
||||||
ConfigRepository, DisplayHint, KeyMapping, WidgetConfig,
|
|
||||||
DataSource, DataSourceConfig, DataSourceType,
|
|
||||||
Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
|
||||||
LayoutPreset,
|
|
||||||
};
|
|
||||||
use config_sqlite::SqliteConfigStore;
|
use config_sqlite::SqliteConfigStore;
|
||||||
|
use domain::{
|
||||||
|
ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType, Direction,
|
||||||
|
DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode, LayoutPreset, Sizing, WidgetConfig,
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
async fn test_store() -> SqliteConfigStore {
|
async fn test_store() -> SqliteConfigStore {
|
||||||
SqliteConfigStore::new("sqlite::memory:").await.unwrap()
|
SqliteConfigStore::new("sqlite::memory:").await.unwrap()
|
||||||
@@ -18,8 +16,14 @@ fn weather_widget() -> WidgetConfig {
|
|||||||
display_hint: DisplayHint::IconValue,
|
display_hint: DisplayHint::IconValue,
|
||||||
data_source_id: 10,
|
data_source_id: 10,
|
||||||
mappings: vec![
|
mappings: vec![
|
||||||
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() },
|
KeyMapping {
|
||||||
KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() },
|
source_path: "$.temp".into(),
|
||||||
|
target_key: "temperature".into(),
|
||||||
|
},
|
||||||
|
KeyMapping {
|
||||||
|
source_path: "$.icon".into(),
|
||||||
|
target_key: "icon".into(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
max_data_size: 2048,
|
max_data_size: 2048,
|
||||||
}
|
}
|
||||||
@@ -46,8 +50,14 @@ fn test_layout() -> Layout {
|
|||||||
gap: 4,
|
gap: 4,
|
||||||
padding: 2,
|
padding: 2,
|
||||||
children: vec![
|
children: vec![
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
LayoutChild {
|
||||||
LayoutChild { sizing: Sizing::Fixed(80), node: LayoutNode::Leaf(2) },
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(1),
|
||||||
|
},
|
||||||
|
LayoutChild {
|
||||||
|
sizing: Sizing::Fixed(80),
|
||||||
|
node: LayoutNode::Leaf(2),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -78,14 +88,17 @@ async fn get_nonexistent_widget_returns_none() {
|
|||||||
async fn list_widgets_returns_all() {
|
async fn list_widgets_returns_all() {
|
||||||
let store = test_store().await;
|
let store = test_store().await;
|
||||||
store.save_widget(&weather_widget()).await.unwrap();
|
store.save_widget(&weather_widget()).await.unwrap();
|
||||||
store.save_widget(&WidgetConfig {
|
store
|
||||||
|
.save_widget(&WidgetConfig {
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "portfolio".into(),
|
name: "portfolio".into(),
|
||||||
display_hint: DisplayHint::KeyValue,
|
display_hint: DisplayHint::KeyValue,
|
||||||
data_source_id: 20,
|
data_source_id: 20,
|
||||||
mappings: vec![],
|
mappings: vec![],
|
||||||
max_data_size: 1024,
|
max_data_size: 1024,
|
||||||
}).await.unwrap();
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let widgets = store.list_widgets().await.unwrap();
|
let widgets = store.list_widgets().await.unwrap();
|
||||||
assert_eq!(widgets.len(), 2);
|
assert_eq!(widgets.len(), 2);
|
||||||
@@ -172,12 +185,22 @@ async fn save_and_retrieve_preset() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_and_delete_presets() {
|
async fn list_and_delete_presets() {
|
||||||
let store = test_store().await;
|
let store = test_store().await;
|
||||||
store.save_preset(&LayoutPreset {
|
store
|
||||||
id: 1, name: "a".into(), layout: test_layout(),
|
.save_preset(&LayoutPreset {
|
||||||
}).await.unwrap();
|
id: 1,
|
||||||
store.save_preset(&LayoutPreset {
|
name: "a".into(),
|
||||||
id: 2, name: "b".into(), layout: test_layout(),
|
layout: test_layout(),
|
||||||
}).await.unwrap();
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.save_preset(&LayoutPreset {
|
||||||
|
id: 2,
|
||||||
|
name: "b".into(),
|
||||||
|
layout: test_layout(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(store.list_presets().await.unwrap().len(), 2);
|
assert_eq!(store.list_presets().await.unwrap().len(), 2);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use client_domain::{BoundingBox, DisplayPort};
|
use client_domain::{BoundingBox, DisplayPort};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
pub struct TerminalDisplay;
|
pub struct TerminalDisplay;
|
||||||
|
|
||||||
impl TerminalDisplay {
|
impl TerminalDisplay {
|
||||||
@@ -12,12 +13,24 @@ impl DisplayPort for TerminalDisplay {
|
|||||||
type Error = std::io::Error;
|
type Error = std::io::Error;
|
||||||
|
|
||||||
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
|
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
|
||||||
println!("[CLEAR] ({}, {}) {}x{}", bounds.x, bounds.y, bounds.width, bounds.height);
|
println!(
|
||||||
|
"[CLEAR] ({}, {}) {}x{}",
|
||||||
|
bounds.x, bounds.y, bounds.width, bounds.height
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error> {
|
fn draw_text(
|
||||||
println!("[TEXT] ({x}, {y}) in {}x{}: \"{text}\"", bounds.width, bounds.height);
|
&mut self,
|
||||||
|
text: &str,
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
bounds: BoundingBox,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
println!(
|
||||||
|
"[TEXT] ({x}, {y}) in {}x{}: \"{text}\"",
|
||||||
|
bounds.width, bounds.height
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +40,10 @@ impl DisplayPort for TerminalDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
|
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
|
||||||
println!("[BG] ({}, {}) {}x{}", bounds.x, bounds.y, bounds.width, bounds.height);
|
println!(
|
||||||
|
"[BG] ({}, {}) {}x{}",
|
||||||
|
bounds.x, bounds.y, bounds.width, bounds.height
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use tower_http::cors::CorsLayer;
|
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
|
||||||
pub struct AppState<C, E> {
|
pub struct AppState<C, E> {
|
||||||
pub config: Arc<C>,
|
pub config: Arc<C>,
|
||||||
|
|||||||
@@ -1,54 +1,107 @@
|
|||||||
|
use crate::AppState;
|
||||||
|
use api_types::DataSourceDto;
|
||||||
|
use application::ConfigService;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
use application::ConfigService;
|
|
||||||
use crate::AppState;
|
|
||||||
use api_types::DataSourceDto;
|
|
||||||
|
|
||||||
type S<C, E> = State<AppState<C, E>>;
|
type S<C, E> = State<AppState<C, E>>;
|
||||||
|
|
||||||
pub async fn list_data_sources<C, E>(State(state): S<C, E>) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
|
pub async fn list_data_sources<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let sources = state.config.list_data_sources().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
let sources = state
|
||||||
|
.config
|
||||||
|
.list_data_sources()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
|
Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_data_source<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<DataSourceDto>, StatusCode>
|
pub async fn get_data_source<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Path(id): Path<u16>,
|
||||||
|
) -> Result<Json<DataSourceDto>, StatusCode>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let source = state.config.get_data_source(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
let source = state
|
||||||
|
.config
|
||||||
|
.get_data_source(id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
match source {
|
match source {
|
||||||
Some(s) => Ok(Json(DataSourceDto::from(&s))),
|
Some(s) => Ok(Json(DataSourceDto::from(&s))),
|
||||||
None => Err(StatusCode::NOT_FOUND),
|
None => Err(StatusCode::NOT_FOUND),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_data_source<C, E>(State(state): S<C, E>, Json(body): Json<DataSourceDto>) -> Result<StatusCode, (StatusCode, String)>
|
pub async fn create_data_source<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Json(body): Json<DataSourceDto>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let source = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
let source = body
|
||||||
|
.into_domain()
|
||||||
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||||
svc.create_data_source(source).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
svc.create_data_source(source)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_data_source<C, E>(State(state): S<C, E>, Path(_id): Path<u16>, Json(body): Json<DataSourceDto>) -> Result<StatusCode, (StatusCode, String)>
|
pub async fn update_data_source<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Path(_id): Path<u16>,
|
||||||
|
Json(body): Json<DataSourceDto>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let source = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
let source = body
|
||||||
|
.into_domain()
|
||||||
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||||
svc.update_data_source(source).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
svc.update_data_source(source)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_data_source<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode>
|
pub async fn delete_data_source<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Path(id): Path<u16>,
|
||||||
|
) -> Result<StatusCode, StatusCode>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||||
svc.delete_data_source(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
svc.delete_data_source(id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,42 @@
|
|||||||
use axum::{
|
|
||||||
extract::State,
|
|
||||||
http::StatusCode,
|
|
||||||
response::Json,
|
|
||||||
};
|
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
|
||||||
use application::ConfigService;
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use api_types::LayoutDto;
|
use api_types::LayoutDto;
|
||||||
|
use application::ConfigService;
|
||||||
|
use axum::{extract::State, http::StatusCode, response::Json};
|
||||||
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
type S<C, E> = State<AppState<C, E>>;
|
type S<C, E> = State<AppState<C, E>>;
|
||||||
|
|
||||||
pub async fn get_layout<C, E>(State(state): S<C, E>) -> Result<Json<Option<LayoutDto>>, StatusCode>
|
pub async fn get_layout<C, E>(State(state): S<C, E>) -> Result<Json<Option<LayoutDto>>, StatusCode>
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let layout = state.config.get_layout().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
let layout = state
|
||||||
|
.config
|
||||||
|
.get_layout()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(Json(layout.as_ref().map(LayoutDto::from)))
|
Ok(Json(layout.as_ref().map(LayoutDto::from)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_layout<C, E>(State(state): S<C, E>, Json(body): Json<LayoutDto>) -> Result<StatusCode, (StatusCode, String)>
|
pub async fn update_layout<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Json(body): Json<LayoutDto>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let layout = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
let layout = body
|
||||||
|
.into_domain()
|
||||||
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||||
svc.update_layout(layout).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
svc.update_layout(layout)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
mod widgets;
|
|
||||||
mod data_sources;
|
mod data_sources;
|
||||||
mod layout;
|
mod layout;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
mod widgets;
|
||||||
|
|
||||||
use axum::Router;
|
|
||||||
use axum::routing::{get, post, put, delete};
|
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
pub fn api_routes<C, E>() -> Router<AppState<C, E>>
|
pub fn api_routes<C, E>() -> Router<AppState<C, E>>
|
||||||
where
|
where
|
||||||
@@ -16,12 +16,38 @@ where
|
|||||||
E::Error: std::fmt::Debug + Send,
|
E::Error: std::fmt::Debug + Send,
|
||||||
{
|
{
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/widgets", get(widgets::list_widgets::<C, E>).post(widgets::create_widget::<C, E>))
|
.route(
|
||||||
.route("/widgets/{id}", get(widgets::get_widget::<C, E>).put(widgets::update_widget::<C, E>).delete(widgets::delete_widget::<C, E>))
|
"/widgets",
|
||||||
.route("/data-sources", get(data_sources::list_data_sources::<C, E>).post(data_sources::create_data_source::<C, E>))
|
get(widgets::list_widgets::<C, E>).post(widgets::create_widget::<C, E>),
|
||||||
.route("/data-sources/{id}", get(data_sources::get_data_source::<C, E>).put(data_sources::update_data_source::<C, E>).delete(data_sources::delete_data_source::<C, E>))
|
)
|
||||||
.route("/layout", get(layout::get_layout::<C, E>).put(layout::update_layout::<C, E>))
|
.route(
|
||||||
.route("/presets", get(presets::list_presets::<C, E>).post(presets::create_preset::<C, E>))
|
"/widgets/{id}",
|
||||||
.route("/presets/{id}", get(presets::get_preset::<C, E>).delete(presets::delete_preset::<C, E>))
|
get(widgets::get_widget::<C, E>)
|
||||||
|
.put(widgets::update_widget::<C, E>)
|
||||||
|
.delete(widgets::delete_widget::<C, E>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/data-sources",
|
||||||
|
get(data_sources::list_data_sources::<C, E>)
|
||||||
|
.post(data_sources::create_data_source::<C, E>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/data-sources/{id}",
|
||||||
|
get(data_sources::get_data_source::<C, E>)
|
||||||
|
.put(data_sources::update_data_source::<C, E>)
|
||||||
|
.delete(data_sources::delete_data_source::<C, E>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/layout",
|
||||||
|
get(layout::get_layout::<C, E>).put(layout::update_layout::<C, E>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/presets",
|
||||||
|
get(presets::list_presets::<C, E>).post(presets::create_preset::<C, E>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/presets/{id}",
|
||||||
|
get(presets::get_preset::<C, E>).delete(presets::delete_preset::<C, E>),
|
||||||
|
)
|
||||||
.route("/presets/{id}/load", post(presets::load_preset::<C, E>))
|
.route("/presets/{id}/load", post(presets::load_preset::<C, E>))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,101 @@
|
|||||||
|
use crate::AppState;
|
||||||
|
use api_types::{CreatePresetDto, PresetDto};
|
||||||
|
use application::ConfigService;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
use application::ConfigService;
|
|
||||||
use crate::AppState;
|
|
||||||
use api_types::{PresetDto, CreatePresetDto};
|
|
||||||
|
|
||||||
type S<C, E> = State<AppState<C, E>>;
|
type S<C, E> = State<AppState<C, E>>;
|
||||||
|
|
||||||
pub async fn list_presets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<PresetDto>>, StatusCode>
|
pub async fn list_presets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<PresetDto>>, StatusCode>
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let presets = state.config.list_presets().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
let presets = state
|
||||||
|
.config
|
||||||
|
.list_presets()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(Json(presets.iter().map(PresetDto::from).collect()))
|
Ok(Json(presets.iter().map(PresetDto::from).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<PresetDto>, StatusCode>
|
pub async fn get_preset<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Path(id): Path<u16>,
|
||||||
|
) -> Result<Json<PresetDto>, StatusCode>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let preset = state.config.get_preset(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
let preset = state
|
||||||
|
.config
|
||||||
|
.get_preset(id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
match preset {
|
match preset {
|
||||||
Some(p) => Ok(Json(PresetDto::from(&p))),
|
Some(p) => Ok(Json(PresetDto::from(&p))),
|
||||||
None => Err(StatusCode::NOT_FOUND),
|
None => Err(StatusCode::NOT_FOUND),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_preset<C, E>(State(state): S<C, E>, Json(body): Json<CreatePresetDto>) -> Result<StatusCode, (StatusCode, String)>
|
pub async fn create_preset<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Json(body): Json<CreatePresetDto>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let preset = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
let preset = body
|
||||||
|
.into_domain()
|
||||||
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||||
svc.save_preset(preset).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
svc.save_preset(preset)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode>
|
pub async fn delete_preset<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Path(id): Path<u16>,
|
||||||
|
) -> Result<StatusCode, StatusCode>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||||
svc.delete_preset(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
svc.delete_preset(id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, (StatusCode, String)>
|
pub async fn load_preset<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Path(id): Path<u16>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||||
svc.load_preset(id).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
svc.load_preset(id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,105 @@
|
|||||||
|
use crate::AppState;
|
||||||
|
use api_types::{CreateWidgetDto, WidgetDto};
|
||||||
|
use application::ConfigService;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
use application::ConfigService;
|
|
||||||
use crate::AppState;
|
|
||||||
use api_types::{WidgetDto, CreateWidgetDto};
|
|
||||||
|
|
||||||
type S<C, E> = State<AppState<C, E>>;
|
type S<C, E> = State<AppState<C, E>>;
|
||||||
|
|
||||||
pub async fn list_widgets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<WidgetDto>>, StatusCode>
|
pub async fn list_widgets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<WidgetDto>>, StatusCode>
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let widgets = state.config.list_widgets().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
let widgets = state
|
||||||
|
.config
|
||||||
|
.list_widgets()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
|
Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_widget<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<WidgetDto>, StatusCode>
|
pub async fn get_widget<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Path(id): Path<u16>,
|
||||||
|
) -> Result<Json<WidgetDto>, StatusCode>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let widget = state.config.get_widget(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
let widget = state
|
||||||
|
.config
|
||||||
|
.get_widget(id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
match widget {
|
match widget {
|
||||||
Some(w) => Ok(Json(WidgetDto::from(&w))),
|
Some(w) => Ok(Json(WidgetDto::from(&w))),
|
||||||
None => Err(StatusCode::NOT_FOUND),
|
None => Err(StatusCode::NOT_FOUND),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_widget<C, E>(State(state): S<C, E>, Json(body): Json<CreateWidgetDto>) -> Result<StatusCode, (StatusCode, String)>
|
pub async fn create_widget<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Json(body): Json<CreateWidgetDto>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let widget = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
let widget = body
|
||||||
|
.into_domain()
|
||||||
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||||
svc.create_widget(widget).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
svc.create_widget(widget)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_widget<C, E>(State(state): S<C, E>, Path(_id): Path<u16>, Json(body): Json<CreateWidgetDto>) -> Result<StatusCode, (StatusCode, String)>
|
pub async fn update_widget<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Path(_id): Path<u16>,
|
||||||
|
Json(body): Json<CreateWidgetDto>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let widget = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
let widget = body
|
||||||
|
.into_domain()
|
||||||
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||||
svc.update_widget(widget).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
svc.update_widget(widget)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_widget<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode>
|
pub async fn delete_widget<C, E>(
|
||||||
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
|
State(state): S<C, E>,
|
||||||
|
Path(id): Path<u16>,
|
||||||
|
) -> Result<StatusCode, StatusCode>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||||
svc.delete_widget(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
svc.delete_widget(id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use tower::ServiceExt;
|
|
||||||
use config_memory::MemoryConfigStore;
|
use config_memory::MemoryConfigStore;
|
||||||
use tcp_server::TcpEventBus;
|
|
||||||
use http_api::{AppState, router};
|
use http_api::{AppState, router};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tcp_server::TcpEventBus;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
fn test_app() -> axum::Router {
|
fn test_app() -> axum::Router {
|
||||||
let config = Arc::new(MemoryConfigStore::new());
|
let config = Arc::new(MemoryConfigStore::new());
|
||||||
@@ -38,13 +38,22 @@ async fn create_and_get_widget() {
|
|||||||
"mappings": [{"source_path": "$.temp", "target_key": "temperature"}]
|
"mappings": [{"source_path": "$.temp", "target_key": "temperature"}]
|
||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
let resp = app.clone().oneshot(json_request("POST", "/api/widgets", Some(body))).await.unwrap();
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(json_request("POST", "/api/widgets", Some(body)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
let resp = app.oneshot(json_request("GET", "/api/widgets/1", None)).await.unwrap();
|
let resp = app
|
||||||
|
.oneshot(json_request("GET", "/api/widgets/1", None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(json["name"], "weather");
|
assert_eq!(json["name"], "weather");
|
||||||
assert_eq!(json["display_hint"], "icon_value");
|
assert_eq!(json["display_hint"], "icon_value");
|
||||||
@@ -58,11 +67,22 @@ async fn list_widgets() {
|
|||||||
let w1 = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
|
let w1 = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
|
||||||
let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#;
|
let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#;
|
||||||
|
|
||||||
app.clone().oneshot(json_request("POST", "/api/widgets", Some(w1))).await.unwrap();
|
app.clone()
|
||||||
app.clone().oneshot(json_request("POST", "/api/widgets", Some(w2))).await.unwrap();
|
.oneshot(json_request("POST", "/api/widgets", Some(w1)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
app.clone()
|
||||||
|
.oneshot(json_request("POST", "/api/widgets", Some(w2)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let resp = app.oneshot(json_request("GET", "/api/widgets", None)).await.unwrap();
|
let resp = app
|
||||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
.oneshot(json_request("GET", "/api/widgets", None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let json: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
|
let json: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(json.len(), 2);
|
assert_eq!(json.len(), 2);
|
||||||
}
|
}
|
||||||
@@ -71,13 +91,24 @@ async fn list_widgets() {
|
|||||||
async fn delete_widget() {
|
async fn delete_widget() {
|
||||||
let app = test_app();
|
let app = test_app();
|
||||||
|
|
||||||
let body = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
|
let body =
|
||||||
app.clone().oneshot(json_request("POST", "/api/widgets", Some(body))).await.unwrap();
|
r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
|
||||||
|
app.clone()
|
||||||
|
.oneshot(json_request("POST", "/api/widgets", Some(body)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let resp = app.clone().oneshot(json_request("DELETE", "/api/widgets/1", None)).await.unwrap();
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(json_request("DELETE", "/api/widgets/1", None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
let resp = app.oneshot(json_request("GET", "/api/widgets/1", None)).await.unwrap();
|
let resp = app
|
||||||
|
.oneshot(json_request("GET", "/api/widgets/1", None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,13 +126,22 @@ async fn create_and_get_data_source() {
|
|||||||
"headers": []
|
"headers": []
|
||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
let resp = app.clone().oneshot(json_request("POST", "/api/data-sources", Some(body))).await.unwrap();
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(json_request("POST", "/api/data-sources", Some(body)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
let resp = app.oneshot(json_request("GET", "/api/data-sources/10", None)).await.unwrap();
|
let resp = app
|
||||||
|
.oneshot(json_request("GET", "/api/data-sources/10", None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(json["name"], "weather_api");
|
assert_eq!(json["name"], "weather_api");
|
||||||
assert_eq!(json["poll_interval_secs"], 300);
|
assert_eq!(json["poll_interval_secs"], 300);
|
||||||
@@ -124,13 +164,22 @@ async fn update_and_get_layout() {
|
|||||||
}
|
}
|
||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
let resp = app.clone().oneshot(json_request("PUT", "/api/layout", Some(body))).await.unwrap();
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(json_request("PUT", "/api/layout", Some(body)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
let resp = app.oneshot(json_request("GET", "/api/layout", None)).await.unwrap();
|
let resp = app
|
||||||
|
.oneshot(json_request("GET", "/api/layout", None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(json["root"]["type"], "container");
|
assert_eq!(json["root"]["type"], "container");
|
||||||
assert_eq!(json["root"]["direction"], "row");
|
assert_eq!(json["root"]["direction"], "row");
|
||||||
@@ -141,9 +190,16 @@ async fn update_and_get_layout() {
|
|||||||
async fn get_nonexistent_returns_404() {
|
async fn get_nonexistent_returns_404() {
|
||||||
let app = test_app();
|
let app = test_app();
|
||||||
|
|
||||||
let resp = app.clone().oneshot(json_request("GET", "/api/widgets/99", None)).await.unwrap();
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(json_request("GET", "/api/widgets/99", None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
let resp = app.oneshot(json_request("GET", "/api/data-sources/99", None)).await.unwrap();
|
let resp = app
|
||||||
|
.oneshot(json_request("GET", "/api/data-sources/99", None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,26 +14,32 @@ pub enum HttpJsonError {
|
|||||||
Parse(String),
|
Parse(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpJsonAdapter {
|
impl Default for HttpJsonAdapter {
|
||||||
pub fn new() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl HttpJsonAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn json_to_value(json: serde_json::Value) -> Value {
|
fn json_to_value(json: serde_json::Value) -> Value {
|
||||||
match json {
|
match json {
|
||||||
serde_json::Value::Null => Value::Null,
|
serde_json::Value::Null => Value::Null,
|
||||||
serde_json::Value::Bool(b) => Value::Bool(b),
|
serde_json::Value::Bool(b) => Value::Bool(b),
|
||||||
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
|
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
|
||||||
serde_json::Value::String(s) => Value::String(s),
|
serde_json::Value::String(s) => Value::String(s),
|
||||||
serde_json::Value::Array(arr) => {
|
serde_json::Value::Array(arr) => Value::Array(arr.into_iter().map(json_to_value).collect()),
|
||||||
Value::Array(arr.into_iter().map(json_to_value).collect())
|
serde_json::Value::Object(map) => Value::Object(
|
||||||
}
|
map.into_iter()
|
||||||
serde_json::Value::Object(map) => {
|
.map(|(k, v)| (k, json_to_value(v)))
|
||||||
Value::Object(map.into_iter().map(|(k, v)| (k, json_to_value(v))).collect())
|
.collect(),
|
||||||
}
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
use std::time::Duration;
|
use axum::{Router, response::Json, routing::get};
|
||||||
use axum::{Router, routing::get, response::Json};
|
|
||||||
use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value};
|
use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value};
|
||||||
use http_json::HttpJsonAdapter;
|
use http_json::HttpJsonAdapter;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
async fn start_fake_api() -> String {
|
async fn start_fake_api() -> String {
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/weather", get(|| async {
|
.route(
|
||||||
|
"/weather",
|
||||||
|
get(|| async {
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"main": {"temp": 5.4, "humidity": 80},
|
"main": {"temp": 5.4, "humidity": 80},
|
||||||
"weather": [{"icon": "cloud_rain"}]
|
"weather": [{"icon": "cloud_rain"}]
|
||||||
}))
|
}))
|
||||||
}))
|
}),
|
||||||
.route("/simple", get(|| async {
|
)
|
||||||
Json(serde_json::json!({"value": "hello"}))
|
.route(
|
||||||
}))
|
"/simple",
|
||||||
|
get(|| async { Json(serde_json::json!({"value": "hello"})) }),
|
||||||
|
)
|
||||||
.route("/not-json", get(|| async { "plain text" }));
|
.route("/not-json", get(|| async { "plain text" }));
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
@@ -46,10 +50,7 @@ async fn polls_url_and_returns_nested_json_as_value() {
|
|||||||
|
|
||||||
let result = adapter.poll(&source).await.unwrap();
|
let result = adapter.poll(&source).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(result.get_path("$.main.temp"), Some(&Value::Number(5.4)));
|
||||||
result.get_path("$.main.temp"),
|
|
||||||
Some(&Value::Number(5.4))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.get_path("$.main.humidity"),
|
result.get_path("$.main.humidity"),
|
||||||
Some(&Value::Number(80.0))
|
Some(&Value::Number(80.0))
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ domain.workspace = true
|
|||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
md5 = "0.7"
|
||||||
|
fastrand = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ pub enum MediaError {
|
|||||||
Request(#[from] reqwest::Error),
|
Request(#[from] reqwest::Error),
|
||||||
#[error("no url configured")]
|
#[error("no url configured")]
|
||||||
NoUrl,
|
NoUrl,
|
||||||
|
#[error("missing field in headers: {0}")]
|
||||||
|
MissingField(&'static str),
|
||||||
#[error("parse: {0}")]
|
#[error("parse: {0}")]
|
||||||
Parse(String),
|
Parse(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,33 +2,61 @@ mod error;
|
|||||||
|
|
||||||
pub use error::MediaError;
|
pub use error::MediaError;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use domain::{DataSource, DataSourcePort, Value};
|
use domain::{DataSource, DataSourcePort, Value};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
pub struct MediaAdapter {
|
pub struct MediaAdapter {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaAdapter {
|
impl Default for MediaAdapter {
|
||||||
pub fn new() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl MediaAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_header<'a>(headers: &'a [(String, String)], key: &str) -> Option<&'a str> {
|
||||||
|
headers
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||||
|
.map(|(_, v)| v.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subsonic_token(password: &str, salt: &str) -> String {
|
||||||
|
format!("{:x}", md5::compute(format!("{password}{salt}")))
|
||||||
|
}
|
||||||
|
|
||||||
impl DataSourcePort for MediaAdapter {
|
impl DataSourcePort for MediaAdapter {
|
||||||
type Error = MediaError;
|
type Error = MediaError;
|
||||||
|
|
||||||
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
|
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
|
||||||
let base_url = source.config.url.as_ref().ok_or(MediaError::NoUrl)?;
|
let base_url = source.config.url.as_ref().ok_or(MediaError::NoUrl)?;
|
||||||
let api_key = source.config.api_key.as_deref().unwrap_or("");
|
let username = find_header(&source.config.headers, "username")
|
||||||
|
.ok_or(MediaError::MissingField("username"))?;
|
||||||
|
let password = find_header(&source.config.headers, "password")
|
||||||
|
.ok_or(MediaError::MissingField("password"))?;
|
||||||
|
|
||||||
|
let salt: String = (0..12).map(|_| fastrand::alphanumeric()).collect();
|
||||||
|
let token = subsonic_token(password, &salt);
|
||||||
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{base_url}/rest/getNowPlaying.view?u=kframe&t={api_key}&s=salt&v=1.16.1&c=kframe&f=json"
|
"{base_url}/rest/getNowPlaying.view?u={username}&t={token}&s={salt}&v=1.16.1&c=kframe&f=json"
|
||||||
);
|
);
|
||||||
|
|
||||||
let resp = self.client.get(&url).send().await.map_err(MediaError::Request)?;
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(MediaError::Request)?;
|
||||||
let json: serde_json::Value = resp.json().await.map_err(MediaError::Request)?;
|
let json: serde_json::Value = resp.json().await.map_err(MediaError::Request)?;
|
||||||
|
|
||||||
let entries = json["subsonic-response"]["nowPlaying"]["entry"]
|
let entries = json["subsonic-response"]["nowPlaying"]["entry"]
|
||||||
@@ -45,15 +73,18 @@ impl DataSourcePort for MediaAdapter {
|
|||||||
let entry = &entries[0];
|
let entry = &entries[0];
|
||||||
let mut result = BTreeMap::new();
|
let mut result = BTreeMap::new();
|
||||||
result.insert("playing".into(), Value::Bool(true));
|
result.insert("playing".into(), Value::Bool(true));
|
||||||
result.insert("title".into(), Value::String(
|
result.insert(
|
||||||
entry["title"].as_str().unwrap_or("Unknown").into()
|
"title".into(),
|
||||||
));
|
Value::String(entry["title"].as_str().unwrap_or("Unknown").into()),
|
||||||
result.insert("artist".into(), Value::String(
|
);
|
||||||
entry["artist"].as_str().unwrap_or("Unknown").into()
|
result.insert(
|
||||||
));
|
"artist".into(),
|
||||||
result.insert("album".into(), Value::String(
|
Value::String(entry["artist"].as_str().unwrap_or("Unknown").into()),
|
||||||
entry["album"].as_str().unwrap_or("Unknown").into()
|
);
|
||||||
));
|
result.insert(
|
||||||
|
"album".into(),
|
||||||
|
Value::String(entry["album"].as_str().unwrap_or("Unknown").into()),
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(duration) = entry["duration"].as_u64() {
|
if let Some(duration) = entry["duration"].as_u64() {
|
||||||
result.insert("duration".into(), Value::Number(duration as f64));
|
result.insert("duration".into(), Value::Number(duration as f64));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::time::Duration;
|
|
||||||
use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value};
|
use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value};
|
||||||
use media_adapter::MediaAdapter;
|
use media_adapter::MediaAdapter;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
fn subsonic_response(playing: bool) -> serde_json::Value {
|
fn subsonic_response(playing: bool) -> serde_json::Value {
|
||||||
if playing {
|
if playing {
|
||||||
@@ -28,10 +28,10 @@ fn subsonic_response(playing: bool) -> serde_json::Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn start_fake_subsonic(playing: bool) -> String {
|
async fn start_fake_subsonic(playing: bool) -> String {
|
||||||
let app = axum::Router::new()
|
let app = axum::Router::new().route(
|
||||||
.route("/rest/getNowPlaying.view", axum::routing::get(move || async move {
|
"/rest/getNowPlaying.view",
|
||||||
axum::response::Json(subsonic_response(playing))
|
axum::routing::get(move || async move { axum::response::Json(subsonic_response(playing)) }),
|
||||||
}));
|
);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
@@ -47,8 +47,11 @@ fn make_source(url: String) -> DataSource {
|
|||||||
poll_interval: Duration::from_secs(5),
|
poll_interval: Duration::from_secs(5),
|
||||||
config: DataSourceConfig {
|
config: DataSourceConfig {
|
||||||
url: Some(url),
|
url: Some(url),
|
||||||
headers: vec![],
|
headers: vec![
|
||||||
api_key: Some("testtoken".into()),
|
("username".into(), "test".into()),
|
||||||
|
("password".into(), "testpass".into()),
|
||||||
|
],
|
||||||
|
api_key: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,8 +65,14 @@ async fn returns_now_playing_info() {
|
|||||||
let result = adapter.poll(&source).await.unwrap();
|
let result = adapter.poll(&source).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(result.get_path("$.playing"), Some(&Value::Bool(true)));
|
assert_eq!(result.get_path("$.playing"), Some(&Value::Bool(true)));
|
||||||
assert_eq!(result.get_path("$.title"), Some(&Value::String("Believer".into())));
|
assert_eq!(
|
||||||
assert_eq!(result.get_path("$.artist"), Some(&Value::String("Imagine Dragons".into())));
|
result.get_path("$.title"),
|
||||||
|
Some(&Value::String("Believer".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.get_path("$.artist"),
|
||||||
|
Some(&Value::String("Imagine Dragons".into()))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -10,21 +10,32 @@ pub struct RssAdapter {
|
|||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RssAdapter {
|
impl Default for RssAdapter {
|
||||||
pub fn new() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RssAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DataSourcePort for RssAdapter {
|
impl DataSourcePort for RssAdapter {
|
||||||
type Error = RssError;
|
type Error = RssError;
|
||||||
|
|
||||||
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
|
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
|
||||||
let url = source.config.url.as_ref().ok_or(RssError::NoUrl)?;
|
let url = source.config.url.as_ref().ok_or(RssError::NoUrl)?;
|
||||||
|
|
||||||
let resp = self.client.get(url).send().await.map_err(RssError::Request)?;
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(RssError::Request)?;
|
||||||
let xml = resp.text().await.map_err(RssError::Request)?;
|
let xml = resp.text().await.map_err(RssError::Request)?;
|
||||||
|
|
||||||
parser::parse_rss(&xml)
|
parser::parse_rss(&xml)
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ pub fn parse_rss(xml: &str) -> Result<Value, RssError> {
|
|||||||
}
|
}
|
||||||
Ok(Event::End(e)) => {
|
Ok(Event::End(e)) => {
|
||||||
let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
||||||
if tag == "item" {
|
if tag == "item"
|
||||||
if let Some(item) = current_item.take() {
|
&& let Some(item) = current_item.take()
|
||||||
|
{
|
||||||
items.push(Value::Object(item));
|
items.push(Value::Object(item));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
current_tag.clear();
|
current_tag.clear();
|
||||||
}
|
}
|
||||||
Ok(Event::Text(e)) => {
|
Ok(Event::Text(e)) => {
|
||||||
@@ -52,12 +52,12 @@ pub fn parse_rss(xml: &str) -> Result<Value, RssError> {
|
|||||||
}
|
}
|
||||||
Ok(Event::CData(e)) => {
|
Ok(Event::CData(e)) => {
|
||||||
let text = String::from_utf8_lossy(&e).to_string();
|
let text = String::from_utf8_lossy(&e).to_string();
|
||||||
if !current_tag.is_empty() {
|
if !current_tag.is_empty()
|
||||||
if let Some(item) = current_item.as_mut() {
|
&& let Some(item) = current_item.as_mut()
|
||||||
|
{
|
||||||
item.insert(current_tag.clone(), Value::String(text));
|
item.insert(current_tag.clone(), Value::String(text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(Event::Eof) => break,
|
Ok(Event::Eof) => break,
|
||||||
Err(e) => return Err(RssError::Parse(format!("{e}"))),
|
Err(e) => return Err(RssError::Parse(format!("{e}"))),
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use domain::Value;
|
use domain::Value;
|
||||||
use rss_adapter::{parse_rss, RssError};
|
use rss_adapter::{RssError, parse_rss};
|
||||||
|
|
||||||
const SAMPLE_RSS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
const SAMPLE_RSS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@@ -23,8 +23,20 @@ const SAMPLE_RSS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
fn parses_rss_into_value() {
|
fn parses_rss_into_value() {
|
||||||
let result = parse_rss(SAMPLE_RSS).unwrap();
|
let result = parse_rss(SAMPLE_RSS).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.get_path("$.title"), Some(&Value::String("Test Feed".into())));
|
assert_eq!(
|
||||||
assert_eq!(result.get_path("$.items[0].title"), Some(&Value::String("First Article".into())));
|
result.get_path("$.title"),
|
||||||
assert_eq!(result.get_path("$.items[1].title"), Some(&Value::String("Second Article".into())));
|
Some(&Value::String("Test Feed".into()))
|
||||||
assert_eq!(result.get_path("$.items[0].description"), Some(&Value::String("Description of first article".into())));
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.get_path("$.items[0].title"),
|
||||||
|
Some(&Value::String("First Article".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.get_path("$.items[1].title"),
|
||||||
|
Some(&Value::String("Second Article".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.get_path("$.items[0].description"),
|
||||||
|
Some(&Value::String("Description of first article".into()))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
use client_domain::NetworkPort;
|
||||||
|
use protocol::MAX_FRAME_SIZE;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use client_domain::NetworkPort;
|
|
||||||
use protocol::MAX_FRAME_SIZE;
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum TcpClientError {
|
pub enum TcpClientError {
|
||||||
@@ -14,13 +14,14 @@ pub enum TcpClientError {
|
|||||||
FrameTooLarge(usize),
|
FrameTooLarge(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
pub struct StdTcpClient {
|
pub struct StdTcpClient {
|
||||||
stream: Option<TcpStream>,
|
stream: Option<TcpStream>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StdTcpClient {
|
impl StdTcpClient {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { stream: None }
|
Self::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +31,9 @@ impl NetworkPort for StdTcpClient {
|
|||||||
fn connect(&mut self, addr: &str) -> Result<(), Self::Error> {
|
fn connect(&mut self, addr: &str) -> Result<(), Self::Error> {
|
||||||
let stream = TcpStream::connect(addr).map_err(TcpClientError::Io)?;
|
let stream = TcpStream::connect(addr).map_err(TcpClientError::Io)?;
|
||||||
stream.set_nonblocking(true).map_err(TcpClientError::Io)?;
|
stream.set_nonblocking(true).map_err(TcpClientError::Io)?;
|
||||||
stream.set_read_timeout(Some(Duration::from_millis(10))).map_err(TcpClientError::Io)?;
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(10)))
|
||||||
|
.map_err(TcpClientError::Io)?;
|
||||||
self.stream = Some(stream);
|
self.stream = Some(stream);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -63,7 +66,9 @@ impl NetworkPort for StdTcpClient {
|
|||||||
|
|
||||||
let mut payload = vec![0u8; len];
|
let mut payload = vec![0u8; len];
|
||||||
stream.set_nonblocking(false).map_err(TcpClientError::Io)?;
|
stream.set_nonblocking(false).map_err(TcpClientError::Io)?;
|
||||||
stream.read_exact(&mut payload).map_err(TcpClientError::Io)?;
|
stream
|
||||||
|
.read_exact(&mut payload)
|
||||||
|
.map_err(TcpClientError::Io)?;
|
||||||
stream.set_nonblocking(true).map_err(TcpClientError::Io)?;
|
stream.set_nonblocking(true).map_err(TcpClientError::Io)?;
|
||||||
|
|
||||||
Ok(Some(payload))
|
Ok(Some(payload))
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
use tokio::sync::broadcast;
|
|
||||||
use domain::{
|
|
||||||
BroadcastPort, Layout, WidgetId, WidgetState,
|
|
||||||
};
|
|
||||||
use protocol::{
|
|
||||||
ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode,
|
|
||||||
encode,
|
|
||||||
};
|
|
||||||
use crate::error::TcpServerError;
|
use crate::error::TcpServerError;
|
||||||
|
use domain::{BroadcastPort, Layout, WidgetId, WidgetState};
|
||||||
|
use protocol::{ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode, encode};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
pub struct TcpBroadcaster {
|
pub struct TcpBroadcaster {
|
||||||
tx: broadcast::Sender<Vec<u8>>,
|
tx: broadcast::Sender<Vec<u8>>,
|
||||||
@@ -37,13 +32,14 @@ impl BroadcastPort for TcpBroadcaster {
|
|||||||
widgets: &[(WidgetId, WidgetState)],
|
widgets: &[(WidgetId, WidgetState)],
|
||||||
) -> Result<(), Self::Error> {
|
) -> Result<(), Self::Error> {
|
||||||
let wire_layout: WireLayoutNode = (&layout.root).into();
|
let wire_layout: WireLayoutNode = (&layout.root).into();
|
||||||
let wire_widgets: Vec<WidgetDescriptor> = widgets.iter().map(|(id, state)| {
|
let wire_widgets: Vec<WidgetDescriptor> = widgets
|
||||||
WidgetDescriptor {
|
.iter()
|
||||||
|
.map(|(id, state)| WidgetDescriptor {
|
||||||
id: *id,
|
id: *id,
|
||||||
display_hint: WireDisplayHint::IconValue,
|
display_hint: WireDisplayHint::IconValue,
|
||||||
state: state.into(),
|
state: state.into(),
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
let msg = ServerMessage::ScreenUpdate {
|
let msg = ServerMessage::ScreenUpdate {
|
||||||
layout: wire_layout,
|
layout: wire_layout,
|
||||||
@@ -58,13 +54,14 @@ impl BroadcastPort for TcpBroadcaster {
|
|||||||
&self,
|
&self,
|
||||||
updates: &[(WidgetId, WidgetState)],
|
updates: &[(WidgetId, WidgetState)],
|
||||||
) -> Result<(), Self::Error> {
|
) -> Result<(), Self::Error> {
|
||||||
let wire_widgets: Vec<WidgetDescriptor> = updates.iter().map(|(id, state)| {
|
let wire_widgets: Vec<WidgetDescriptor> = updates
|
||||||
WidgetDescriptor {
|
.iter()
|
||||||
|
.map(|(id, state)| WidgetDescriptor {
|
||||||
id: *id,
|
id: *id,
|
||||||
display_hint: WireDisplayHint::IconValue,
|
display_hint: WireDisplayHint::IconValue,
|
||||||
state: state.into(),
|
state: state.into(),
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
let msg = ServerMessage::DataUpdate {
|
let msg = ServerMessage::DataUpdate {
|
||||||
widgets: wire_widgets,
|
widgets: wire_widgets,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use tokio::sync::broadcast;
|
|
||||||
use domain::{EventPublisher, DomainEvent};
|
|
||||||
use crate::error::TcpServerError;
|
use crate::error::TcpServerError;
|
||||||
|
use domain::{DomainEvent, EventPublisher};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
pub struct TcpEventBus {
|
pub struct TcpEventBus {
|
||||||
tx: broadcast::Sender<DomainEvent>,
|
tx: broadcast::Sender<DomainEvent>,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
mod error;
|
|
||||||
mod broadcaster;
|
mod broadcaster;
|
||||||
|
mod error;
|
||||||
mod event_bus;
|
mod event_bus;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
pub use error::TcpServerError;
|
|
||||||
pub use broadcaster::TcpBroadcaster;
|
pub use broadcaster::TcpBroadcaster;
|
||||||
|
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,10 +1,10 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
use tracing::{info, warn};
|
|
||||||
use crate::broadcaster::TcpBroadcaster;
|
use crate::broadcaster::TcpBroadcaster;
|
||||||
use crate::error::TcpServerError;
|
use crate::error::TcpServerError;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
pub async fn run_tcp_server(
|
pub async fn run_tcp_server(
|
||||||
addr: &str,
|
addr: &str,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use std::time::Duration;
|
|
||||||
use domain::*;
|
use domain::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct DataSourceDto {
|
pub struct DataSourceDto {
|
||||||
@@ -21,11 +21,12 @@ impl From<&DataSource> for DataSourceDto {
|
|||||||
source_type: match ds.source_type {
|
source_type: match ds.source_type {
|
||||||
DataSourceType::Weather => "weather",
|
DataSourceType::Weather => "weather",
|
||||||
DataSourceType::Media => "media",
|
DataSourceType::Media => "media",
|
||||||
DataSourceType::Xtb => "xtb",
|
|
||||||
DataSourceType::Rss => "rss",
|
DataSourceType::Rss => "rss",
|
||||||
DataSourceType::HttpJson => "http_json",
|
DataSourceType::HttpJson => "http_json",
|
||||||
DataSourceType::Webhook => "webhook",
|
DataSourceType::Webhook => "webhook",
|
||||||
}.into(),
|
}
|
||||||
|
.into(),
|
||||||
poll_interval_secs: ds.poll_interval.as_secs(),
|
poll_interval_secs: ds.poll_interval.as_secs(),
|
||||||
url: ds.config.url.clone(),
|
url: ds.config.url.clone(),
|
||||||
api_key: ds.config.api_key.clone(),
|
api_key: ds.config.api_key.clone(),
|
||||||
@@ -39,7 +40,7 @@ impl DataSourceDto {
|
|||||||
let source_type = match self.source_type.as_str() {
|
let source_type = match self.source_type.as_str() {
|
||||||
"weather" => DataSourceType::Weather,
|
"weather" => DataSourceType::Weather,
|
||||||
"media" => DataSourceType::Media,
|
"media" => DataSourceType::Media,
|
||||||
"xtb" => DataSourceType::Xtb,
|
|
||||||
"rss" => DataSourceType::Rss,
|
"rss" => DataSourceType::Rss,
|
||||||
"http_json" => DataSourceType::HttpJson,
|
"http_json" => DataSourceType::HttpJson,
|
||||||
"webhook" => DataSourceType::Webhook,
|
"webhook" => DataSourceType::Webhook,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use domain::*;
|
use domain::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct SizingDto {
|
pub struct SizingDto {
|
||||||
@@ -41,18 +41,27 @@ impl From<&LayoutNode> for LayoutNodeDto {
|
|||||||
LayoutNode::Leaf(id) => Self {
|
LayoutNode::Leaf(id) => Self {
|
||||||
node_type: "leaf".into(),
|
node_type: "leaf".into(),
|
||||||
widget_id: Some(*id),
|
widget_id: Some(*id),
|
||||||
direction: None, gap: None, padding: None, children: None,
|
direction: None,
|
||||||
|
gap: None,
|
||||||
|
padding: None,
|
||||||
|
children: None,
|
||||||
},
|
},
|
||||||
LayoutNode::Container(c) => Self {
|
LayoutNode::Container(c) => Self {
|
||||||
node_type: "container".into(),
|
node_type: "container".into(),
|
||||||
widget_id: None,
|
widget_id: None,
|
||||||
direction: Some(match c.direction {
|
direction: Some(
|
||||||
|
match c.direction {
|
||||||
Direction::Row => "row",
|
Direction::Row => "row",
|
||||||
Direction::Column => "column",
|
Direction::Column => "column",
|
||||||
}.into()),
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
gap: Some(c.gap),
|
gap: Some(c.gap),
|
||||||
padding: Some(c.padding),
|
padding: Some(c.padding),
|
||||||
children: Some(c.children.iter().map(|ch| LayoutChildDto {
|
children: Some(
|
||||||
|
c.children
|
||||||
|
.iter()
|
||||||
|
.map(|ch| LayoutChildDto {
|
||||||
sizing: SizingDto {
|
sizing: SizingDto {
|
||||||
sizing_type: match ch.sizing {
|
sizing_type: match ch.sizing {
|
||||||
Sizing::Fixed(_) => "fixed".into(),
|
Sizing::Fixed(_) => "fixed".into(),
|
||||||
@@ -64,7 +73,9 @@ impl From<&LayoutNode> for LayoutNodeDto {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
node: (&ch.node).into(),
|
node: (&ch.node).into(),
|
||||||
}).collect()),
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +94,9 @@ impl LayoutNodeDto {
|
|||||||
"column" => Direction::Column,
|
"column" => Direction::Column,
|
||||||
d => return Err(format!("unknown direction: {d}")),
|
d => return Err(format!("unknown direction: {d}")),
|
||||||
};
|
};
|
||||||
let children = self.children.ok_or("missing children")?
|
let children = self
|
||||||
|
.children
|
||||||
|
.ok_or("missing children")?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|ch| {
|
.map(|ch| {
|
||||||
let sizing = match ch.sizing.sizing_type.as_str() {
|
let sizing = match ch.sizing.sizing_type.as_str() {
|
||||||
@@ -110,12 +123,16 @@ impl LayoutNodeDto {
|
|||||||
|
|
||||||
impl From<&Layout> for LayoutDto {
|
impl From<&Layout> for LayoutDto {
|
||||||
fn from(l: &Layout) -> Self {
|
fn from(l: &Layout) -> Self {
|
||||||
Self { root: (&l.root).into() }
|
Self {
|
||||||
|
root: (&l.root).into(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LayoutDto {
|
impl LayoutDto {
|
||||||
pub fn into_domain(self) -> Result<Layout, String> {
|
pub fn into_domain(self) -> Result<Layout, String> {
|
||||||
Ok(Layout { root: self.root.into_domain()? })
|
Ok(Layout {
|
||||||
|
root: self.root.into_domain()?,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
pub mod widget;
|
|
||||||
pub mod data_source;
|
pub mod data_source;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod preset;
|
pub mod preset;
|
||||||
|
pub mod widget;
|
||||||
|
|
||||||
pub use widget::{KeyMappingDto, WidgetDto, CreateWidgetDto};
|
|
||||||
pub use data_source::DataSourceDto;
|
pub use data_source::DataSourceDto;
|
||||||
pub use layout::{LayoutDto, LayoutNodeDto, LayoutChildDto, SizingDto};
|
pub use layout::{LayoutChildDto, LayoutDto, LayoutNodeDto, SizingDto};
|
||||||
pub use preset::{PresetDto, CreatePresetDto};
|
pub use preset::{CreatePresetDto, PresetDto};
|
||||||
|
pub use widget::{CreateWidgetDto, KeyMappingDto, WidgetDto};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use domain::*;
|
|
||||||
use crate::layout::LayoutDto;
|
use crate::layout::LayoutDto;
|
||||||
|
use domain::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct PresetDto {
|
pub struct PresetDto {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use domain::*;
|
use domain::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct KeyMappingDto {
|
pub struct KeyMappingDto {
|
||||||
@@ -28,7 +28,9 @@ pub struct CreateWidgetDto {
|
|||||||
pub max_data_size: u16,
|
pub max_data_size: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_max_data_size() -> u16 { 2048 }
|
fn default_max_data_size() -> u16 {
|
||||||
|
2048
|
||||||
|
}
|
||||||
|
|
||||||
impl From<&WidgetConfig> for WidgetDto {
|
impl From<&WidgetConfig> for WidgetDto {
|
||||||
fn from(w: &WidgetConfig) -> Self {
|
fn from(w: &WidgetConfig) -> Self {
|
||||||
@@ -39,12 +41,17 @@ impl From<&WidgetConfig> for WidgetDto {
|
|||||||
DisplayHint::IconValue => "icon_value",
|
DisplayHint::IconValue => "icon_value",
|
||||||
DisplayHint::TextBlock => "text_block",
|
DisplayHint::TextBlock => "text_block",
|
||||||
DisplayHint::KeyValue => "key_value",
|
DisplayHint::KeyValue => "key_value",
|
||||||
}.into(),
|
}
|
||||||
|
.into(),
|
||||||
data_source_id: w.data_source_id,
|
data_source_id: w.data_source_id,
|
||||||
mappings: w.mappings.iter().map(|m| KeyMappingDto {
|
mappings: w
|
||||||
|
.mappings
|
||||||
|
.iter()
|
||||||
|
.map(|m| KeyMappingDto {
|
||||||
source_path: m.source_path.clone(),
|
source_path: m.source_path.clone(),
|
||||||
target_key: m.target_key.clone(),
|
target_key: m.target_key.clone(),
|
||||||
}).collect(),
|
})
|
||||||
|
.collect(),
|
||||||
max_data_size: w.max_data_size,
|
max_data_size: w.max_data_size,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,10 +70,14 @@ impl CreateWidgetDto {
|
|||||||
name: self.name,
|
name: self.name,
|
||||||
display_hint: hint,
|
display_hint: hint,
|
||||||
data_source_id: self.data_source_id,
|
data_source_id: self.data_source_id,
|
||||||
mappings: self.mappings.into_iter().map(|m| KeyMapping {
|
mappings: self
|
||||||
|
.mappings
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| KeyMapping {
|
||||||
source_path: m.source_path,
|
source_path: m.source_path,
|
||||||
target_key: m.target_key,
|
target_key: m.target_key,
|
||||||
}).collect(),
|
})
|
||||||
|
.collect(),
|
||||||
max_data_size: self.max_data_size,
|
max_data_size: self.max_data_size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use std::fmt;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, EventPublisher, DomainEvent,
|
ConfigRepository, DataSource, DataSourceId, DataSourceValidationError, DomainEvent,
|
||||||
WidgetConfig, WidgetId,
|
EventPublisher, Layout, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
||||||
DataSource, DataSourceId, DataSourceValidationError,
|
|
||||||
Layout, LayoutPreset, LayoutPresetId,
|
|
||||||
};
|
};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
pub struct ConfigService<'a, C, E> {
|
pub struct ConfigService<'a, C, E> {
|
||||||
config: &'a C,
|
config: &'a C,
|
||||||
@@ -34,78 +32,173 @@ where
|
|||||||
Self { config, events }
|
Self { config, events }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_widget(&self, widget: WidgetConfig) -> Result<(), ConfigError<C::Error, E::Error>> {
|
pub async fn create_widget(
|
||||||
self.config.save_widget(&widget).await.map_err(ConfigError::Repository)?;
|
&self,
|
||||||
self.events.publish(DomainEvent::WidgetCreated { id: widget.id }).await.map_err(ConfigError::Event)?;
|
widget: WidgetConfig,
|
||||||
|
) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||||
|
self.config
|
||||||
|
.save_widget(&widget)
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Repository)?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::WidgetCreated { id: widget.id })
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Event)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_widget(&self, widget: WidgetConfig) -> Result<(), ConfigError<C::Error, E::Error>> {
|
pub async fn update_widget(
|
||||||
self.config.save_widget(&widget).await.map_err(ConfigError::Repository)?;
|
&self,
|
||||||
self.events.publish(DomainEvent::WidgetUpdated { id: widget.id }).await.map_err(ConfigError::Event)?;
|
widget: WidgetConfig,
|
||||||
|
) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||||
|
self.config
|
||||||
|
.save_widget(&widget)
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Repository)?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::WidgetUpdated { id: widget.id })
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Event)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_widget(&self, id: WidgetId) -> Result<(), ConfigError<C::Error, E::Error>> {
|
pub async fn delete_widget(&self, id: WidgetId) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||||
self.config.delete_widget(id).await.map_err(ConfigError::Repository)?;
|
self.config
|
||||||
self.events.publish(DomainEvent::WidgetDeleted { id }).await.map_err(ConfigError::Event)?;
|
.delete_widget(id)
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Repository)?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::WidgetDeleted { id })
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Event)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_data_source(&self, source: DataSource) -> Result<(), ConfigError<C::Error, E::Error>> {
|
pub async fn create_data_source(
|
||||||
|
&self,
|
||||||
|
source: DataSource,
|
||||||
|
) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||||
let errors = source.validate();
|
let errors = source.validate();
|
||||||
if !errors.is_empty() {
|
if !errors.is_empty() {
|
||||||
return Err(ConfigError::Validation(errors));
|
return Err(ConfigError::Validation(errors));
|
||||||
}
|
}
|
||||||
self.config.save_data_source(&source).await.map_err(ConfigError::Repository)?;
|
self.config
|
||||||
self.events.publish(DomainEvent::DataSourceAdded { id: source.id }).await.map_err(ConfigError::Event)?;
|
.save_data_source(&source)
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Repository)?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::DataSourceAdded { id: source.id })
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Event)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_data_source(&self, source: DataSource) -> Result<(), ConfigError<C::Error, E::Error>> {
|
pub async fn update_data_source(
|
||||||
|
&self,
|
||||||
|
source: DataSource,
|
||||||
|
) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||||
let errors = source.validate();
|
let errors = source.validate();
|
||||||
if !errors.is_empty() {
|
if !errors.is_empty() {
|
||||||
return Err(ConfigError::Validation(errors));
|
return Err(ConfigError::Validation(errors));
|
||||||
}
|
}
|
||||||
self.config.save_data_source(&source).await.map_err(ConfigError::Repository)?;
|
self.config
|
||||||
self.events.publish(DomainEvent::DataSourceUpdated { id: source.id }).await.map_err(ConfigError::Event)?;
|
.save_data_source(&source)
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Repository)?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::DataSourceUpdated { id: source.id })
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Event)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_data_source(&self, id: DataSourceId) -> Result<(), ConfigError<C::Error, E::Error>> {
|
pub async fn delete_data_source(
|
||||||
self.config.delete_data_source(id).await.map_err(ConfigError::Repository)?;
|
&self,
|
||||||
self.events.publish(DomainEvent::DataSourceRemoved { id }).await.map_err(ConfigError::Event)?;
|
id: DataSourceId,
|
||||||
|
) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||||
|
self.config
|
||||||
|
.delete_data_source(id)
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Repository)?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::DataSourceRemoved { id })
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Event)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_layout(&self, layout: Layout) -> Result<(), ConfigError<C::Error, E::Error>> {
|
pub async fn update_layout(
|
||||||
self.config.save_layout(&layout).await.map_err(ConfigError::Repository)?;
|
&self,
|
||||||
self.events.publish(DomainEvent::LayoutChanged { layout }).await.map_err(ConfigError::Event)?;
|
layout: Layout,
|
||||||
|
) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||||
|
self.config
|
||||||
|
.save_layout(&layout)
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Repository)?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::LayoutChanged { layout })
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Event)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save_preset(&self, preset: LayoutPreset) -> Result<(), ConfigError<C::Error, E::Error>> {
|
pub async fn save_preset(
|
||||||
self.config.save_preset(&preset).await.map_err(ConfigError::Repository)?;
|
&self,
|
||||||
self.events.publish(DomainEvent::LayoutPresetSaved { id: preset.id }).await.map_err(ConfigError::Event)?;
|
preset: LayoutPreset,
|
||||||
|
) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||||
|
self.config
|
||||||
|
.save_preset(&preset)
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Repository)?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::LayoutPresetSaved { id: preset.id })
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Event)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_preset(&self, id: LayoutPresetId) -> Result<(), ConfigError<C::Error, E::Error>> {
|
pub async fn load_preset(
|
||||||
let preset = self.config.get_preset(id).await
|
&self,
|
||||||
|
id: LayoutPresetId,
|
||||||
|
) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||||
|
let preset = self
|
||||||
|
.config
|
||||||
|
.get_preset(id)
|
||||||
|
.await
|
||||||
.map_err(ConfigError::Repository)?
|
.map_err(ConfigError::Repository)?
|
||||||
.ok_or(ConfigError::NotFound)?;
|
.ok_or(ConfigError::NotFound)?;
|
||||||
|
|
||||||
self.events.publish(DomainEvent::LayoutPresetLoaded { id }).await.map_err(ConfigError::Event)?;
|
self.events
|
||||||
|
.publish(DomainEvent::LayoutPresetLoaded { id })
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Event)?;
|
||||||
|
|
||||||
self.config.save_layout(&preset.layout).await.map_err(ConfigError::Repository)?;
|
self.config
|
||||||
self.events.publish(DomainEvent::LayoutChanged { layout: preset.layout }).await.map_err(ConfigError::Event)?;
|
.save_layout(&preset.layout)
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Repository)?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::LayoutChanged {
|
||||||
|
layout: preset.layout,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Event)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), ConfigError<C::Error, E::Error>> {
|
pub async fn delete_preset(
|
||||||
self.config.delete_preset(id).await.map_err(ConfigError::Repository)?;
|
&self,
|
||||||
self.events.publish(DomainEvent::LayoutPresetDeleted { id }).await.map_err(ConfigError::Event)?;
|
id: LayoutPresetId,
|
||||||
|
) -> Result<(), ConfigError<C::Error, E::Error>> {
|
||||||
|
self.config
|
||||||
|
.delete_preset(id)
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Repository)?;
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::LayoutPresetDeleted { id })
|
||||||
|
.await
|
||||||
|
.map_err(ConfigError::Event)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use domain::{DataSourceId, Value, WidgetConfig, WidgetId, WidgetState};
|
use domain::{DataSourceId, Value, WidgetConfig, WidgetId, WidgetState};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
pub struct DataProjection {
|
pub struct DataProjection {
|
||||||
current: HashMap<WidgetId, WidgetState>,
|
current: HashMap<WidgetId, WidgetState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DataProjection {
|
impl DataProjection {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self::default()
|
||||||
current: HashMap::new(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_state(&self, widget_id: WidgetId) -> Option<&WidgetState> {
|
||||||
|
self.current.get(&widget_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_poll_result(
|
pub fn apply_poll_result(
|
||||||
@@ -27,9 +30,10 @@ impl DataProjection {
|
|||||||
|
|
||||||
let new_state = config.extract(raw);
|
let new_state = config.extract(raw);
|
||||||
|
|
||||||
let is_changed = self.current
|
let is_changed = self
|
||||||
|
.current
|
||||||
.get(&config.id)
|
.get(&config.id)
|
||||||
.map_or(true, |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());
|
self.current.insert(config.id, new_state.clone());
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
mod support;
|
mod support;
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
use domain::{
|
|
||||||
ConfigRepository, DisplayHint, DomainEvent, KeyMapping, WidgetConfig,
|
|
||||||
DataSource, DataSourceConfig, DataSourceType,
|
|
||||||
Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
|
||||||
LayoutPreset,
|
|
||||||
};
|
|
||||||
use application::ConfigService;
|
use application::ConfigService;
|
||||||
|
use domain::{
|
||||||
|
ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType, Direction,
|
||||||
|
DisplayHint, DomainEvent, KeyMapping, Layout, LayoutChild, LayoutNode, LayoutPreset, Sizing,
|
||||||
|
WidgetConfig,
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
use support::{InMemoryConfigRepository, InMemoryEventPublisher};
|
use support::{InMemoryConfigRepository, InMemoryEventPublisher};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -21,9 +20,10 @@ async fn create_widget_persists_and_emits_event() {
|
|||||||
"weather".into(),
|
"weather".into(),
|
||||||
DisplayHint::IconValue,
|
DisplayHint::IconValue,
|
||||||
1,
|
1,
|
||||||
vec![
|
vec![KeyMapping {
|
||||||
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() },
|
source_path: "$.temp".into(),
|
||||||
],
|
target_key: "temperature".into(),
|
||||||
|
}],
|
||||||
);
|
);
|
||||||
|
|
||||||
service.create_widget(config).await.unwrap();
|
service.create_widget(config).await.unwrap();
|
||||||
@@ -99,8 +99,14 @@ async fn update_layout_persists_and_emits_event() {
|
|||||||
gap: 4,
|
gap: 4,
|
||||||
padding: 2,
|
padding: 2,
|
||||||
children: vec![
|
children: vec![
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
LayoutChild {
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(1),
|
||||||
|
},
|
||||||
|
LayoutChild {
|
||||||
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(2),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -111,7 +117,10 @@ async fn update_layout_persists_and_emits_event() {
|
|||||||
assert_eq!(stored, Some(layout));
|
assert_eq!(stored, Some(layout));
|
||||||
|
|
||||||
assert_eq!(events.emitted().len(), 1);
|
assert_eq!(events.emitted().len(), 1);
|
||||||
assert!(matches!(events.emitted()[0], DomainEvent::LayoutChanged { .. }));
|
assert!(matches!(
|
||||||
|
events.emitted()[0],
|
||||||
|
DomainEvent::LayoutChanged { .. }
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -125,9 +134,10 @@ async fn load_preset_replaces_active_layout() {
|
|||||||
direction: Direction::Column,
|
direction: Direction::Column,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
children: vec![
|
children: vec![LayoutChild {
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(5) },
|
sizing: Sizing::Flex(1),
|
||||||
],
|
node: LayoutNode::Leaf(5),
|
||||||
|
}],
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,6 +156,9 @@ async fn load_preset_replaces_active_layout() {
|
|||||||
|
|
||||||
let emitted = events.emitted();
|
let emitted = events.emitted();
|
||||||
assert_eq!(emitted.len(), 2);
|
assert_eq!(emitted.len(), 2);
|
||||||
assert!(matches!(emitted[0], DomainEvent::LayoutPresetLoaded { id: 1 }));
|
assert!(matches!(
|
||||||
|
emitted[0],
|
||||||
|
DomainEvent::LayoutPresetLoaded { id: 1 }
|
||||||
|
));
|
||||||
assert!(matches!(emitted[1], DomainEvent::LayoutChanged { .. }));
|
assert!(matches!(emitted[1], DomainEvent::LayoutChanged { .. }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use domain::{
|
|
||||||
DisplayHint, KeyMapping, Value, WidgetConfig, WidgetId, WidgetState,
|
|
||||||
};
|
|
||||||
use application::DataProjection;
|
use application::DataProjection;
|
||||||
|
use domain::{DisplayHint, KeyMapping, Value, WidgetConfig, WidgetId, WidgetState};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
fn weather_widget() -> WidgetConfig {
|
fn weather_widget() -> WidgetConfig {
|
||||||
WidgetConfig::new(
|
WidgetConfig::new(
|
||||||
@@ -11,8 +9,14 @@ fn weather_widget() -> WidgetConfig {
|
|||||||
DisplayHint::IconValue,
|
DisplayHint::IconValue,
|
||||||
10,
|
10,
|
||||||
vec![
|
vec![
|
||||||
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() },
|
KeyMapping {
|
||||||
KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() },
|
source_path: "$.temp".into(),
|
||||||
|
target_key: "temperature".into(),
|
||||||
|
},
|
||||||
|
KeyMapping {
|
||||||
|
source_path: "$.icon".into(),
|
||||||
|
target_key: "icon".into(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -33,7 +37,10 @@ fn apply_poll_result_detects_new_widget_state() {
|
|||||||
|
|
||||||
assert_eq!(changed.len(), 1);
|
assert_eq!(changed.len(), 1);
|
||||||
assert_eq!(changed[0].0, 1);
|
assert_eq!(changed[0].0, 1);
|
||||||
assert_eq!(changed[0].1.data.get("temperature"), Some(&Value::Number(5.4)));
|
assert_eq!(
|
||||||
|
changed[0].1.data.get("temperature"),
|
||||||
|
Some(&Value::Number(5.4))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -56,7 +63,10 @@ fn apply_poll_result_detects_changed_value() {
|
|||||||
let changed = projection.apply_poll_result(10, &weather_response(6.1), &widgets);
|
let changed = projection.apply_poll_result(10, &weather_response(6.1), &widgets);
|
||||||
|
|
||||||
assert_eq!(changed.len(), 1);
|
assert_eq!(changed.len(), 1);
|
||||||
assert_eq!(changed[0].1.data.get("temperature"), Some(&Value::Number(6.1)));
|
assert_eq!(
|
||||||
|
changed[0].1.data.get("temperature"),
|
||||||
|
Some(&Value::Number(6.1))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -69,9 +79,10 @@ fn apply_poll_result_only_updates_widgets_bound_to_source() {
|
|||||||
"portfolio".into(),
|
"portfolio".into(),
|
||||||
DisplayHint::KeyValue,
|
DisplayHint::KeyValue,
|
||||||
20,
|
20,
|
||||||
vec![
|
vec![KeyMapping {
|
||||||
KeyMapping { source_path: "$.value".into(), target_key: "amount".into() },
|
source_path: "$.value".into(),
|
||||||
],
|
target_key: "amount".into(),
|
||||||
|
}],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use std::sync::Mutex;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, EventPublisher,
|
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
|
||||||
DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId,
|
LayoutPresetId, WidgetConfig, WidgetId,
|
||||||
WidgetConfig, WidgetId, DomainEvent,
|
|
||||||
};
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
pub struct InMemoryConfigRepository {
|
pub struct InMemoryConfigRepository {
|
||||||
widgets: Mutex<HashMap<WidgetId, WidgetConfig>>,
|
widgets: Mutex<HashMap<WidgetId, WidgetConfig>>,
|
||||||
@@ -45,7 +44,10 @@ impl ConfigRepository for InMemoryConfigRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
|
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
|
||||||
self.widgets.lock().unwrap().insert(config.id, config.clone());
|
self.widgets
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(config.id, config.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,11 +61,20 @@ impl ConfigRepository for InMemoryConfigRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
|
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
|
||||||
Ok(self.data_sources.lock().unwrap().values().cloned().collect())
|
Ok(self
|
||||||
|
.data_sources
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
|
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
|
||||||
self.data_sources.lock().unwrap().insert(source.id, source.clone());
|
self.data_sources
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(source.id, source.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +101,10 @@ impl ConfigRepository for InMemoryConfigRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
|
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
|
||||||
self.presets.lock().unwrap().insert(preset.id, preset.clone());
|
self.presets
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(preset.id, preset.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ config-sqlite.workspace = true
|
|||||||
tcp-server.workspace = true
|
tcp-server.workspace = true
|
||||||
http-api.workspace = true
|
http-api.workspace = true
|
||||||
http-json.workspace = true
|
http-json.workspace = true
|
||||||
|
media-adapter.workspace = true
|
||||||
|
rss-adapter.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ impl ServerConfig {
|
|||||||
Self {
|
Self {
|
||||||
database_url: env::var("KFRAME_DATABASE_URL")
|
database_url: env::var("KFRAME_DATABASE_URL")
|
||||||
.unwrap_or_else(|_| "sqlite:kframe.db?mode=rwc".into()),
|
.unwrap_or_else(|_| "sqlite:kframe.db?mode=rwc".into()),
|
||||||
tcp_addr: env::var("KFRAME_TCP_ADDR")
|
tcp_addr: env::var("KFRAME_TCP_ADDR").unwrap_or_else(|_| "0.0.0.0:2699".into()),
|
||||||
.unwrap_or_else(|_| "0.0.0.0:2699".into()),
|
http_addr: env::var("KFRAME_HTTP_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into()),
|
||||||
http_addr: env::var("KFRAME_HTTP_ADDR")
|
|
||||||
.unwrap_or_else(|_| "0.0.0.0:3000".into()),
|
|
||||||
poll_interval_secs: env::var("KFRAME_POLL_INTERVAL_SECS")
|
poll_interval_secs: env::var("KFRAME_POLL_INTERVAL_SECS")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
|
|||||||
53
crates/bootstrap/src/event_handler.rs
Normal file
53
crates/bootstrap/src/event_handler.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use application::DataProjection;
|
||||||
|
use config_sqlite::SqliteConfigStore;
|
||||||
|
use domain::{BroadcastPort, ConfigRepository, DomainEvent};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tcp_server::{TcpBroadcaster, TcpEventBus};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
event_bus: Arc<TcpEventBus>,
|
||||||
|
config: Arc<SqliteConfigStore>,
|
||||||
|
broadcaster: Arc<TcpBroadcaster>,
|
||||||
|
projection: Arc<Mutex<DataProjection>>,
|
||||||
|
) {
|
||||||
|
let mut rx = event_bus.subscribe();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(DomainEvent::LayoutChanged { layout }) => {
|
||||||
|
let widgets = match config.list_widgets().await {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "failed to fetch widgets for screen update");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let proj = projection.lock().await;
|
||||||
|
let widget_states: Vec<_> = widgets
|
||||||
|
.iter()
|
||||||
|
.filter_map(|w| proj.get_state(w.id).map(|s| (w.id, s.clone())))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Err(e) = broadcaster
|
||||||
|
.push_screen_update(&layout, &widget_states)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!(error = %e, "failed to push screen update");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("layout changed, pushed screen update to clients");
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
warn!(skipped = n, "event handler lagged, missed events");
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||||
|
error!("event bus closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
mod config;
|
mod config;
|
||||||
|
mod event_handler;
|
||||||
mod polling;
|
mod polling;
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use application::DataProjection;
|
use application::DataProjection;
|
||||||
use config_sqlite::SqliteConfigStore;
|
use config_sqlite::SqliteConfigStore;
|
||||||
use tcp_server::{TcpBroadcaster, TcpEventBus, run_tcp_server};
|
|
||||||
use http_api::AppState;
|
use http_api::AppState;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tcp_server::{TcpBroadcaster, TcpEventBus, run_tcp_server};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{info, error};
|
use tracing::{error, info};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
@@ -53,5 +54,19 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
info!("K-Frame server running");
|
info!("K-Frame server running");
|
||||||
|
|
||||||
polling::run(config_store, broadcaster, projection, cfg.poll_interval_secs).await
|
let ev_bus = event_bus.clone();
|
||||||
|
let ev_config = config_store.clone();
|
||||||
|
let ev_bc = broadcaster.clone();
|
||||||
|
let ev_proj = projection.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
event_handler::run(ev_bus, ev_config, ev_bc, ev_proj).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
polling::run(
|
||||||
|
config_store,
|
||||||
|
broadcaster,
|
||||||
|
projection,
|
||||||
|
cfg.poll_interval_secs,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use application::DataProjection;
|
||||||
|
use config_sqlite::SqliteConfigStore;
|
||||||
|
use domain::{
|
||||||
|
BroadcastPort, ConfigRepository, DataSource, DataSourcePort, DataSourceType, Value, WidgetState,
|
||||||
|
};
|
||||||
|
use http_json::HttpJsonAdapter;
|
||||||
|
use media_adapter::MediaAdapter;
|
||||||
|
use rss_adapter::RssAdapter;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use anyhow::Result;
|
|
||||||
use domain::{
|
|
||||||
ConfigRepository, BroadcastPort, DataSourcePort, DataSourceType,
|
|
||||||
DataSource, Value, WidgetState,
|
|
||||||
};
|
|
||||||
use application::DataProjection;
|
|
||||||
use http_json::HttpJsonAdapter;
|
|
||||||
use tcp_server::TcpBroadcaster;
|
use tcp_server::TcpBroadcaster;
|
||||||
use config_sqlite::SqliteConfigStore;
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{info, warn, debug};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
config: Arc<SqliteConfigStore>,
|
config: Arc<SqliteConfigStore>,
|
||||||
@@ -19,6 +20,8 @@ pub async fn run(
|
|||||||
poll_interval_secs: u64,
|
poll_interval_secs: u64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let http_adapter = HttpJsonAdapter::new();
|
let http_adapter = HttpJsonAdapter::new();
|
||||||
|
let media_adapter = MediaAdapter::new();
|
||||||
|
let rss_adapter = RssAdapter::new();
|
||||||
let interval = Duration::from_secs(poll_interval_secs);
|
let interval = Duration::from_secs(poll_interval_secs);
|
||||||
|
|
||||||
info!(interval_secs = poll_interval_secs, "polling loop started");
|
info!(interval_secs = poll_interval_secs, "polling loop started");
|
||||||
@@ -26,11 +29,17 @@ pub async fn run(
|
|||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(interval).await;
|
tokio::time::sleep(interval).await;
|
||||||
|
|
||||||
let sources = config.list_data_sources().await
|
let sources = config
|
||||||
|
.list_data_sources()
|
||||||
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
let widgets = config.list_widgets().await
|
let widgets = config
|
||||||
|
.list_widgets()
|
||||||
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
let layout = config.get_layout().await
|
let layout = config
|
||||||
|
.get_layout()
|
||||||
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
if sources.is_empty() || widgets.is_empty() {
|
if sources.is_empty() || widgets.is_empty() {
|
||||||
@@ -41,7 +50,8 @@ pub async fn run(
|
|||||||
let mut all_changed: Vec<(u16, WidgetState)> = Vec::new();
|
let mut all_changed: Vec<(u16, WidgetState)> = Vec::new();
|
||||||
|
|
||||||
for source in &sources {
|
for source in &sources {
|
||||||
let result = match poll_source(&http_adapter, source).await {
|
let result =
|
||||||
|
match poll_source(&http_adapter, &media_adapter, &rss_adapter, source).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(source = %source.name, error = %e, "poll failed");
|
warn!(source = %source.name, error = %e, "poll failed");
|
||||||
@@ -56,7 +66,9 @@ pub async fn run(
|
|||||||
|
|
||||||
if !all_changed.is_empty() {
|
if !all_changed.is_empty() {
|
||||||
if let Some(l) = &layout {
|
if let Some(l) = &layout {
|
||||||
broadcaster.push_screen_update(l, &all_changed).await
|
broadcaster
|
||||||
|
.push_screen_update(l, &all_changed)
|
||||||
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
}
|
}
|
||||||
info!(count = all_changed.len(), "pushed widget updates");
|
info!(count = all_changed.len(), "pushed widget updates");
|
||||||
@@ -66,15 +78,25 @@ pub async fn run(
|
|||||||
|
|
||||||
async fn poll_source(
|
async fn poll_source(
|
||||||
http_adapter: &HttpJsonAdapter,
|
http_adapter: &HttpJsonAdapter,
|
||||||
|
media_adapter: &MediaAdapter,
|
||||||
|
rss_adapter: &RssAdapter,
|
||||||
source: &DataSource,
|
source: &DataSource,
|
||||||
) -> Result<Value> {
|
) -> Result<Value> {
|
||||||
match source.source_type {
|
match source.source_type {
|
||||||
DataSourceType::HttpJson | DataSourceType::Weather => {
|
DataSourceType::HttpJson | DataSourceType::Weather => http_adapter
|
||||||
http_adapter.poll(source).await
|
.poll(source)
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
.await
|
||||||
}
|
.map_err(|e| anyhow::anyhow!("{e}")),
|
||||||
_ => {
|
DataSourceType::Media => media_adapter
|
||||||
Err(anyhow::anyhow!("unsupported source type: {:?}", source.source_type))
|
.poll(source)
|
||||||
}
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}")),
|
||||||
|
DataSourceType::Rss => rss_adapter
|
||||||
|
.poll(source)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}")),
|
||||||
|
DataSourceType::Webhook => Err(anyhow::anyhow!(
|
||||||
|
"webhook sources are push-based, not polled"
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use domain::LayoutNode;
|
|
||||||
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
|
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
|
||||||
use protocol::{
|
use domain::LayoutNode;
|
||||||
ServerMessage, WidgetDescriptor, WireDisplayHint, WireWidgetState, WireLayoutNode,
|
use protocol::{ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode, WireWidgetState};
|
||||||
};
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub struct ClientApp {
|
pub struct ClientApp {
|
||||||
screen: BoundingBox,
|
screen: BoundingBox,
|
||||||
@@ -33,9 +31,7 @@ impl ClientApp {
|
|||||||
ServerMessage::ScreenUpdate { layout, widgets } => {
|
ServerMessage::ScreenUpdate { layout, widgets } => {
|
||||||
self.handle_screen_update(layout, widgets)
|
self.handle_screen_update(layout, widgets)
|
||||||
}
|
}
|
||||||
ServerMessage::DataUpdate { widgets } => {
|
ServerMessage::DataUpdate { widgets } => self.handle_data_update(widgets),
|
||||||
self.handle_data_update(widgets)
|
|
||||||
}
|
|
||||||
ServerMessage::Heartbeat => Vec::new(),
|
ServerMessage::Heartbeat => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +46,8 @@ impl ClientApp {
|
|||||||
|
|
||||||
self.widget_states.clear();
|
self.widget_states.clear();
|
||||||
for w in &widgets {
|
for w in &widgets {
|
||||||
self.widget_states.insert(w.id, (w.display_hint.clone(), w.state.clone()));
|
self.widget_states
|
||||||
|
.insert(w.id, (w.display_hint.clone(), w.state.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let repaints = self.build_repaints_for_all(&new_tree);
|
let repaints = self.build_repaints_for_all(&new_tree);
|
||||||
@@ -58,10 +55,7 @@ impl ClientApp {
|
|||||||
repaints
|
repaints
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_data_update(
|
fn handle_data_update(&mut self, widgets: Vec<WidgetDescriptor>) -> Vec<RepaintCommand> {
|
||||||
&mut self,
|
|
||||||
widgets: Vec<WidgetDescriptor>,
|
|
||||||
) -> Vec<RepaintCommand> {
|
|
||||||
let tree = match &self.render_tree {
|
let tree = match &self.render_tree {
|
||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
None => return Vec::new(),
|
None => return Vec::new(),
|
||||||
@@ -70,9 +64,10 @@ impl ClientApp {
|
|||||||
let mut repaints = Vec::new();
|
let mut repaints = Vec::new();
|
||||||
|
|
||||||
for w in widgets {
|
for w in widgets {
|
||||||
let changed = self.widget_states
|
let changed = self
|
||||||
|
.widget_states
|
||||||
.get(&w.id)
|
.get(&w.id)
|
||||||
.map_or(true, |(_, prev_state)| *prev_state != w.state);
|
.is_none_or(|(_, prev_state)| *prev_state != w.state);
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
if let Some(bounds) = tree.get_widget_bounds(w.id) {
|
if let Some(bounds) = tree.get_widget_bounds(w.id) {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use client_application::{ClientApp, RepaintCommand};
|
use client_application::{ClientApp, RepaintCommand};
|
||||||
use client_domain::BoundingBox;
|
use client_domain::BoundingBox;
|
||||||
use protocol::{
|
use protocol::{
|
||||||
ServerMessage, WidgetDescriptor,
|
ServerMessage, WidgetDescriptor, WireContainerNode, WireDirection, WireDisplayHint,
|
||||||
WireDisplayHint, WireLayoutNode, WireContainerNode, WireLayoutChild,
|
WireKeyValue, WireLayoutChild, WireLayoutNode, WireSizing, WireValue, WireWidgetState,
|
||||||
WireDirection, WireSizing, WireWidgetState, WireKeyValue, WireValue,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fn screen() -> BoundingBox {
|
fn screen() -> BoundingBox {
|
||||||
@@ -15,9 +14,10 @@ fn weather_descriptor(id: u16, temp: &str) -> WidgetDescriptor {
|
|||||||
id,
|
id,
|
||||||
display_hint: WireDisplayHint::IconValue,
|
display_hint: WireDisplayHint::IconValue,
|
||||||
state: WireWidgetState {
|
state: WireWidgetState {
|
||||||
data: vec![
|
data: vec![WireKeyValue {
|
||||||
WireKeyValue { key: "temperature".into(), value: WireValue::String(temp.into()) },
|
key: "temperature".into(),
|
||||||
],
|
value: WireValue::String(temp.into()),
|
||||||
|
}],
|
||||||
error: None,
|
error: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -29,8 +29,14 @@ fn two_widget_layout() -> WireLayoutNode {
|
|||||||
gap: 0,
|
gap: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
children: vec![
|
children: vec![
|
||||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(1) },
|
WireLayoutChild {
|
||||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(2) },
|
sizing: WireSizing::Flex(1),
|
||||||
|
node: WireLayoutNode::Leaf(1),
|
||||||
|
},
|
||||||
|
WireLayoutChild {
|
||||||
|
sizing: WireSizing::Flex(1),
|
||||||
|
node: WireLayoutNode::Leaf(2),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -116,8 +122,14 @@ fn second_screen_update_repaints_all_widgets_with_new_layout() {
|
|||||||
gap: 0,
|
gap: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
children: vec![
|
children: vec![
|
||||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(1) },
|
WireLayoutChild {
|
||||||
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(2) },
|
sizing: WireSizing::Flex(1),
|
||||||
|
node: WireLayoutNode::Leaf(1),
|
||||||
|
},
|
||||||
|
WireLayoutChild {
|
||||||
|
sizing: WireSizing::Flex(1),
|
||||||
|
node: WireLayoutNode::Leaf(2),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use std::thread;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::time::Duration;
|
|
||||||
use client_domain::{BoundingBox, DisplayPort, NetworkPort};
|
|
||||||
use client_application::ClientApp;
|
use client_application::ClientApp;
|
||||||
use tcp_client::StdTcpClient;
|
use client_domain::{BoundingBox, DisplayPort, NetworkPort};
|
||||||
use display_terminal::TerminalDisplay;
|
use display_terminal::TerminalDisplay;
|
||||||
use protocol::decode_server_message;
|
use protocol::decode_server_message;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tcp_client::StdTcpClient;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let screen = BoundingBox::screen(240, 320);
|
let screen = BoundingBox::screen(240, 320);
|
||||||
@@ -35,12 +35,12 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match net.receive() {
|
match net.receive() {
|
||||||
Ok(Some(payload)) => {
|
Ok(Some(payload)) => match decode_server_message(&payload) {
|
||||||
match decode_server_message(&payload) {
|
Ok(msg) => {
|
||||||
Ok(msg) => { let _ = tx.send(msg); }
|
let _ = tx.send(msg);
|
||||||
|
}
|
||||||
Err(e) => println!("[NET] Decode error: {e}"),
|
Err(e) => println!("[NET] Decode error: {e}"),
|
||||||
}
|
},
|
||||||
}
|
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
thread::sleep(Duration::from_millis(50));
|
thread::sleep(Duration::from_millis(50));
|
||||||
}
|
}
|
||||||
@@ -67,11 +67,14 @@ fn main() {
|
|||||||
|
|
||||||
for kv in &cmd.state.data {
|
for kv in &cmd.state.data {
|
||||||
if let protocol::WireValue::String(s) = &kv.value {
|
if let protocol::WireValue::String(s) = &kv.value {
|
||||||
display.draw_text(
|
display
|
||||||
|
.draw_text(
|
||||||
&format!("{}: {s}", kv.key),
|
&format!("{}: {s}", kv.key),
|
||||||
cmd.bounds.x, cmd.bounds.y,
|
cmd.bounds.x,
|
||||||
|
cmd.bounds.y,
|
||||||
cmd.bounds,
|
cmd.bounds,
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,20 @@ pub struct BoundingBox {
|
|||||||
|
|
||||||
impl BoundingBox {
|
impl BoundingBox {
|
||||||
pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
|
pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
|
||||||
Self { x, y, width, height }
|
Self {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn screen(width: u16, height: u16) -> Self {
|
pub fn screen(width: u16, height: u16) -> Self {
|
||||||
Self { x: 0, y: 0, width, height }
|
Self {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use domain::{LayoutNode, ContainerNode, Direction, Sizing};
|
|
||||||
use crate::{BoundingBox, RenderTree};
|
use crate::{BoundingBox, RenderTree};
|
||||||
|
use domain::{ContainerNode, Direction, LayoutNode, Sizing};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub struct LayoutEngine;
|
pub struct LayoutEngine;
|
||||||
|
|
||||||
@@ -11,11 +11,7 @@ impl LayoutEngine {
|
|||||||
RenderTree { widget_bounds }
|
RenderTree { widget_bounds }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_node(
|
fn compute_node(node: &LayoutNode, bounds: BoundingBox, out: &mut HashMap<u16, BoundingBox>) {
|
||||||
node: &LayoutNode,
|
|
||||||
bounds: BoundingBox,
|
|
||||||
out: &mut HashMap<u16, BoundingBox>,
|
|
||||||
) {
|
|
||||||
match node {
|
match node {
|
||||||
LayoutNode::Leaf(id) => {
|
LayoutNode::Leaf(id) => {
|
||||||
out.insert(*id, bounds);
|
out.insert(*id, bounds);
|
||||||
@@ -48,16 +44,22 @@ impl LayoutEngine {
|
|||||||
let total_gap = container.gap as u16 * (children.len() as u16).saturating_sub(1);
|
let total_gap = container.gap as u16 * (children.len() as u16).saturating_sub(1);
|
||||||
let available = total_axis.saturating_sub(total_gap);
|
let available = total_axis.saturating_sub(total_gap);
|
||||||
|
|
||||||
let fixed_total: u16 = children.iter().map(|c| match c.sizing {
|
let fixed_total: u16 = children
|
||||||
|
.iter()
|
||||||
|
.map(|c| match c.sizing {
|
||||||
Sizing::Fixed(px) => px,
|
Sizing::Fixed(px) => px,
|
||||||
Sizing::Flex(_) => 0,
|
Sizing::Flex(_) => 0,
|
||||||
}).sum();
|
})
|
||||||
|
.sum();
|
||||||
|
|
||||||
let flex_space = available.saturating_sub(fixed_total);
|
let flex_space = available.saturating_sub(fixed_total);
|
||||||
let flex_total: u16 = children.iter().map(|c| match c.sizing {
|
let flex_total: u16 = children
|
||||||
|
.iter()
|
||||||
|
.map(|c| match c.sizing {
|
||||||
Sizing::Flex(w) => w as u16,
|
Sizing::Flex(w) => w as u16,
|
||||||
Sizing::Fixed(_) => 0,
|
Sizing::Fixed(_) => 0,
|
||||||
}).sum();
|
})
|
||||||
|
.sum();
|
||||||
|
|
||||||
let mut offset = 0u16;
|
let mut offset = 0u16;
|
||||||
|
|
||||||
@@ -74,19 +76,9 @@ impl LayoutEngine {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let child_bounds = if is_row {
|
let child_bounds = if is_row {
|
||||||
BoundingBox::new(
|
BoundingBox::new(inner.x + offset, inner.y, child_size, inner.height)
|
||||||
inner.x + offset,
|
|
||||||
inner.y,
|
|
||||||
child_size,
|
|
||||||
inner.height,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
BoundingBox::new(
|
BoundingBox::new(inner.x, inner.y + offset, inner.width, child_size)
|
||||||
inner.x,
|
|
||||||
inner.y + offset,
|
|
||||||
inner.width,
|
|
||||||
child_size,
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::compute_node(&child.node, child_bounds, out);
|
Self::compute_node(&child.node, child_bounds, out);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
mod bounding_box;
|
mod bounding_box;
|
||||||
mod layout_engine;
|
mod layout_engine;
|
||||||
mod render_tree;
|
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
|
mod render_tree;
|
||||||
|
|
||||||
pub use bounding_box::BoundingBox;
|
pub use bounding_box::BoundingBox;
|
||||||
pub use layout_engine::LayoutEngine;
|
pub use layout_engine::LayoutEngine;
|
||||||
|
pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort};
|
||||||
pub use render_tree::RenderTree;
|
pub use render_tree::RenderTree;
|
||||||
pub use ports::{DisplayPort, NetworkPort, StoragePort, ClientConfig};
|
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ pub trait DisplayPort {
|
|||||||
type Error;
|
type Error;
|
||||||
|
|
||||||
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>;
|
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>;
|
||||||
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error>;
|
fn draw_text(
|
||||||
|
&mut self,
|
||||||
|
text: &str,
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
bounds: BoundingBox,
|
||||||
|
) -> Result<(), Self::Error>;
|
||||||
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error>;
|
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error>;
|
||||||
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>;
|
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>;
|
||||||
fn flush(&mut self) -> Result<(), Self::Error>;
|
fn flush(&mut self) -> Result<(), Self::Error>;
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ mod storage;
|
|||||||
|
|
||||||
pub use display::DisplayPort;
|
pub use display::DisplayPort;
|
||||||
pub use network::NetworkPort;
|
pub use network::NetworkPort;
|
||||||
pub use storage::{StoragePort, ClientConfig};
|
pub use storage::{ClientConfig, StoragePort};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use domain::WidgetId;
|
|
||||||
use crate::BoundingBox;
|
use crate::BoundingBox;
|
||||||
|
use domain::WidgetId;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub struct RenderTree {
|
pub struct RenderTree {
|
||||||
pub widget_bounds: HashMap<WidgetId, BoundingBox>,
|
pub widget_bounds: HashMap<WidgetId, BoundingBox>,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use domain::{
|
|
||||||
LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
|
||||||
};
|
|
||||||
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
|
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
|
||||||
|
use domain::{ContainerNode, Direction, LayoutChild, LayoutNode, Sizing};
|
||||||
|
|
||||||
fn screen() -> BoundingBox {
|
fn screen() -> BoundingBox {
|
||||||
BoundingBox::screen(240, 320)
|
BoundingBox::screen(240, 320)
|
||||||
@@ -73,9 +71,18 @@ fn row_splits_width_among_equal_flex_children() {
|
|||||||
let layout = row(vec![leaf(1), leaf(2), leaf(3)]);
|
let layout = row(vec![leaf(1), leaf(2), leaf(3)]);
|
||||||
let tree = LayoutEngine::compute(&layout, screen());
|
let tree = LayoutEngine::compute(&layout, screen());
|
||||||
|
|
||||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 80, 320)));
|
assert_eq!(
|
||||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(80, 0, 80, 320)));
|
tree.get_widget_bounds(1),
|
||||||
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(160, 0, 80, 320)));
|
Some(&BoundingBox::new(0, 0, 80, 320))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.get_widget_bounds(2),
|
||||||
|
Some(&BoundingBox::new(80, 0, 80, 320))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.get_widget_bounds(3),
|
||||||
|
Some(&BoundingBox::new(160, 0, 80, 320))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -83,8 +90,14 @@ fn column_splits_height_among_equal_flex_children() {
|
|||||||
let layout = column(vec![leaf(1), leaf(2)]);
|
let layout = column(vec![leaf(1), leaf(2)]);
|
||||||
let tree = LayoutEngine::compute(&layout, screen());
|
let tree = LayoutEngine::compute(&layout, screen());
|
||||||
|
|
||||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 240, 160)));
|
assert_eq!(
|
||||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(0, 160, 240, 160)));
|
tree.get_widget_bounds(1),
|
||||||
|
Some(&BoundingBox::new(0, 0, 240, 160))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.get_widget_bounds(2),
|
||||||
|
Some(&BoundingBox::new(0, 160, 240, 160))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -92,8 +105,14 @@ fn fixed_and_flex_children_coexist() {
|
|||||||
let layout = row(vec![leaf_fixed(1, 40), leaf(2)]);
|
let layout = row(vec![leaf_fixed(1, 40), leaf(2)]);
|
||||||
let tree = LayoutEngine::compute(&layout, screen());
|
let tree = LayoutEngine::compute(&layout, screen());
|
||||||
|
|
||||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 40, 320)));
|
assert_eq!(
|
||||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(40, 0, 200, 320)));
|
tree.get_widget_bounds(1),
|
||||||
|
Some(&BoundingBox::new(0, 0, 40, 320))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.get_widget_bounds(2),
|
||||||
|
Some(&BoundingBox::new(40, 0, 200, 320))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -102,8 +121,14 @@ fn gap_is_subtracted_before_distributing_space() {
|
|||||||
let layout = row_with_gap(10, vec![leaf(1), leaf(2)]);
|
let layout = row_with_gap(10, vec![leaf(1), leaf(2)]);
|
||||||
let tree = LayoutEngine::compute(&layout, screen());
|
let tree = LayoutEngine::compute(&layout, screen());
|
||||||
|
|
||||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 115, 320)));
|
assert_eq!(
|
||||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(125, 0, 115, 320)));
|
tree.get_widget_bounds(1),
|
||||||
|
Some(&BoundingBox::new(0, 0, 115, 320))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.get_widget_bounds(2),
|
||||||
|
Some(&BoundingBox::new(125, 0, 115, 320))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -112,7 +137,10 @@ fn padding_insets_available_area() {
|
|||||||
let layout = row_with_padding(10, vec![leaf(1)]);
|
let layout = row_with_padding(10, vec![leaf(1)]);
|
||||||
let tree = LayoutEngine::compute(&layout, screen());
|
let tree = LayoutEngine::compute(&layout, screen());
|
||||||
|
|
||||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(10, 10, 220, 300)));
|
assert_eq!(
|
||||||
|
tree.get_widget_bounds(1),
|
||||||
|
Some(&BoundingBox::new(10, 10, 220, 300))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -125,9 +153,18 @@ fn nested_containers_compute_correctly() {
|
|||||||
let layout = row(vec![leaf(1), inner_col]);
|
let layout = row(vec![leaf(1), inner_col]);
|
||||||
let tree = LayoutEngine::compute(&layout, screen());
|
let tree = LayoutEngine::compute(&layout, screen());
|
||||||
|
|
||||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 120, 320)));
|
assert_eq!(
|
||||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(120, 0, 120, 160)));
|
tree.get_widget_bounds(1),
|
||||||
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(120, 160, 120, 160)));
|
Some(&BoundingBox::new(0, 0, 120, 320))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.get_widget_bounds(2),
|
||||||
|
Some(&BoundingBox::new(120, 0, 120, 160))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.get_widget_bounds(3),
|
||||||
|
Some(&BoundingBox::new(120, 160, 120, 160))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -138,14 +175,32 @@ fn weighted_flex_distributes_proportionally() {
|
|||||||
gap: 0,
|
gap: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
children: vec![
|
children: vec![
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
LayoutChild {
|
||||||
LayoutChild { sizing: Sizing::Flex(2), node: LayoutNode::Leaf(2) },
|
sizing: Sizing::Flex(1),
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(3) },
|
node: LayoutNode::Leaf(1),
|
||||||
|
},
|
||||||
|
LayoutChild {
|
||||||
|
sizing: Sizing::Flex(2),
|
||||||
|
node: LayoutNode::Leaf(2),
|
||||||
|
},
|
||||||
|
LayoutChild {
|
||||||
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(3),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let tree = LayoutEngine::compute(&layout, screen());
|
let tree = LayoutEngine::compute(&layout, screen());
|
||||||
|
|
||||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 60, 320)));
|
assert_eq!(
|
||||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(60, 0, 120, 320)));
|
tree.get_widget_bounds(1),
|
||||||
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(180, 0, 60, 320)));
|
Some(&BoundingBox::new(0, 0, 60, 320))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.get_widget_bounds(2),
|
||||||
|
Some(&BoundingBox::new(60, 0, 120, 320))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree.get_widget_bounds(3),
|
||||||
|
Some(&BoundingBox::new(180, 0, 60, 320))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use domain::{
|
|
||||||
LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
|
||||||
};
|
|
||||||
use client_domain::{BoundingBox, LayoutEngine};
|
use client_domain::{BoundingBox, LayoutEngine};
|
||||||
|
use domain::{ContainerNode, Direction, LayoutChild, LayoutNode, Sizing};
|
||||||
|
|
||||||
fn screen() -> BoundingBox {
|
fn screen() -> BoundingBox {
|
||||||
BoundingBox::screen(240, 320)
|
BoundingBox::screen(240, 320)
|
||||||
@@ -14,8 +12,14 @@ fn diff_detects_moved_widget_after_layout_change() {
|
|||||||
gap: 0,
|
gap: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
children: vec![
|
children: vec![
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
LayoutChild {
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(1),
|
||||||
|
},
|
||||||
|
LayoutChild {
|
||||||
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(2),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,8 +28,14 @@ fn diff_detects_moved_widget_after_layout_change() {
|
|||||||
gap: 0,
|
gap: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
children: vec![
|
children: vec![
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
LayoutChild {
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(1),
|
||||||
|
},
|
||||||
|
LayoutChild {
|
||||||
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(2),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,8 +54,14 @@ fn diff_returns_empty_for_identical_layouts() {
|
|||||||
gap: 0,
|
gap: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
children: vec![
|
children: vec![
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
LayoutChild {
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(1),
|
||||||
|
},
|
||||||
|
LayoutChild {
|
||||||
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(2),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,18 +77,20 @@ fn diff_detects_added_and_removed_widgets() {
|
|||||||
direction: Direction::Row,
|
direction: Direction::Row,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
children: vec![
|
children: vec![LayoutChild {
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
sizing: Sizing::Flex(1),
|
||||||
],
|
node: LayoutNode::Leaf(1),
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
let layout_b = LayoutNode::Container(ContainerNode {
|
let layout_b = LayoutNode::Container(ContainerNode {
|
||||||
direction: Direction::Row,
|
direction: Direction::Row,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
children: vec![
|
children: vec![LayoutChild {
|
||||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
|
sizing: Sizing::Flex(1),
|
||||||
],
|
node: LayoutNode::Leaf(2),
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
let tree_a = LayoutEngine::compute(&layout_a, screen());
|
let tree_a = LayoutEngine::compute(&layout_a, screen());
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ pub type DataSourceId = u16;
|
|||||||
pub enum DataSourceType {
|
pub enum DataSourceType {
|
||||||
Weather,
|
Weather,
|
||||||
Media,
|
Media,
|
||||||
Xtb,
|
|
||||||
Rss,
|
Rss,
|
||||||
HttpJson,
|
HttpJson,
|
||||||
Webhook,
|
Webhook,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
mod widget_config;
|
|
||||||
mod data_source;
|
mod data_source;
|
||||||
mod layout_preset;
|
mod layout_preset;
|
||||||
|
mod widget_config;
|
||||||
|
|
||||||
pub use widget_config::{WidgetConfig, WidgetId};
|
pub use data_source::{
|
||||||
pub use data_source::{DataSource, DataSourceId, DataSourceType, DataSourceConfig, DataSourceValidationError};
|
DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError,
|
||||||
|
};
|
||||||
pub use layout_preset::{LayoutPreset, LayoutPresetId};
|
pub use layout_preset::{LayoutPreset, LayoutPresetId};
|
||||||
|
pub use widget_config::{WidgetConfig, WidgetId};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use crate::value_objects::{DisplayHint, KeyMapping, Value, WidgetState};
|
use crate::value_objects::{DisplayHint, KeyMapping, Value, WidgetState};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
pub type WidgetId = u16;
|
pub type WidgetId = u16;
|
||||||
pub type DataSourceId = u16;
|
pub type DataSourceId = u16;
|
||||||
@@ -58,7 +58,8 @@ impl WidgetConfig {
|
|||||||
fn truncate_value(value: Value, max_bytes: usize) -> Value {
|
fn truncate_value(value: Value, max_bytes: usize) -> Value {
|
||||||
match value {
|
match value {
|
||||||
Value::String(s) if s.len() > max_bytes => {
|
Value::String(s) if s.len() > max_bytes => {
|
||||||
let truncated: String = s.char_indices()
|
let truncated: String = s
|
||||||
|
.char_indices()
|
||||||
.take_while(|(i, _)| *i < max_bytes)
|
.take_while(|(i, _)| *i < max_bytes)
|
||||||
.map(|(_, c)| c)
|
.map(|(_, c)| c)
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
#![allow(async_fn_in_trait)]
|
#![allow(async_fn_in_trait)]
|
||||||
|
|
||||||
pub mod entities;
|
pub mod entities;
|
||||||
pub mod value_objects;
|
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
|
pub mod value_objects;
|
||||||
|
|
||||||
pub use entities::{
|
pub use entities::{
|
||||||
WidgetConfig, WidgetId,
|
DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError,
|
||||||
DataSource, DataSourceId, DataSourceType, DataSourceConfig, DataSourceValidationError,
|
LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
||||||
LayoutPreset, LayoutPresetId,
|
|
||||||
};
|
|
||||||
pub use value_objects::{
|
|
||||||
Value, KeyMapping,
|
|
||||||
WidgetState, WidgetError, DisplayHint,
|
|
||||||
Layout, LayoutNode, LayoutChild, ContainerNode, Direction, Sizing, LayoutValidationError,
|
|
||||||
};
|
};
|
||||||
pub use events::DomainEvent;
|
pub use events::DomainEvent;
|
||||||
pub use ports::{ConfigRepository, DataSourcePort, BroadcastPort, EventPublisher};
|
pub use ports::{BroadcastPort, ConfigRepository, DataSourcePort, EventPublisher};
|
||||||
|
pub use value_objects::{
|
||||||
|
ContainerNode, Direction, DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode,
|
||||||
|
LayoutValidationError, Sizing, Value, WidgetError, WidgetState,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::future::Future;
|
|
||||||
use crate::entities::WidgetId;
|
use crate::entities::WidgetId;
|
||||||
use crate::value_objects::{Layout, WidgetState};
|
use crate::value_objects::{Layout, WidgetState};
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
pub trait BroadcastPort {
|
pub trait BroadcastPort {
|
||||||
type Error;
|
type Error;
|
||||||
|
|||||||
@@ -1,27 +1,53 @@
|
|||||||
use std::future::Future;
|
|
||||||
use crate::entities::{
|
use crate::entities::{
|
||||||
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
use crate::value_objects::Layout;
|
use crate::value_objects::Layout;
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
pub trait ConfigRepository {
|
pub trait ConfigRepository {
|
||||||
type Error;
|
type Error;
|
||||||
|
|
||||||
fn get_widget(&self, id: WidgetId) -> impl Future<Output = Result<Option<WidgetConfig>, Self::Error>> + Send;
|
fn get_widget(
|
||||||
|
&self,
|
||||||
|
id: WidgetId,
|
||||||
|
) -> impl Future<Output = Result<Option<WidgetConfig>, Self::Error>> + Send;
|
||||||
fn list_widgets(&self) -> impl Future<Output = Result<Vec<WidgetConfig>, Self::Error>> + Send;
|
fn list_widgets(&self) -> impl Future<Output = Result<Vec<WidgetConfig>, Self::Error>> + Send;
|
||||||
fn save_widget(&self, config: &WidgetConfig) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
fn save_widget(
|
||||||
|
&self,
|
||||||
|
config: &WidgetConfig,
|
||||||
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
fn delete_widget(&self, id: WidgetId) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
fn delete_widget(&self, id: WidgetId) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
|
||||||
fn get_data_source(&self, id: DataSourceId) -> impl Future<Output = Result<Option<DataSource>, Self::Error>> + Send;
|
fn get_data_source(
|
||||||
fn list_data_sources(&self) -> impl Future<Output = Result<Vec<DataSource>, Self::Error>> + Send;
|
&self,
|
||||||
fn save_data_source(&self, source: &DataSource) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
id: DataSourceId,
|
||||||
fn delete_data_source(&self, id: DataSourceId) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
) -> impl Future<Output = Result<Option<DataSource>, Self::Error>> + Send;
|
||||||
|
fn list_data_sources(
|
||||||
|
&self,
|
||||||
|
) -> impl Future<Output = Result<Vec<DataSource>, Self::Error>> + Send;
|
||||||
|
fn save_data_source(
|
||||||
|
&self,
|
||||||
|
source: &DataSource,
|
||||||
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
fn delete_data_source(
|
||||||
|
&self,
|
||||||
|
id: DataSourceId,
|
||||||
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
|
||||||
fn get_layout(&self) -> impl Future<Output = Result<Option<Layout>, Self::Error>> + Send;
|
fn get_layout(&self) -> impl Future<Output = Result<Option<Layout>, Self::Error>> + Send;
|
||||||
fn save_layout(&self, layout: &Layout) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
fn save_layout(&self, layout: &Layout) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
|
||||||
fn get_preset(&self, id: LayoutPresetId) -> impl Future<Output = Result<Option<LayoutPreset>, Self::Error>> + Send;
|
fn get_preset(
|
||||||
|
&self,
|
||||||
|
id: LayoutPresetId,
|
||||||
|
) -> impl Future<Output = Result<Option<LayoutPreset>, Self::Error>> + Send;
|
||||||
fn list_presets(&self) -> impl Future<Output = Result<Vec<LayoutPreset>, Self::Error>> + Send;
|
fn list_presets(&self) -> impl Future<Output = Result<Vec<LayoutPreset>, Self::Error>> + Send;
|
||||||
fn save_preset(&self, preset: &LayoutPreset) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
fn save_preset(
|
||||||
fn delete_preset(&self, id: LayoutPresetId) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
&self,
|
||||||
|
preset: &LayoutPreset,
|
||||||
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
fn delete_preset(
|
||||||
|
&self,
|
||||||
|
id: LayoutPresetId,
|
||||||
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::future::Future;
|
|
||||||
use crate::entities::DataSource;
|
use crate::entities::DataSource;
|
||||||
use crate::value_objects::Value;
|
use crate::value_objects::Value;
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
pub trait DataSourcePort {
|
pub trait DataSourcePort {
|
||||||
type Error;
|
type Error;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::future::Future;
|
|
||||||
use crate::events::DomainEvent;
|
use crate::events::DomainEvent;
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
pub trait EventPublisher {
|
pub trait EventPublisher {
|
||||||
type Error;
|
type Error;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
mod broadcast;
|
||||||
mod config_repository;
|
mod config_repository;
|
||||||
mod data_source_port;
|
mod data_source_port;
|
||||||
mod broadcast;
|
|
||||||
mod event;
|
mod event;
|
||||||
|
|
||||||
|
pub use broadcast::BroadcastPort;
|
||||||
pub use config_repository::ConfigRepository;
|
pub use config_repository::ConfigRepository;
|
||||||
pub use data_source_port::DataSourcePort;
|
pub use data_source_port::DataSourcePort;
|
||||||
pub use broadcast::BroadcastPort;
|
|
||||||
pub use event::EventPublisher;
|
pub use event::EventPublisher;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::BTreeSet;
|
|
||||||
use crate::entities::WidgetId;
|
use crate::entities::WidgetId;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum Sizing {
|
pub enum Sizing {
|
||||||
@@ -81,7 +81,9 @@ impl Layout {
|
|||||||
|
|
||||||
fn collect_ids(node: &LayoutNode, ids: &mut BTreeSet<WidgetId>) {
|
fn collect_ids(node: &LayoutNode, ids: &mut BTreeSet<WidgetId>) {
|
||||||
match node {
|
match node {
|
||||||
LayoutNode::Leaf(id) => { ids.insert(*id); }
|
LayoutNode::Leaf(id) => {
|
||||||
|
ids.insert(*id);
|
||||||
|
}
|
||||||
LayoutNode::Container(c) => {
|
LayoutNode::Container(c) => {
|
||||||
for child in &c.children {
|
for child in &c.children {
|
||||||
Self::collect_ids(&child.node, ids);
|
Self::collect_ids(&child.node, ids);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
mod value;
|
|
||||||
mod key_mapping;
|
mod key_mapping;
|
||||||
mod widget_state;
|
|
||||||
mod layout;
|
mod layout;
|
||||||
|
mod value;
|
||||||
|
mod widget_state;
|
||||||
|
|
||||||
pub use value::Value;
|
|
||||||
pub use key_mapping::KeyMapping;
|
pub use key_mapping::KeyMapping;
|
||||||
pub use widget_state::{WidgetState, WidgetError, DisplayHint};
|
|
||||||
pub use layout::{
|
pub use layout::{
|
||||||
Layout, LayoutNode, LayoutChild, ContainerNode, Direction, Sizing, LayoutValidationError,
|
ContainerNode, Direction, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing,
|
||||||
};
|
};
|
||||||
|
pub use value::Value;
|
||||||
|
pub use widget_state::{DisplayHint, WidgetError, WidgetState};
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ impl Value {
|
|||||||
Value::Number(_) => 8,
|
Value::Number(_) => 8,
|
||||||
Value::String(s) => s.len(),
|
Value::String(s) => s.len(),
|
||||||
Value::Array(arr) => arr.iter().map(|v| v.estimated_size()).sum(),
|
Value::Array(arr) => arr.iter().map(|v| v.estimated_size()).sum(),
|
||||||
Value::Object(map) => map
|
Value::Object(map) => map.iter().map(|(k, v)| k.len() + v.estimated_size()).sum(),
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| k.len() + v.estimated_size())
|
|
||||||
.sum(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use super::Value;
|
use super::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct WidgetState {
|
pub struct WidgetState {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::time::Duration;
|
|
||||||
use domain::{DataSource, DataSourceConfig, DataSourceType, DataSourceValidationError};
|
use domain::{DataSource, DataSourceConfig, DataSourceType, DataSourceValidationError};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
fn make_source(source_type: DataSourceType, url: Option<&str>, poll: Duration) -> DataSource {
|
fn make_source(source_type: DataSourceType, url: Option<&str>, poll: Duration) -> DataSource {
|
||||||
DataSource {
|
DataSource {
|
||||||
@@ -38,14 +38,22 @@ fn webhook_with_zero_interval_is_valid() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn poll_based_source_requires_nonzero_interval() {
|
fn poll_based_source_requires_nonzero_interval() {
|
||||||
let source = make_source(DataSourceType::Weather, Some("https://api.weather.com"), Duration::ZERO);
|
let source = make_source(
|
||||||
|
DataSourceType::Weather,
|
||||||
|
Some("https://api.weather.com"),
|
||||||
|
Duration::ZERO,
|
||||||
|
);
|
||||||
let errors = source.validate();
|
let errors = source.validate();
|
||||||
assert!(errors.contains(&DataSourceValidationError::PollIntervalRequired));
|
assert!(errors.contains(&DataSourceValidationError::PollIntervalRequired));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn valid_poll_source_has_no_errors() {
|
fn valid_poll_source_has_no_errors() {
|
||||||
let source = make_source(DataSourceType::Rss, Some("https://feed.example.com"), Duration::from_secs(300));
|
let source = make_source(
|
||||||
|
DataSourceType::Rss,
|
||||||
|
Some("https://feed.example.com"),
|
||||||
|
Duration::from_secs(300),
|
||||||
|
);
|
||||||
let errors = source.validate();
|
let errors = source.validate();
|
||||||
assert!(errors.is_empty());
|
assert!(errors.is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use domain::{KeyMapping, Value};
|
use domain::{KeyMapping, Value};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extracts_value_at_path_and_renames_key() {
|
fn extracts_value_at_path_and_renames_key() {
|
||||||
@@ -8,11 +8,10 @@ fn extracts_value_at_path_and_renames_key() {
|
|||||||
target_key: "temperature".into(),
|
target_key: "temperature".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let raw = Value::Object(BTreeMap::from([
|
let raw = Value::Object(BTreeMap::from([(
|
||||||
("main".into(), Value::Object(BTreeMap::from([
|
"main".into(),
|
||||||
("temp".into(), Value::Number(5.4)),
|
Value::Object(BTreeMap::from([("temp".into(), Value::Number(5.4))])),
|
||||||
]))),
|
)]));
|
||||||
]));
|
|
||||||
|
|
||||||
let result = mapping.extract(&raw);
|
let result = mapping.extract(&raw);
|
||||||
assert_eq!(result, Some(("temperature".into(), Value::Number(5.4))));
|
assert_eq!(result, Some(("temperature".into(), Value::Number(5.4))));
|
||||||
@@ -25,9 +24,7 @@ fn returns_none_when_path_does_not_match() {
|
|||||||
target_key: "value".into(),
|
target_key: "value".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let raw = Value::Object(BTreeMap::from([
|
let raw = Value::Object(BTreeMap::from([("other".into(), Value::Number(1.0))]));
|
||||||
("other".into(), Value::Number(1.0)),
|
|
||||||
]));
|
|
||||||
|
|
||||||
assert_eq!(mapping.extract(&raw), None);
|
assert_eq!(mapping.extract(&raw), None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::collections::BTreeSet;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
ContainerNode, Direction, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing,
|
ContainerNode, Direction, Layout, LayoutChild, LayoutNode, LayoutValidationError, Sizing,
|
||||||
WidgetId,
|
WidgetId,
|
||||||
};
|
};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
fn leaf(id: WidgetId) -> LayoutChild {
|
fn leaf(id: WidgetId) -> LayoutChild {
|
||||||
LayoutChild {
|
LayoutChild {
|
||||||
@@ -22,14 +22,18 @@ fn row(children: Vec<LayoutChild>) -> LayoutNode {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_returns_empty_when_all_widgets_exist() {
|
fn validate_returns_empty_when_all_widgets_exist() {
|
||||||
let layout = Layout { root: row(vec![leaf(1), leaf(2)]) };
|
let layout = Layout {
|
||||||
|
root: row(vec![leaf(1), leaf(2)]),
|
||||||
|
};
|
||||||
let known = BTreeSet::from([1, 2]);
|
let known = BTreeSet::from([1, 2]);
|
||||||
assert!(layout.validate(&known).is_empty());
|
assert!(layout.validate(&known).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_reports_unknown_widget_ids() {
|
fn validate_reports_unknown_widget_ids() {
|
||||||
let layout = Layout { root: row(vec![leaf(1), leaf(99)]) };
|
let layout = Layout {
|
||||||
|
root: row(vec![leaf(1), leaf(99)]),
|
||||||
|
};
|
||||||
let known = BTreeSet::from([1]);
|
let known = BTreeSet::from([1]);
|
||||||
let errors = layout.validate(&known);
|
let errors = layout.validate(&known);
|
||||||
assert_eq!(errors, vec![LayoutValidationError::UnknownWidget(99)]);
|
assert_eq!(errors, vec![LayoutValidationError::UnknownWidget(99)]);
|
||||||
@@ -49,7 +53,9 @@ fn validate_checks_nested_containers() {
|
|||||||
sizing: Sizing::Flex(1),
|
sizing: Sizing::Flex(1),
|
||||||
node: row(vec![leaf(1), leaf(42)]),
|
node: row(vec![leaf(1), leaf(42)]),
|
||||||
};
|
};
|
||||||
let layout = Layout { root: row(vec![inner, leaf(2)]) };
|
let layout = Layout {
|
||||||
|
root: row(vec![inner, leaf(2)]),
|
||||||
|
};
|
||||||
let known = BTreeSet::from([1, 2]);
|
let known = BTreeSet::from([1, 2]);
|
||||||
let errors = layout.validate(&known);
|
let errors = layout.validate(&known);
|
||||||
assert_eq!(errors, vec![LayoutValidationError::UnknownWidget(42)]);
|
assert_eq!(errors, vec![LayoutValidationError::UnknownWidget(42)]);
|
||||||
@@ -61,6 +67,8 @@ fn widget_ids_collects_all_leaf_ids() {
|
|||||||
sizing: Sizing::Flex(1),
|
sizing: Sizing::Flex(1),
|
||||||
node: row(vec![leaf(3)]),
|
node: row(vec![leaf(3)]),
|
||||||
};
|
};
|
||||||
let layout = Layout { root: row(vec![leaf(1), inner, leaf(2)]) };
|
let layout = Layout {
|
||||||
|
root: row(vec![leaf(1), inner, leaf(2)]),
|
||||||
|
};
|
||||||
assert_eq!(layout.widget_ids(), BTreeSet::from([1, 2, 3]));
|
assert_eq!(layout.widget_ids(), BTreeSet::from([1, 2, 3]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use domain::Value;
|
use domain::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn estimated_size_of_string_is_its_byte_length() {
|
fn estimated_size_of_string_is_its_byte_length() {
|
||||||
@@ -29,37 +29,31 @@ fn estimated_size_of_nested_structure_sums_recursively() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn estimated_size_of_array_sums_elements() {
|
fn estimated_size_of_array_sums_elements() {
|
||||||
let v = Value::Array(vec![
|
let v = Value::Array(vec![Value::String("abc".into()), Value::Number(1.0)]);
|
||||||
Value::String("abc".into()),
|
|
||||||
Value::Number(1.0),
|
|
||||||
]);
|
|
||||||
assert_eq!(v.estimated_size(), 11);
|
assert_eq!(v.estimated_size(), 11);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_path_returns_none_for_missing_key() {
|
fn get_path_returns_none_for_missing_key() {
|
||||||
let data = Value::Object(BTreeMap::from([
|
let data = Value::Object(BTreeMap::from([("main".into(), Value::Number(1.0))]));
|
||||||
("main".into(), Value::Number(1.0)),
|
|
||||||
]));
|
|
||||||
assert_eq!(data.get_path("$.missing"), None);
|
assert_eq!(data.get_path("$.missing"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_path_returns_none_when_traversing_non_object() {
|
fn get_path_returns_none_when_traversing_non_object() {
|
||||||
let data = Value::Object(BTreeMap::from([
|
let data = Value::Object(BTreeMap::from([("temp".into(), Value::Number(5.4))]));
|
||||||
("temp".into(), Value::Number(5.4)),
|
|
||||||
]));
|
|
||||||
assert_eq!(data.get_path("$.temp.nested"), None);
|
assert_eq!(data.get_path("$.temp.nested"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_path_accesses_array_by_index() {
|
fn get_path_accesses_array_by_index() {
|
||||||
let data = Value::Object(BTreeMap::from([
|
let data = Value::Object(BTreeMap::from([(
|
||||||
("items".into(), Value::Array(vec![
|
"items".into(),
|
||||||
|
Value::Array(vec![
|
||||||
Value::String("first".into()),
|
Value::String("first".into()),
|
||||||
Value::String("second".into()),
|
Value::String("second".into()),
|
||||||
])),
|
]),
|
||||||
]));
|
)]));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
data.get_path("$.items[1]"),
|
data.get_path("$.items[1]"),
|
||||||
Some(&Value::String("second".into()))
|
Some(&Value::String("second".into()))
|
||||||
@@ -68,10 +62,9 @@ fn get_path_accesses_array_by_index() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_path_traverses_nested_object() {
|
fn get_path_traverses_nested_object() {
|
||||||
let data = Value::Object(BTreeMap::from([
|
let data = Value::Object(BTreeMap::from([(
|
||||||
("main".into(), Value::Object(BTreeMap::from([
|
"main".into(),
|
||||||
("temp".into(), Value::Number(5.4)),
|
Value::Object(BTreeMap::from([("temp".into(), Value::Number(5.4))])),
|
||||||
]))),
|
)]));
|
||||||
]));
|
|
||||||
assert_eq!(data.get_path("$.main.temp"), Some(&Value::Number(5.4)));
|
assert_eq!(data.get_path("$.main.temp"), Some(&Value::Number(5.4)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use domain::{DisplayHint, KeyMapping, Value, WidgetConfig};
|
use domain::{DisplayHint, KeyMapping, Value, WidgetConfig};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_applies_all_mappings_to_produce_widget_state() {
|
fn extract_applies_all_mappings_to_produce_widget_state() {
|
||||||
@@ -9,27 +9,39 @@ fn extract_applies_all_mappings_to_produce_widget_state() {
|
|||||||
display_hint: DisplayHint::IconValue,
|
display_hint: DisplayHint::IconValue,
|
||||||
data_source_id: 1,
|
data_source_id: 1,
|
||||||
mappings: vec![
|
mappings: vec![
|
||||||
KeyMapping { source_path: "$.main.temp".into(), target_key: "temperature".into() },
|
KeyMapping {
|
||||||
KeyMapping { source_path: "$.weather[0].icon".into(), target_key: "icon".into() },
|
source_path: "$.main.temp".into(),
|
||||||
|
target_key: "temperature".into(),
|
||||||
|
},
|
||||||
|
KeyMapping {
|
||||||
|
source_path: "$.weather[0].icon".into(),
|
||||||
|
target_key: "icon".into(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
max_data_size: 2048,
|
max_data_size: 2048,
|
||||||
};
|
};
|
||||||
|
|
||||||
let raw = Value::Object(BTreeMap::from([
|
let raw = Value::Object(BTreeMap::from([
|
||||||
("main".into(), Value::Object(BTreeMap::from([
|
(
|
||||||
("temp".into(), Value::Number(5.4)),
|
"main".into(),
|
||||||
]))),
|
Value::Object(BTreeMap::from([("temp".into(), Value::Number(5.4))])),
|
||||||
("weather".into(), Value::Array(vec![
|
),
|
||||||
Value::Object(BTreeMap::from([
|
(
|
||||||
("icon".into(), Value::String("cloud_rain".into())),
|
"weather".into(),
|
||||||
])),
|
Value::Array(vec![Value::Object(BTreeMap::from([(
|
||||||
])),
|
"icon".into(),
|
||||||
|
Value::String("cloud_rain".into()),
|
||||||
|
)]))]),
|
||||||
|
),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
let state = config.extract(&raw);
|
let state = config.extract(&raw);
|
||||||
|
|
||||||
assert_eq!(state.data.get("temperature"), Some(&Value::Number(5.4)));
|
assert_eq!(state.data.get("temperature"), Some(&Value::Number(5.4)));
|
||||||
assert_eq!(state.data.get("icon"), Some(&Value::String("cloud_rain".into())));
|
assert_eq!(
|
||||||
|
state.data.get("icon"),
|
||||||
|
Some(&Value::String("cloud_rain".into()))
|
||||||
|
);
|
||||||
assert_eq!(state.error, None);
|
assert_eq!(state.error, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,15 +53,14 @@ fn extract_truncates_string_values_exceeding_max_data_size() {
|
|||||||
name: "news".into(),
|
name: "news".into(),
|
||||||
display_hint: DisplayHint::TextBlock,
|
display_hint: DisplayHint::TextBlock,
|
||||||
data_source_id: 1,
|
data_source_id: 1,
|
||||||
mappings: vec![
|
mappings: vec![KeyMapping {
|
||||||
KeyMapping { source_path: "$.text".into(), target_key: "body".into() },
|
source_path: "$.text".into(),
|
||||||
],
|
target_key: "body".into(),
|
||||||
|
}],
|
||||||
max_data_size: 100,
|
max_data_size: 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
let raw = Value::Object(BTreeMap::from([
|
let raw = Value::Object(BTreeMap::from([("text".into(), Value::String(long_text))]));
|
||||||
("text".into(), Value::String(long_text)),
|
|
||||||
]));
|
|
||||||
|
|
||||||
let state = config.extract(&raw);
|
let state = config.extract(&raw);
|
||||||
match state.data.get("body") {
|
match state.data.get("body") {
|
||||||
@@ -66,9 +77,18 @@ fn extract_respects_max_data_size_across_total_state() {
|
|||||||
display_hint: DisplayHint::TextBlock,
|
display_hint: DisplayHint::TextBlock,
|
||||||
data_source_id: 1,
|
data_source_id: 1,
|
||||||
mappings: vec![
|
mappings: vec![
|
||||||
KeyMapping { source_path: "$.a".into(), target_key: "a".into() },
|
KeyMapping {
|
||||||
KeyMapping { source_path: "$.b".into(), target_key: "b".into() },
|
source_path: "$.a".into(),
|
||||||
KeyMapping { source_path: "$.c".into(), target_key: "c".into() },
|
target_key: "a".into(),
|
||||||
|
},
|
||||||
|
KeyMapping {
|
||||||
|
source_path: "$.b".into(),
|
||||||
|
target_key: "b".into(),
|
||||||
|
},
|
||||||
|
KeyMapping {
|
||||||
|
source_path: "$.c".into(),
|
||||||
|
target_key: "c".into(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
max_data_size: 50,
|
max_data_size: 50,
|
||||||
};
|
};
|
||||||
@@ -92,15 +112,19 @@ fn extract_skips_mappings_that_dont_match() {
|
|||||||
display_hint: DisplayHint::IconValue,
|
display_hint: DisplayHint::IconValue,
|
||||||
data_source_id: 1,
|
data_source_id: 1,
|
||||||
mappings: vec![
|
mappings: vec![
|
||||||
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() },
|
KeyMapping {
|
||||||
KeyMapping { source_path: "$.missing".into(), target_key: "gone".into() },
|
source_path: "$.temp".into(),
|
||||||
|
target_key: "temperature".into(),
|
||||||
|
},
|
||||||
|
KeyMapping {
|
||||||
|
source_path: "$.missing".into(),
|
||||||
|
target_key: "gone".into(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
max_data_size: 2048,
|
max_data_size: 2048,
|
||||||
};
|
};
|
||||||
|
|
||||||
let raw = Value::Object(BTreeMap::from([
|
let raw = Value::Object(BTreeMap::from([("temp".into(), Value::Number(5.4))]));
|
||||||
("temp".into(), Value::Number(5.4)),
|
|
||||||
]));
|
|
||||||
|
|
||||||
let state = config.extract(&raw);
|
let state = config.extract(&raw);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde::{Serialize, Deserialize};
|
use super::wire::{WireDisplayHint, WireLayoutNode, WireWidgetState};
|
||||||
use super::wire::{WireLayoutNode, WireWidgetState, WireDisplayHint};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct WidgetDescriptor {
|
pub struct WidgetDescriptor {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
mod wire;
|
|
||||||
mod frame;
|
mod frame;
|
||||||
|
mod wire;
|
||||||
|
|
||||||
pub use wire::{
|
|
||||||
WireValue, WireWidgetState, WireWidgetError, WireDisplayHint,
|
|
||||||
WireLayoutNode, WireContainerNode, WireLayoutChild, WireDirection, WireSizing,
|
|
||||||
WireKeyValue,
|
|
||||||
};
|
|
||||||
pub use frame::{
|
pub use frame::{
|
||||||
ServerMessage, ClientMessage, WidgetDescriptor,
|
ClientMessage, MAX_FRAME_SIZE, ServerMessage, WidgetDescriptor, decode_client_message,
|
||||||
encode, decode_server_message, encode_client, decode_client_message,
|
decode_server_message, encode, encode_client,
|
||||||
MAX_FRAME_SIZE,
|
};
|
||||||
|
pub use wire::{
|
||||||
|
WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild,
|
||||||
|
WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use domain::value_objects::{
|
use domain::value_objects::{
|
||||||
ContainerNode, Direction, DisplayHint, LayoutChild, LayoutNode, Sizing, Value,
|
ContainerNode, Direction, DisplayHint, LayoutChild, LayoutNode, Sizing, Value, WidgetError,
|
||||||
WidgetError, WidgetState,
|
WidgetState,
|
||||||
};
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireValue {
|
pub enum WireValue {
|
||||||
@@ -84,10 +84,14 @@ pub struct WireWidgetState {
|
|||||||
impl From<&WidgetState> for WireWidgetState {
|
impl From<&WidgetState> for WireWidgetState {
|
||||||
fn from(s: &WidgetState) -> Self {
|
fn from(s: &WidgetState) -> Self {
|
||||||
WireWidgetState {
|
WireWidgetState {
|
||||||
data: s.data.iter().map(|(k, v)| WireKeyValue {
|
data: s
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| WireKeyValue {
|
||||||
key: k.clone(),
|
key: k.clone(),
|
||||||
value: v.into(),
|
value: v.into(),
|
||||||
}).collect(),
|
})
|
||||||
|
.collect(),
|
||||||
error: s.error.as_ref().map(Into::into),
|
error: s.error.as_ref().map(Into::into),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +100,11 @@ impl From<&WidgetState> for WireWidgetState {
|
|||||||
impl From<WireWidgetState> for WidgetState {
|
impl From<WireWidgetState> for WidgetState {
|
||||||
fn from(w: WireWidgetState) -> Self {
|
fn from(w: WireWidgetState) -> Self {
|
||||||
WidgetState {
|
WidgetState {
|
||||||
data: w.data.into_iter().map(|kv| (kv.key, kv.value.into())).collect(),
|
data: w
|
||||||
|
.data
|
||||||
|
.into_iter()
|
||||||
|
.map(|kv| (kv.key, kv.value.into()))
|
||||||
|
.collect(),
|
||||||
error: w.error.map(Into::into),
|
error: w.error.map(Into::into),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,10 +213,14 @@ impl From<&LayoutNode> for WireLayoutNode {
|
|||||||
direction: (&c.direction).into(),
|
direction: (&c.direction).into(),
|
||||||
gap: c.gap,
|
gap: c.gap,
|
||||||
padding: c.padding,
|
padding: c.padding,
|
||||||
children: c.children.iter().map(|ch| WireLayoutChild {
|
children: c
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.map(|ch| WireLayoutChild {
|
||||||
sizing: (&ch.sizing).into(),
|
sizing: (&ch.sizing).into(),
|
||||||
node: (&ch.node).into(),
|
node: (&ch.node).into(),
|
||||||
}).collect(),
|
})
|
||||||
|
.collect(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,10 +234,14 @@ impl From<WireLayoutNode> for LayoutNode {
|
|||||||
direction: c.direction.into(),
|
direction: c.direction.into(),
|
||||||
gap: c.gap,
|
gap: c.gap,
|
||||||
padding: c.padding,
|
padding: c.padding,
|
||||||
children: c.children.into_iter().map(|ch| LayoutChild {
|
children: c
|
||||||
|
.children
|
||||||
|
.into_iter()
|
||||||
|
.map(|ch| LayoutChild {
|
||||||
sizing: ch.sizing.into(),
|
sizing: ch.sizing.into(),
|
||||||
node: ch.node.into(),
|
node: ch.node.into(),
|
||||||
}).collect(),
|
})
|
||||||
|
.collect(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
Value, WidgetState, WidgetError, DisplayHint,
|
ContainerNode, Direction, DisplayHint, LayoutChild, LayoutNode, Sizing, Value, WidgetError,
|
||||||
LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
WidgetState,
|
||||||
};
|
};
|
||||||
use protocol::{
|
use protocol::{
|
||||||
WireValue, WireWidgetState, WireWidgetError, WireDisplayHint,
|
WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild,
|
||||||
WireLayoutNode, WireContainerNode, WireLayoutChild, WireDirection, WireSizing,
|
WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState,
|
||||||
WireKeyValue,
|
|
||||||
};
|
};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn value_converts_to_wire_and_back() {
|
fn value_converts_to_wire_and_back() {
|
||||||
let original = Value::Object(BTreeMap::from([
|
let original = Value::Object(BTreeMap::from([(
|
||||||
("items".into(), Value::Array(vec![
|
"items".into(),
|
||||||
|
Value::Array(vec![
|
||||||
Value::String("hello".into()),
|
Value::String("hello".into()),
|
||||||
Value::Number(42.0),
|
Value::Number(42.0),
|
||||||
Value::Bool(true),
|
Value::Bool(true),
|
||||||
Value::Null,
|
Value::Null,
|
||||||
])),
|
]),
|
||||||
]));
|
)]));
|
||||||
|
|
||||||
let wire: WireValue = (&original).into();
|
let wire: WireValue = (&original).into();
|
||||||
let roundtripped: Value = wire.into();
|
let roundtripped: Value = wire.into();
|
||||||
@@ -28,9 +28,7 @@ fn value_converts_to_wire_and_back() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn widget_state_with_error_converts_to_wire_and_back() {
|
fn widget_state_with_error_converts_to_wire_and_back() {
|
||||||
let original = WidgetState {
|
let original = WidgetState {
|
||||||
data: BTreeMap::from([
|
data: BTreeMap::from([("temp".into(), Value::Number(5.4))]),
|
||||||
("temp".into(), Value::Number(5.4)),
|
|
||||||
]),
|
|
||||||
error: Some(WidgetError::SourceUnavailable),
|
error: Some(WidgetError::SourceUnavailable),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,12 +54,10 @@ fn layout_tree_converts_to_wire_and_back() {
|
|||||||
direction: Direction::Column,
|
direction: Direction::Column,
|
||||||
gap: 2,
|
gap: 2,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
children: vec![
|
children: vec![LayoutChild {
|
||||||
LayoutChild {
|
|
||||||
sizing: Sizing::Flex(1),
|
sizing: Sizing::Flex(1),
|
||||||
node: LayoutNode::Leaf(2),
|
node: LayoutNode::Leaf(2),
|
||||||
},
|
}],
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -74,7 +70,11 @@ fn layout_tree_converts_to_wire_and_back() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_hint_converts_to_wire_and_back() {
|
fn display_hint_converts_to_wire_and_back() {
|
||||||
for hint in [DisplayHint::IconValue, DisplayHint::TextBlock, DisplayHint::KeyValue] {
|
for hint in [
|
||||||
|
DisplayHint::IconValue,
|
||||||
|
DisplayHint::TextBlock,
|
||||||
|
DisplayHint::KeyValue,
|
||||||
|
] {
|
||||||
let wire: WireDisplayHint = (&hint).into();
|
let wire: WireDisplayHint = (&hint).into();
|
||||||
let roundtripped: DisplayHint = wire.into();
|
let roundtripped: DisplayHint = wire.into();
|
||||||
assert_eq!(hint, roundtripped);
|
assert_eq!(hint, roundtripped);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use protocol::{
|
use protocol::{
|
||||||
ServerMessage, ClientMessage, WidgetDescriptor,
|
ClientMessage, ServerMessage, WidgetDescriptor, WireContainerNode, WireDirection,
|
||||||
WireDisplayHint, WireLayoutNode, WireContainerNode, WireLayoutChild,
|
WireDisplayHint, WireKeyValue, WireLayoutChild, WireLayoutNode, WireSizing, WireValue,
|
||||||
WireDirection, WireSizing, WireWidgetState, WireKeyValue, WireValue,
|
WireWidgetState, decode_client_message, decode_server_message, encode, encode_client,
|
||||||
encode, decode_server_message, encode_client, decode_client_message,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -23,19 +22,23 @@ fn screen_update_round_trips() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
widgets: vec![
|
widgets: vec![WidgetDescriptor {
|
||||||
WidgetDescriptor {
|
|
||||||
id: 1,
|
id: 1,
|
||||||
display_hint: WireDisplayHint::IconValue,
|
display_hint: WireDisplayHint::IconValue,
|
||||||
state: WireWidgetState {
|
state: WireWidgetState {
|
||||||
data: vec![
|
data: vec![
|
||||||
WireKeyValue { key: "temperature".into(), value: WireValue::String("5.4°C".into()) },
|
WireKeyValue {
|
||||||
WireKeyValue { key: "icon".into(), value: WireValue::String("cloud_rain".into()) },
|
key: "temperature".into(),
|
||||||
|
value: WireValue::String("5.4°C".into()),
|
||||||
|
},
|
||||||
|
WireKeyValue {
|
||||||
|
key: "icon".into(),
|
||||||
|
value: WireValue::String("cloud_rain".into()),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
error: None,
|
error: None,
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let frame = encode(&msg).unwrap();
|
let frame = encode(&msg).unwrap();
|
||||||
@@ -47,18 +50,17 @@ fn screen_update_round_trips() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn data_update_round_trips() {
|
fn data_update_round_trips() {
|
||||||
let msg = ServerMessage::DataUpdate {
|
let msg = ServerMessage::DataUpdate {
|
||||||
widgets: vec![
|
widgets: vec![WidgetDescriptor {
|
||||||
WidgetDescriptor {
|
|
||||||
id: 3,
|
id: 3,
|
||||||
display_hint: WireDisplayHint::TextBlock,
|
display_hint: WireDisplayHint::TextBlock,
|
||||||
state: WireWidgetState {
|
state: WireWidgetState {
|
||||||
data: vec![
|
data: vec![WireKeyValue {
|
||||||
WireKeyValue { key: "body".into(), value: WireValue::String("Breaking news...".into()) },
|
key: "body".into(),
|
||||||
],
|
value: WireValue::String("Breaking news...".into()),
|
||||||
|
}],
|
||||||
error: None,
|
error: None,
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let frame = encode(&msg).unwrap();
|
let frame = encode(&msg).unwrap();
|
||||||
|
|||||||
147
docs/k-frame-spa-handoff.md
Normal file
147
docs/k-frame-spa-handoff.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# K-Frame SPA Handoff
|
||||||
|
|
||||||
|
## What is K-Frame
|
||||||
|
|
||||||
|
IoT dashboard system. Server aggregates data from configurable sources and pushes to ESP32 display clients via TCP. The server is fully functional — SQLite config, REST API, TCP broadcasting, data source polling. ESP32 firmware works end-to-end (display renders widgets). Now needs a config UI.
|
||||||
|
|
||||||
|
## What this session should build
|
||||||
|
|
||||||
|
A React SPA (config/admin UI) for the K-Frame server. The SPA is at `/mnt/drive/dev/k-frame/spa/` — fresh Vite + React 19 + shadcn/ui + TanStack Router + TanStack Query setup. Currently shows a placeholder page.
|
||||||
|
|
||||||
|
## Existing artifacts to read first
|
||||||
|
|
||||||
|
- **Design spec**: `docs/superpowers/specs/2026-06-18-k-frame-design.md`
|
||||||
|
- **Domain glossary**: `CONTEXT.md`
|
||||||
|
- **ADRs**: `docs/adr/0001-event-driven-cqrs.md`, `0002-static-dispatch-over-trait-objects.md`, `0003-postcard-over-flatbuffers.md`
|
||||||
|
- **API types (DTO definitions)**: `crates/api-types/src/` — widget.rs, data_source.rs, layout.rs, preset.rs. These define the exact JSON shapes the API accepts/returns.
|
||||||
|
|
||||||
|
## REST API (server runs on :3000)
|
||||||
|
|
||||||
|
All endpoints return/accept JSON.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | /api/widgets | List all widgets |
|
||||||
|
| POST | /api/widgets | Create widget |
|
||||||
|
| GET | /api/widgets/{id} | Get widget |
|
||||||
|
| PUT | /api/widgets/{id} | Update widget |
|
||||||
|
| DELETE | /api/widgets/{id} | Delete widget |
|
||||||
|
| GET | /api/data-sources | List all data sources |
|
||||||
|
| POST | /api/data-sources | Create data source |
|
||||||
|
| GET | /api/data-sources/{id} | Get data source |
|
||||||
|
| PUT | /api/data-sources/{id} | Update data source |
|
||||||
|
| DELETE | /api/data-sources/{id} | Delete data source |
|
||||||
|
| GET | /api/layout | Get current layout (nullable) |
|
||||||
|
| PUT | /api/layout | Update layout |
|
||||||
|
| GET | /api/presets | List presets |
|
||||||
|
| POST | /api/presets | Create preset |
|
||||||
|
| GET | /api/presets/{id} | Get preset |
|
||||||
|
| DELETE | /api/presets/{id} | Delete preset |
|
||||||
|
| POST | /api/presets/{id}/load | Load preset as active layout |
|
||||||
|
|
||||||
|
### Key JSON shapes
|
||||||
|
|
||||||
|
**Widget** (create/update):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "weather",
|
||||||
|
"display_hint": "icon_value",
|
||||||
|
"data_source_id": 10,
|
||||||
|
"mappings": [
|
||||||
|
{"source_path": "$.main.temp", "target_key": "temperature"},
|
||||||
|
{"source_path": "$.weather[0].icon", "target_key": "icon"}
|
||||||
|
],
|
||||||
|
"max_data_size": 2048
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`display_hint` values: `"icon_value"`, `"text_block"`, `"key_value"`
|
||||||
|
|
||||||
|
**Data Source** (create/update):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"name": "weather",
|
||||||
|
"source_type": "weather",
|
||||||
|
"poll_interval_secs": 300,
|
||||||
|
"url": "https://api.openweathermap.org/...",
|
||||||
|
"api_key": "xxx",
|
||||||
|
"headers": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`source_type` values: `"weather"`, `"media"`, `"rss"`, `"http_json"`, `"webhook"`
|
||||||
|
|
||||||
|
**Layout**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"type": "container",
|
||||||
|
"direction": "row",
|
||||||
|
"gap": 4,
|
||||||
|
"padding": 2,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"sizing": {"type": "flex", "value": 1},
|
||||||
|
"node": {"type": "leaf", "widget_id": 1}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sizing": {"type": "fixed", "value": 80},
|
||||||
|
"node": {"type": "leaf", "widget_id": 2}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Nodes are recursive — containers can nest.
|
||||||
|
|
||||||
|
**Preset**:
|
||||||
|
```json
|
||||||
|
{"id": 1, "name": "dashboard", "layout": { "root": { ... } }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pages to build
|
||||||
|
|
||||||
|
1. **Dashboard** — overview of connected clients, active data sources, current layout. Landing page.
|
||||||
|
2. **Data Sources** — CRUD list. Form: name, source_type (select), URL, API key, poll interval, headers.
|
||||||
|
3. **Widgets** — CRUD list. Form: name, display_hint (select), data_source_id (select from existing sources), key mappings (dynamic list of source_path + target_key pairs), max_data_size.
|
||||||
|
4. **Layout Builder** — visual tree editor. Add containers (row/column), nest them, place widgets as leaves, set sizing (fixed/flex), gap, padding. This is the most complex page.
|
||||||
|
5. **Presets** — save current layout as preset, load presets, delete presets.
|
||||||
|
|
||||||
|
## SPA tech stack (already set up)
|
||||||
|
|
||||||
|
- React 19 + TypeScript
|
||||||
|
- Vite 8
|
||||||
|
- shadcn/ui (components already installed in `src/components/ui/`)
|
||||||
|
- TanStack Router (not yet configured with routes)
|
||||||
|
- TanStack Query (not yet configured with provider)
|
||||||
|
- Tailwind CSS 4
|
||||||
|
- Bun (lockfile is bun.lock)
|
||||||
|
|
||||||
|
## Server integration
|
||||||
|
|
||||||
|
The SPA's built files need to be served by the Axum server. Two approaches:
|
||||||
|
1. **Dev**: SPA runs on Vite dev server (:5173), proxies API calls to :3000. Add proxy config to `vite.config.ts`.
|
||||||
|
2. **Prod**: `bun run build` outputs to `spa/dist/`, Axum serves these as static files. Need to add static file serving to the http-api adapter.
|
||||||
|
|
||||||
|
The Vite proxy setup is needed first so development works.
|
||||||
|
|
||||||
|
## User preferences
|
||||||
|
|
||||||
|
- Concise, no filler
|
||||||
|
- No mocking — test against real API
|
||||||
|
- Clean code, modules, no huge files
|
||||||
|
- shadcn components for all UI elements
|
||||||
|
- TanStack Router for routing, TanStack Query for data fetching
|
||||||
|
- No Co-Authored-By in commits
|
||||||
|
|
||||||
|
## What NOT to change
|
||||||
|
|
||||||
|
- No changes to Rust crates (domain, application, adapters, etc.)
|
||||||
|
- No changes to the ESP32 firmware
|
||||||
|
- API is stable — build against it as-is
|
||||||
|
|
||||||
|
## Suggested skills
|
||||||
|
|
||||||
|
- `superpowers:brainstorming` — for designing the layout builder UX (most complex page)
|
||||||
|
- `frontend-design` — for visual design direction, making it not look like a default template
|
||||||
|
- `shadcn` — for component usage, composition, and styling patterns
|
||||||
24
spa/.gitignore
vendored
Normal file
24
spa/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
7
spa/.prettierignore
Normal file
7
spa/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
.pnpm-store/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
11
spa/.prettierrc
Normal file
11
spa/.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 80,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"],
|
||||||
|
"tailwindStylesheet": "src/index.css",
|
||||||
|
"tailwindFunctions": ["cn", "cva"]
|
||||||
|
}
|
||||||
21
spa/README.md
Normal file
21
spa/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# React + TypeScript + Vite + shadcn/ui
|
||||||
|
|
||||||
|
This is a template for a new Vite project with React, TypeScript, and shadcn/ui.
|
||||||
|
|
||||||
|
## Adding components
|
||||||
|
|
||||||
|
To add components to your app, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button
|
||||||
|
```
|
||||||
|
|
||||||
|
This will place the ui components in the `src/components` directory.
|
||||||
|
|
||||||
|
## Using components
|
||||||
|
|
||||||
|
To use the components in your app, import them as follows:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
```
|
||||||
1292
spa/bun.lock
Normal file
1292
spa/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
25
spa/components.json
Normal file
25
spa/components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
22
spa/eslint.config.js
Normal file
22
spa/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
spa/index.html
Normal file
13
spa/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>K-Frame</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
57
spa/package.json
Normal file
57
spa/package.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "spa",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.6.0",
|
||||||
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
|
"@tailwindcss/vite": "^4",
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
|
"@tanstack/react-router": "^1.170.16",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^1.21.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.6.0",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
|
"react-resizable-panels": "^4.11.2",
|
||||||
|
"recharts": "3.8.0",
|
||||||
|
"shadcn": "^4.11.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"vaul": "^1.1.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10",
|
||||||
|
"@types/node": "^24",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^6",
|
||||||
|
"eslint": "^10",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
|
"typescript": "~6",
|
||||||
|
"typescript-eslint": "^8",
|
||||||
|
"vite": "^8"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
spa/public/vite.svg
Normal file
1
spa/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
28
spa/src/api/client.ts
Normal file
28
spa/src/api/client.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const BASE = "/api"
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText)
|
||||||
|
throw new Error(`${res.status}: ${text}`)
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
const text = await res.text()
|
||||||
|
if (!text) return null as T
|
||||||
|
return JSON.parse(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => request<T>(path),
|
||||||
|
post: <T>(path: string, body: unknown) =>
|
||||||
|
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
put: <T>(path: string, body: unknown) =>
|
||||||
|
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||||
|
}
|
||||||
50
spa/src/api/data-sources.ts
Normal file
50
spa/src/api/data-sources.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { api } from "./client"
|
||||||
|
import type { DataSource } from "./types"
|
||||||
|
|
||||||
|
const KEYS = {
|
||||||
|
all: ["data-sources"] as const,
|
||||||
|
one: (id: number) => ["data-sources", id] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDataSources() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: KEYS.all,
|
||||||
|
queryFn: () => api.get<DataSource[]>("/data-sources"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDataSource(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: KEYS.one(id),
|
||||||
|
queryFn: () => api.get<DataSource>(`/data-sources/${id}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateDataSource() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (ds: DataSource) => api.post<DataSource>("/data-sources", ds),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEYS.all }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateDataSource() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (ds: DataSource) =>
|
||||||
|
api.put<DataSource>(`/data-sources/${ds.id}`, ds),
|
||||||
|
onSuccess: (_, ds) => {
|
||||||
|
qc.invalidateQueries({ queryKey: KEYS.all })
|
||||||
|
qc.invalidateQueries({ queryKey: KEYS.one(ds.id) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteDataSource() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => api.delete(`/data-sources/${id}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEYS.all }),
|
||||||
|
})
|
||||||
|
}
|
||||||
22
spa/src/api/layout.ts
Normal file
22
spa/src/api/layout.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { api } from "./client"
|
||||||
|
import type { Layout } from "./types"
|
||||||
|
|
||||||
|
const KEYS = {
|
||||||
|
current: ["layout"] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLayout() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: KEYS.current,
|
||||||
|
queryFn: () => api.get<Layout | null>("/layout"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateLayout() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (layout: Layout) => api.put<Layout>("/layout", layout),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEYS.current }),
|
||||||
|
})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user