Compare commits
12 Commits
a51d22649a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fa097771d4 | |||
| 7001b5e911 | |||
| 1c854d127f | |||
| 838e29702a | |||
| 5bcf4c4e0d | |||
| 27c1fe3f37 | |||
| b964801765 | |||
| 13497dd53c | |||
| 8b1dac9669 | |||
| a6152c9a9a | |||
| 455d5da901 | |||
| 437056cfc4 |
@@ -2,7 +2,6 @@
|
|||||||
KFRAME_DATABASE_URL=sqlite:kframe.db?mode=rwc
|
KFRAME_DATABASE_URL=sqlite:kframe.db?mode=rwc
|
||||||
KFRAME_TCP_ADDR=0.0.0.0:2699
|
KFRAME_TCP_ADDR=0.0.0.0:2699
|
||||||
KFRAME_HTTP_ADDR=0.0.0.0:3000
|
KFRAME_HTTP_ADDR=0.0.0.0:3000
|
||||||
KFRAME_POLL_INTERVAL_SECS=5
|
|
||||||
|
|
||||||
# Auth (required)
|
# Auth (required)
|
||||||
JWT_SECRET=change-me-to-a-random-secret
|
JWT_SECRET=change-me-to-a-random-secret
|
||||||
|
|||||||
19
Cargo.lock
generated
19
Cargo.lock
generated
@@ -79,9 +79,11 @@ dependencies = [
|
|||||||
name = "application"
|
name = "application"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"domain",
|
"domain",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -226,9 +228,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"application",
|
"application",
|
||||||
"chrono",
|
|
||||||
"chrono-tz",
|
|
||||||
"config-sqlite",
|
"config-sqlite",
|
||||||
|
"data-generators",
|
||||||
"domain",
|
"domain",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"http-api",
|
"http-api",
|
||||||
@@ -467,6 +468,16 @@ dependencies = [
|
|||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-generators"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"chrono-tz",
|
||||||
|
"domain",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -517,6 +528,9 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "domain"
|
name = "domain"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dotenvy"
|
name = "dotenvy"
|
||||||
@@ -1678,7 +1692,6 @@ dependencies = [
|
|||||||
name = "protocol"
|
name = "protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"domain",
|
|
||||||
"postcard",
|
"postcard",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ members = [
|
|||||||
"crates/adapters/media",
|
"crates/adapters/media",
|
||||||
"crates/adapters/auth",
|
"crates/adapters/auth",
|
||||||
"crates/adapters/secret-store",
|
"crates/adapters/secret-store",
|
||||||
|
"crates/adapters/data-generators",
|
||||||
"crates/api-types",
|
"crates/api-types",
|
||||||
"crates/bootstrap",
|
"crates/bootstrap",
|
||||||
"crates/client-desktop",
|
"crates/client-desktop",
|
||||||
@@ -57,5 +58,4 @@ postcard = { version = "1.1", default-features = false, features = ["alloc"] }
|
|||||||
tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] }
|
tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
chrono = "0.4"
|
data-generators = { path = "crates/adapters/data-generators" }
|
||||||
chrono-tz = "0.10"
|
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -25,6 +25,7 @@ Hexagonal / ports-and-adapters with full CQRS. Domain logic has zero framework d
|
|||||||
│ tcp-server binary protocol broadcast │
|
│ tcp-server binary protocol broadcast │
|
||||||
│ http-json external API polling │
|
│ http-json external API polling │
|
||||||
│ media, rss source-specific adapters │
|
│ media, rss source-specific adapters │
|
||||||
|
│ data-generators clock, static text │
|
||||||
│ auth argon2 + JWT │
|
│ auth argon2 + JWT │
|
||||||
├─────────────────── Shared ───────────────────┤
|
├─────────────────── Shared ───────────────────┤
|
||||||
│ protocol/ wire types, postcard serde │
|
│ protocol/ wire types, postcard serde │
|
||||||
@@ -53,10 +54,12 @@ See `docs/adr/` for architectural decision records and `CONTEXT.md` for the doma
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Data sources**: HTTP/JSON, weather, media (Subsonic/Navidrome), RSS, webhooks
|
- **Data sources**: HTTP/JSON, weather, media (Subsonic/Navidrome), RSS, webhooks, clock, static text
|
||||||
- **Layout engine**: flexbox-like containers (row/column, fixed/flex sizing, gap, padding, justify-content, align-items)
|
- **Layout engine**: flexbox-like containers (row/column, fixed/flex sizing, gap, padding, justify-content, align-items)
|
||||||
- **Theming**: 5 configurable colors (primary, secondary, accent, text, background), live push to clients
|
- **Theming**: 5 configurable colors (primary, secondary, accent, text, background), live push to clients
|
||||||
- **Rich text**: inline color markup (`{primary}text{/}`, `{#FF0000}hex{/}`)
|
- **Rich text**: inline color markup (`{primary}text{/}`, `{#FF0000}hex{/}`)
|
||||||
|
- **Widget alignment**: per-widget horizontal/vertical text alignment (left/center/right, top/middle/bottom), reflected in layout preview
|
||||||
|
- **Connection indicator**: green/red dot on ESP32 display showing server connectivity
|
||||||
- **Overflow scroll**: bounce animation when content exceeds widget bounds, speed auto-derived from overflow
|
- **Overflow scroll**: bounce animation when content exceeds widget bounds, speed auto-derived from overflow
|
||||||
- **Captive portal**: ESP32 AP mode with DNS + HTTP config form for WiFi provisioning
|
- **Captive portal**: ESP32 AP mode with DNS + HTTP config form for WiFi provisioning
|
||||||
- **Auth**: argon2 password hashing, JWT tokens, protected API routes
|
- **Auth**: argon2 password hashing, JWT tokens, protected API routes
|
||||||
@@ -86,6 +89,23 @@ cd spa && bun install && bun run dev
|
|||||||
# 5. Add a data source, create widgets, build a layout
|
# 5. Add a data source, create widgets, build a layout
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ESP32 wiring
|
||||||
|
|
||||||
|
2.4" ILI9341 SPI LCD module → ESP-WROOM-32:
|
||||||
|
|
||||||
|
| LCD Pin | ESP32 GPIO | Function |
|
||||||
|
|---------|------------|----------|
|
||||||
|
| VCC | 3V3 | Power |
|
||||||
|
| GND | GND | Ground |
|
||||||
|
| DIN | GPIO23 | SPI MOSI |
|
||||||
|
| CLK | GPIO18 | SPI SCLK |
|
||||||
|
| CS | GPIO26 | Chip select |
|
||||||
|
| DC | GPIO21 | Data/command |
|
||||||
|
| RST | GPIO22 | Reset |
|
||||||
|
| BL | 3V3 | Backlight (always on) |
|
||||||
|
|
||||||
|
Uses SPI2 (HSPI) at 26 MHz. Pin assignments are in `crates/client-esp32/src/main.rs`.
|
||||||
|
|
||||||
### ESP32 client
|
### ESP32 client
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
||||||
User, WidgetConfig, WidgetId,
|
User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
@@ -177,6 +177,10 @@ impl ConfigRepository for MemoryConfigStore {
|
|||||||
guard.remove(&id);
|
guard.remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRepository for MemoryConfigStore {
|
||||||
|
type Error = MemoryConfigError;
|
||||||
|
|
||||||
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
||||||
let guard = self
|
let guard = self
|
||||||
@@ -204,3 +208,18 @@ impl ConfigRepository for MemoryConfigStore {
|
|||||||
Ok(guard.len() as u32)
|
Ok(guard.len() as u32)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl WidgetStateCache for MemoryConfigStore {
|
||||||
|
type Error = MemoryConfigError;
|
||||||
|
|
||||||
|
async fn save_widget_states(
|
||||||
|
&self,
|
||||||
|
_states: &[(WidgetId, WidgetState)],
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain.workspace = true
|
domain = { workspace = true, features = ["json"] }
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -96,6 +96,15 @@ impl SqliteConfigStore {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS widget_state_cache (
|
||||||
|
widget_id INTEGER PRIMARY KEY,
|
||||||
|
state_json TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Add alignment columns to widgets (idempotent)
|
// Add alignment columns to widgets (idempotent)
|
||||||
let _ = sqlx::query("ALTER TABLE widgets ADD COLUMN h_align TEXT NOT NULL DEFAULT 'left'")
|
let _ = sqlx::query("ALTER TABLE widgets ADD COLUMN h_align TEXT NOT NULL DEFAULT 'left'")
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ mod layout;
|
|||||||
mod presets;
|
mod presets;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod users;
|
mod users;
|
||||||
|
mod widget_state_cache;
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|
||||||
use crate::SqliteConfigStore;
|
use crate::SqliteConfigStore;
|
||||||
use crate::error::SqliteConfigError;
|
use crate::error::SqliteConfigError;
|
||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
||||||
User, WidgetConfig, WidgetId,
|
User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl ConfigRepository for SqliteConfigStore {
|
impl ConfigRepository for SqliteConfigStore {
|
||||||
@@ -78,6 +79,10 @@ impl ConfigRepository for SqliteConfigStore {
|
|||||||
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
|
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
|
||||||
self.save_theme_impl(theme).await
|
self.save_theme_impl(theme).await
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRepository for SqliteConfigStore {
|
||||||
|
type Error = SqliteConfigError;
|
||||||
|
|
||||||
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
||||||
self.get_user_by_username_impl(username).await
|
self.get_user_by_username_impl(username).await
|
||||||
@@ -91,3 +96,18 @@ impl ConfigRepository for SqliteConfigStore {
|
|||||||
self.count_users_impl().await
|
self.count_users_impl().await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl WidgetStateCache for SqliteConfigStore {
|
||||||
|
type Error = SqliteConfigError;
|
||||||
|
|
||||||
|
async fn save_widget_states(
|
||||||
|
&self,
|
||||||
|
states: &[(WidgetId, WidgetState)],
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
self.save_widget_states_impl(states).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
|
||||||
|
self.load_widget_states_impl().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use crate::SqliteConfigStore;
|
||||||
|
use crate::error::SqliteConfigError;
|
||||||
|
use domain::{Value, WidgetId, WidgetState};
|
||||||
|
use sqlx::Row;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
impl SqliteConfigStore {
|
||||||
|
pub(crate) async fn save_widget_states_impl(
|
||||||
|
&self,
|
||||||
|
states: &[(WidgetId, WidgetState)],
|
||||||
|
) -> Result<(), SqliteConfigError> {
|
||||||
|
for (id, state) in states {
|
||||||
|
let json = domain_state_to_json(state)
|
||||||
|
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR REPLACE INTO widget_state_cache (widget_id, state_json) VALUES (?, ?)",
|
||||||
|
)
|
||||||
|
.bind(*id as i64)
|
||||||
|
.bind(&json)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_widget_states_impl(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<(WidgetId, WidgetState)>, SqliteConfigError> {
|
||||||
|
let rows = sqlx::query("SELECT widget_id, state_json FROM widget_state_cache")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for row in &rows {
|
||||||
|
let id: i64 = row.get("widget_id");
|
||||||
|
let json_str: String = row.get("state_json");
|
||||||
|
let state = json_to_domain_state(&json_str)
|
||||||
|
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||||
|
result.push((id as WidgetId, state));
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain_state_to_json(state: &WidgetState) -> Result<String, serde_json::Error> {
|
||||||
|
let data: serde_json::Map<String, serde_json::Value> = state
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.into()))
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string(&data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_domain_state(json: &str) -> Result<WidgetState, serde_json::Error> {
|
||||||
|
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(json)?;
|
||||||
|
let data: BTreeMap<String, Value> = map.into_iter().map(|(k, v)| (k, v.into())).collect();
|
||||||
|
Ok(WidgetState { data, error: None })
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use config_sqlite::SqliteConfigStore;
|
|||||||
use domain::{
|
use domain::{
|
||||||
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
|
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
|
||||||
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
|
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
|
||||||
LayoutNode, LayoutPreset, Sizing, WidgetConfig,
|
LayoutNode, LayoutPreset, Sizing, UserRepository, WidgetConfig, WidgetStateCache,
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|||||||
10
crates/adapters/data-generators/Cargo.toml
Normal file
10
crates/adapters/data-generators/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "data-generators"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
chrono = "0.4"
|
||||||
|
chrono-tz = "0.10"
|
||||||
|
thiserror.workspace = true
|
||||||
59
crates/adapters/data-generators/src/lib.rs
Normal file
59
crates/adapters/data-generators/src/lib.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
use domain::{DataSource, DataSourceConfig, DataSourcePort, Value};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ClockGenerator;
|
||||||
|
|
||||||
|
impl ClockGenerator {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum GeneratorError {
|
||||||
|
#[error("wrong config type for generator")]
|
||||||
|
WrongConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataSourcePort for ClockGenerator {
|
||||||
|
type Error = GeneratorError;
|
||||||
|
|
||||||
|
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
|
||||||
|
let (fmt, tz_name) = match &source.config {
|
||||||
|
DataSourceConfig::Clock { format, timezone } => (format.as_str(), timezone.as_str()),
|
||||||
|
_ => ("%H:%M:%S", "UTC"),
|
||||||
|
};
|
||||||
|
let tz: Tz = tz_name.parse().unwrap_or(chrono_tz::UTC);
|
||||||
|
let now = Utc::now().with_timezone(&tz);
|
||||||
|
let formatted = now.format(fmt).to_string();
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
map.insert("time".into(), Value::String(formatted));
|
||||||
|
Ok(Value::Object(map))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct StaticTextGenerator;
|
||||||
|
|
||||||
|
impl StaticTextGenerator {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataSourcePort for StaticTextGenerator {
|
||||||
|
type Error = GeneratorError;
|
||||||
|
|
||||||
|
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
|
||||||
|
let text = match &source.config {
|
||||||
|
DataSourceConfig::StaticText { text } => text.clone(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
map.insert("text".into(), Value::String(text));
|
||||||
|
Ok(Value::Object(map))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain.workspace = true
|
domain = { workspace = true, features = ["json"] }
|
||||||
application.workspace = true
|
application.workspace = true
|
||||||
api-types.workspace = true
|
api-types.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
pub mod extractors;
|
pub mod extractors;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
|
use application::ConfigService;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use domain::{
|
use domain::{
|
||||||
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
|
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
|
||||||
WidgetStateReader,
|
UserRepository, WidgetStateCache, WidgetStateReader,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
@@ -36,10 +37,23 @@ impl<C, E, W, B, R, A, H> Clone for AppState<C, E, W, B, R, A, H> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<C, E, W, B, R, A, H> AppState<C, E, W, B, R, A, H>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
E: EventPublisher,
|
||||||
|
E::Error: std::fmt::Debug,
|
||||||
|
{
|
||||||
|
pub fn config_service(&self) -> ConfigService<'_, C, E> {
|
||||||
|
ConfigService::new(self.config.as_ref(), self.events.as_ref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
|
pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
|
||||||
C::Error: std::fmt::Debug + Send,
|
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
|
||||||
|
<C as UserRepository>::Error: std::fmt::Debug + Send,
|
||||||
E: EventPublisher + Send + Sync + 'static,
|
E: EventPublisher + Send + Sync + 'static,
|
||||||
E::Error: std::fmt::Debug + Send,
|
E::Error: std::fmt::Debug + Send,
|
||||||
W: WidgetStateReader + Send + Sync + 'static,
|
W: WidgetStateReader + Send + Sync + 'static,
|
||||||
@@ -69,8 +83,9 @@ pub async fn serve<C, E, W, B, R, A, H>(
|
|||||||
state: AppState<C, E, W, B, R, A, H>,
|
state: AppState<C, E, W, B, R, A, H>,
|
||||||
) -> Result<(), std::io::Error>
|
) -> Result<(), std::io::Error>
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
|
||||||
C::Error: std::fmt::Debug + Send,
|
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
|
||||||
|
<C as UserRepository>::Error: std::fmt::Debug + Send,
|
||||||
E: EventPublisher + Send + Sync + 'static,
|
E: EventPublisher + Send + Sync + 'static,
|
||||||
E::Error: std::fmt::Debug + Send,
|
E::Error: std::fmt::Debug + Send,
|
||||||
W: WidgetStateReader + Send + Sync + 'static,
|
W: WidgetStateReader + Send + Sync + 'static,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::AppState;
|
|||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
use domain::{AuthPort, ConfigRepository, PasswordHashPort};
|
use domain::{AuthPort, PasswordHashPort, UserRepository};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||||
@@ -28,7 +28,7 @@ pub async fn login<C, E, W, B, R, A, H>(
|
|||||||
Json(body): Json<LoginRequest>,
|
Json(body): Json<LoginRequest>,
|
||||||
) -> Result<Json<LoginResponse>, (StatusCode, String)>
|
) -> Result<Json<LoginResponse>, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: UserRepository,
|
||||||
C::Error: std::fmt::Debug,
|
C::Error: std::fmt::Debug,
|
||||||
A: AuthPort,
|
A: AuthPort,
|
||||||
H: PasswordHashPort,
|
H: PasswordHashPort,
|
||||||
@@ -51,7 +51,7 @@ pub async fn register<C, E, W, B, R, A, H>(
|
|||||||
Json(body): Json<LoginRequest>,
|
Json(body): Json<LoginRequest>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: UserRepository,
|
||||||
C::Error: std::fmt::Debug,
|
C::Error: std::fmt::Debug,
|
||||||
H: PasswordHashPort,
|
H: PasswordHashPort,
|
||||||
{
|
{
|
||||||
@@ -71,7 +71,7 @@ pub async fn auth_status<C, E, W, B, R, A, H>(
|
|||||||
State(state): S<C, E, W, B, R, A, H>,
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
) -> Result<Json<StatusResponse>, StatusCode>
|
) -> Result<Json<StatusResponse>, StatusCode>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: UserRepository,
|
||||||
C::Error: std::fmt::Debug,
|
C::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let count = state
|
let count = state
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::extractors::AuthUser;
|
use crate::extractors::AuthUser;
|
||||||
use api_types::DataSourceDto;
|
use api_types::DataSourceDto;
|
||||||
use application::ConfigService;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
@@ -65,7 +65,7 @@ where
|
|||||||
let source = body
|
let source = body
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = state.config_service();
|
||||||
svc.create_data_source(source)
|
svc.create_data_source(source)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
@@ -87,7 +87,7 @@ where
|
|||||||
let source = body
|
let source = body
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = state.config_service();
|
||||||
svc.update_data_source(source)
|
svc.update_data_source(source)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
@@ -105,7 +105,7 @@ where
|
|||||||
E: EventPublisher,
|
E: EventPublisher,
|
||||||
E::Error: std::fmt::Debug,
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = state.config_service();
|
||||||
svc.delete_data_source(id)
|
svc.delete_data_source(id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::extractors::AuthUser;
|
use crate::extractors::AuthUser;
|
||||||
use api_types::LayoutDto;
|
use api_types::LayoutDto;
|
||||||
use application::ConfigService;
|
|
||||||
use axum::{extract::State, http::StatusCode, response::Json};
|
use axum::{extract::State, http::StatusCode, response::Json};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ where
|
|||||||
let layout = body
|
let layout = body
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = state.config_service();
|
||||||
svc.update_layout(layout)
|
svc.update_layout(layout)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ use axum::Router;
|
|||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use domain::{
|
use domain::{
|
||||||
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
|
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
|
||||||
WidgetStateReader,
|
UserRepository, WidgetStateCache, WidgetStateReader,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn api_routes<C, E, W, B, R, A, H>() -> Router<AppState<C, E, W, B, R, A, H>>
|
pub fn api_routes<C, E, W, B, R, A, H>() -> Router<AppState<C, E, W, B, R, A, H>>
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
|
||||||
C::Error: std::fmt::Debug + Send,
|
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
|
||||||
|
<C as UserRepository>::Error: std::fmt::Debug + Send,
|
||||||
E: EventPublisher + Send + Sync + 'static,
|
E: EventPublisher + Send + Sync + 'static,
|
||||||
E::Error: std::fmt::Debug + Send,
|
E::Error: std::fmt::Debug + Send,
|
||||||
W: WidgetStateReader + Send + Sync + 'static,
|
W: WidgetStateReader + Send + Sync + 'static,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::extractors::AuthUser;
|
use crate::extractors::AuthUser;
|
||||||
use api_types::{CreatePresetDto, PresetDto};
|
use api_types::{CreatePresetDto, PresetDto};
|
||||||
use application::ConfigService;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
@@ -65,7 +65,7 @@ where
|
|||||||
let preset = body
|
let preset = body
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = state.config_service();
|
||||||
svc.save_preset(preset)
|
svc.save_preset(preset)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
@@ -83,7 +83,7 @@ where
|
|||||||
E: EventPublisher,
|
E: EventPublisher,
|
||||||
E::Error: std::fmt::Debug,
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = state.config_service();
|
||||||
svc.delete_preset(id)
|
svc.delete_preset(id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -101,7 +101,7 @@ where
|
|||||||
E: EventPublisher,
|
E: EventPublisher,
|
||||||
E::Error: std::fmt::Debug,
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = state.config_service();
|
||||||
svc.load_preset(id)
|
svc.load_preset(id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::extractors::AuthUser;
|
use crate::extractors::AuthUser;
|
||||||
use api_types::ThemeDto;
|
use api_types::ThemeDto;
|
||||||
use application::ConfigService;
|
|
||||||
use axum::{extract::State, http::StatusCode, response::Json};
|
use axum::{extract::State, http::StatusCode, response::Json};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ where
|
|||||||
E::Error: std::fmt::Debug,
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let theme = body.into_domain();
|
let theme = body.into_domain();
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = state.config_service();
|
||||||
svc.update_theme(theme)
|
svc.update_theme(theme)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::AppState;
|
|||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
use domain::{BroadcastPort, ConfigRepository, EventPublisher, WidgetStateReader};
|
use domain::{ConfigRepository, DomainEvent, EventPublisher};
|
||||||
|
|
||||||
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||||
|
|
||||||
@@ -16,9 +16,6 @@ where
|
|||||||
C::Error: std::fmt::Debug,
|
C::Error: std::fmt::Debug,
|
||||||
E: EventPublisher,
|
E: EventPublisher,
|
||||||
E::Error: std::fmt::Debug,
|
E::Error: std::fmt::Debug,
|
||||||
W: WidgetStateReader,
|
|
||||||
B: BroadcastPort,
|
|
||||||
B::Error: std::fmt::Debug,
|
|
||||||
{
|
{
|
||||||
let source = state
|
let source = state
|
||||||
.config
|
.config
|
||||||
@@ -34,55 +31,13 @@ where
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw = json_to_domain_value(body);
|
let data: domain::Value = body.into();
|
||||||
let widgets = state
|
|
||||||
.config
|
state
|
||||||
.list_widgets()
|
.events
|
||||||
|
.publish(DomainEvent::WebhookDataReceived { source_id, data })
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||||
|
|
||||||
let layout = state
|
|
||||||
.config
|
|
||||||
.get_layout()
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
|
||||||
|
|
||||||
let changed = state
|
|
||||||
.widget_states
|
|
||||||
.apply_raw_data(source_id, &raw, &widgets)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if !changed.is_empty()
|
|
||||||
&& let Some(l) = &layout
|
|
||||||
{
|
|
||||||
let with_hints: Vec<_> = changed
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(id, s)| {
|
|
||||||
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
|
|
||||||
Some((*id, hint, s.clone()))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let _ = state.broadcaster.push_screen_update(l, &with_hints).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn json_to_domain_value(json: serde_json::Value) -> domain::Value {
|
|
||||||
match json {
|
|
||||||
serde_json::Value::Null => domain::Value::Null,
|
|
||||||
serde_json::Value::Bool(b) => domain::Value::Bool(b),
|
|
||||||
serde_json::Value::Number(n) => domain::Value::Number(n.as_f64().unwrap_or(0.0)),
|
|
||||||
serde_json::Value::String(s) => domain::Value::String(s),
|
|
||||||
serde_json::Value::Array(arr) => {
|
|
||||||
domain::Value::Array(arr.into_iter().map(json_to_domain_value).collect())
|
|
||||||
}
|
|
||||||
serde_json::Value::Object(obj) => {
|
|
||||||
let map = obj
|
|
||||||
.into_iter()
|
|
||||||
.map(|(k, v)| (k, json_to_domain_value(v)))
|
|
||||||
.collect();
|
|
||||||
domain::Value::Object(map)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::extractors::AuthUser;
|
use crate::extractors::AuthUser;
|
||||||
use api_types::{CreateWidgetDto, WidgetDto};
|
use api_types::{CreateWidgetDto, WidgetDto};
|
||||||
use application::ConfigService;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
@@ -65,7 +65,7 @@ where
|
|||||||
let widget = body
|
let widget = body
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = state.config_service();
|
||||||
svc.create_widget(widget)
|
svc.create_widget(widget)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
@@ -87,7 +87,7 @@ where
|
|||||||
let widget = body
|
let widget = body
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = state.config_service();
|
||||||
svc.update_widget(widget)
|
svc.update_widget(widget)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||||
@@ -105,7 +105,7 @@ where
|
|||||||
E: EventPublisher,
|
E: EventPublisher,
|
||||||
E::Error: std::fmt::Debug,
|
E::Error: std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
let svc = state.config_service();
|
||||||
svc.delete_widget(id)
|
svc.delete_widget(id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -126,32 +126,10 @@ where
|
|||||||
{
|
{
|
||||||
match state.widget_states.get_widget_state(id).await {
|
match state.widget_states.get_widget_state(id).await {
|
||||||
Some(ws) => {
|
Some(ws) => {
|
||||||
let map: serde_json::Map<String, serde_json::Value> = ws
|
let map: serde_json::Map<String, serde_json::Value> =
|
||||||
.data
|
ws.data.iter().map(|(k, v)| (k.clone(), v.into())).collect();
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
|
|
||||||
.collect();
|
|
||||||
Ok(Json(serde_json::Value::Object(map)))
|
Ok(Json(serde_json::Value::Object(map)))
|
||||||
}
|
}
|
||||||
None => Err(StatusCode::NOT_FOUND),
|
None => Err(StatusCode::NOT_FOUND),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn domain_value_to_json(v: &domain::Value) -> serde_json::Value {
|
|
||||||
match v {
|
|
||||||
domain::Value::Null => serde_json::Value::Null,
|
|
||||||
domain::Value::Bool(b) => serde_json::Value::Bool(*b),
|
|
||||||
domain::Value::Number(n) => serde_json::json!(n),
|
|
||||||
domain::Value::String(s) => serde_json::Value::String(s.clone()),
|
|
||||||
domain::Value::Array(arr) => {
|
|
||||||
serde_json::Value::Array(arr.iter().map(domain_value_to_json).collect())
|
|
||||||
}
|
|
||||||
domain::Value::Object(obj) => {
|
|
||||||
let map = obj
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
|
|
||||||
.collect();
|
|
||||||
serde_json::Value::Object(map)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain.workspace = true
|
domain = { workspace = true, features = ["json"] }
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -28,21 +28,6 @@ impl HttpJsonAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn json_to_value(json: serde_json::Value) -> Value {
|
|
||||||
match json {
|
|
||||||
serde_json::Value::Null => Value::Null,
|
|
||||||
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::String(s) => Value::String(s),
|
|
||||||
serde_json::Value::Array(arr) => Value::Array(arr.into_iter().map(json_to_value).collect()),
|
|
||||||
serde_json::Value::Object(map) => Value::Object(
|
|
||||||
map.into_iter()
|
|
||||||
.map(|(k, v)| (k, json_to_value(v)))
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DataSourcePort for HttpJsonAdapter {
|
impl DataSourcePort for HttpJsonAdapter {
|
||||||
type Error = HttpJsonError;
|
type Error = HttpJsonError;
|
||||||
|
|
||||||
@@ -70,6 +55,6 @@ impl DataSourcePort for HttpJsonAdapter {
|
|||||||
let resp = req.send().await.map_err(HttpJsonError::Request)?;
|
let resp = req.send().await.map_err(HttpJsonError::Request)?;
|
||||||
let json: serde_json::Value = resp.json().await.map_err(HttpJsonError::Request)?;
|
let json: serde_json::Value = resp.json().await.map_err(HttpJsonError::Request)?;
|
||||||
|
|
||||||
Ok(json_to_value(json))
|
Ok(json.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire};
|
||||||
use crate::error::TcpServerError;
|
use crate::error::TcpServerError;
|
||||||
use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState};
|
use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState};
|
||||||
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode, WireTheme, encode};
|
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireTheme, encode};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
pub struct TcpBroadcaster {
|
pub struct TcpBroadcaster {
|
||||||
@@ -31,13 +32,13 @@ impl BroadcastPort for TcpBroadcaster {
|
|||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
widgets: &[(WidgetId, DisplayHint, WidgetState)],
|
widgets: &[(WidgetId, DisplayHint, WidgetState)],
|
||||||
) -> Result<(), Self::Error> {
|
) -> Result<(), Self::Error> {
|
||||||
let wire_layout: WireLayoutNode = (&layout.root).into();
|
let wire_layout = layout_to_wire(&layout.root);
|
||||||
let wire_widgets: Vec<WidgetDescriptor> = widgets
|
let wire_widgets: Vec<WidgetDescriptor> = widgets
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(id, hint, state)| WidgetDescriptor {
|
.map(|(id, hint, state)| WidgetDescriptor {
|
||||||
id: *id,
|
id: *id,
|
||||||
display_hint: hint.into(),
|
display_hint: display_hint_to_wire(hint),
|
||||||
state: state.into(),
|
state: widget_state_to_wire(state),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -58,8 +59,8 @@ impl BroadcastPort for TcpBroadcaster {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(id, hint, state)| WidgetDescriptor {
|
.map(|(id, hint, state)| WidgetDescriptor {
|
||||||
id: *id,
|
id: *id,
|
||||||
display_hint: hint.into(),
|
display_hint: display_hint_to_wire(hint),
|
||||||
state: state.into(),
|
state: widget_state_to_wire(state),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
103
crates/adapters/tcp-server/src/conversions.rs
Normal file
103
crates/adapters/tcp-server/src/conversions.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
use domain::value_objects::{
|
||||||
|
AlignItems, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent, LayoutNode,
|
||||||
|
Sizing, VAlign, Value, WidgetError, WidgetState,
|
||||||
|
};
|
||||||
|
use protocol::{
|
||||||
|
WireAlignItems, WireContainerNode, WireDirection, WireDisplayHint, WireDisplayHintKind,
|
||||||
|
WireHAlign, WireJustifyContent, WireKeyValue, WireLayoutChild, WireLayoutNode, WireSizing,
|
||||||
|
WireVAlign, WireValue, WireWidgetError, WireWidgetState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn value_to_wire(v: &Value) -> WireValue {
|
||||||
|
match v {
|
||||||
|
Value::Null => WireValue::Null,
|
||||||
|
Value::Bool(b) => WireValue::Bool(*b),
|
||||||
|
Value::Number(n) => WireValue::Number(*n),
|
||||||
|
Value::String(s) => WireValue::String(s.clone()),
|
||||||
|
Value::Array(arr) => WireValue::Array(arr.iter().map(value_to_wire).collect()),
|
||||||
|
Value::Object(map) => WireValue::Object(
|
||||||
|
map.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), value_to_wire(v)))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_error_to_wire(e: &WidgetError) -> WireWidgetError {
|
||||||
|
match e {
|
||||||
|
WidgetError::SourceUnavailable => WireWidgetError::SourceUnavailable,
|
||||||
|
WidgetError::ExtractionFailed => WireWidgetError::ExtractionFailed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_state_to_wire(s: &WidgetState) -> WireWidgetState {
|
||||||
|
WireWidgetState {
|
||||||
|
data: s
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| WireKeyValue {
|
||||||
|
key: k.clone(),
|
||||||
|
value: value_to_wire(v),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
error: s.error.as_ref().map(widget_error_to_wire),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_hint_to_wire(h: &DisplayHint) -> WireDisplayHint {
|
||||||
|
WireDisplayHint {
|
||||||
|
kind: match h.kind {
|
||||||
|
DisplayHintKind::IconValue => WireDisplayHintKind::IconValue,
|
||||||
|
DisplayHintKind::TextBlock => WireDisplayHintKind::TextBlock,
|
||||||
|
DisplayHintKind::KeyValue => WireDisplayHintKind::KeyValue,
|
||||||
|
},
|
||||||
|
h_align: match h.h_align {
|
||||||
|
HAlign::Left => WireHAlign::Left,
|
||||||
|
HAlign::Center => WireHAlign::Center,
|
||||||
|
HAlign::Right => WireHAlign::Right,
|
||||||
|
},
|
||||||
|
v_align: match h.v_align {
|
||||||
|
VAlign::Top => WireVAlign::Top,
|
||||||
|
VAlign::Middle => WireVAlign::Middle,
|
||||||
|
VAlign::Bottom => WireVAlign::Bottom,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn layout_to_wire(n: &LayoutNode) -> WireLayoutNode {
|
||||||
|
match n {
|
||||||
|
LayoutNode::Leaf(id) => WireLayoutNode::Leaf(*id),
|
||||||
|
LayoutNode::Container(c) => WireLayoutNode::Container(WireContainerNode {
|
||||||
|
direction: match c.direction {
|
||||||
|
Direction::Row => WireDirection::Row,
|
||||||
|
Direction::Column => WireDirection::Column,
|
||||||
|
},
|
||||||
|
gap: c.gap,
|
||||||
|
padding: c.padding,
|
||||||
|
justify_content: match c.justify_content {
|
||||||
|
JustifyContent::Start => WireJustifyContent::Start,
|
||||||
|
JustifyContent::Center => WireJustifyContent::Center,
|
||||||
|
JustifyContent::End => WireJustifyContent::End,
|
||||||
|
JustifyContent::SpaceBetween => WireJustifyContent::SpaceBetween,
|
||||||
|
JustifyContent::SpaceEvenly => WireJustifyContent::SpaceEvenly,
|
||||||
|
},
|
||||||
|
align_items: match c.align_items {
|
||||||
|
AlignItems::Start => WireAlignItems::Start,
|
||||||
|
AlignItems::Center => WireAlignItems::Center,
|
||||||
|
AlignItems::End => WireAlignItems::End,
|
||||||
|
AlignItems::Stretch => WireAlignItems::Stretch,
|
||||||
|
},
|
||||||
|
children: c
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.map(|ch| WireLayoutChild {
|
||||||
|
sizing: match ch.sizing {
|
||||||
|
Sizing::Fixed(px) => WireSizing::Fixed(px),
|
||||||
|
Sizing::Flex(w) => WireSizing::Flex(w),
|
||||||
|
},
|
||||||
|
node: layout_to_wire(&ch.node),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod broadcaster;
|
mod broadcaster;
|
||||||
mod client_tracker;
|
mod client_tracker;
|
||||||
|
mod conversions;
|
||||||
mod error;
|
mod error;
|
||||||
mod event_bus;
|
mod event_bus;
|
||||||
mod server;
|
mod server;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use crate::broadcaster::domain_theme_to_wire;
|
use crate::broadcaster::domain_theme_to_wire;
|
||||||
use crate::client_tracker::ClientTracker;
|
use crate::client_tracker::ClientTracker;
|
||||||
|
use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire};
|
||||||
use crate::error::TcpServerError;
|
use crate::error::TcpServerError;
|
||||||
use domain::{ConfigRepository, WidgetStateReader};
|
use domain::{ConfigRepository, WidgetStateReader};
|
||||||
use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode};
|
use protocol::{ServerMessage, WidgetDescriptor, encode};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -87,14 +88,14 @@ where
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let wire_layout: WireLayoutNode = (&layout.root).into();
|
let wire_layout = layout_to_wire(&layout.root);
|
||||||
let mut wire_widgets = Vec::new();
|
let mut wire_widgets = Vec::new();
|
||||||
for w in &widgets {
|
for w in &widgets {
|
||||||
if let Some(s) = widget_states.get_widget_state(w.id).await {
|
if let Some(s) = widget_states.get_widget_state(w.id).await {
|
||||||
wire_widgets.push(WidgetDescriptor {
|
wire_widgets.push(WidgetDescriptor {
|
||||||
id: w.id,
|
id: w.id,
|
||||||
display_hint: (&w.display_hint).into(),
|
display_hint: display_hint_to_wire(&w.display_hint),
|
||||||
state: (&s).into(),
|
state: widget_state_to_wire(&s),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ edition = "2024"
|
|||||||
domain.workspace = true
|
domain.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use domain::{AuthPort, ConfigRepository, PasswordHashPort, User};
|
use domain::{AuthPort, PasswordHashPort, User, UserRepository};
|
||||||
|
|
||||||
pub enum AuthError<E> {
|
pub enum AuthError<E> {
|
||||||
InvalidCredentials,
|
InvalidCredentials,
|
||||||
@@ -26,7 +26,7 @@ pub async fn login<C, A, H>(
|
|||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<String, AuthError<C::Error>>
|
) -> Result<String, AuthError<C::Error>>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: UserRepository,
|
||||||
A: AuthPort,
|
A: AuthPort,
|
||||||
H: PasswordHashPort,
|
H: PasswordHashPort,
|
||||||
{
|
{
|
||||||
@@ -55,7 +55,7 @@ pub async fn register<C, H>(
|
|||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<(), AuthError<C::Error>>
|
) -> Result<(), AuthError<C::Error>>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: UserRepository,
|
||||||
H: PasswordHashPort,
|
H: PasswordHashPort,
|
||||||
{
|
{
|
||||||
let count = config.count_users().await.map_err(AuthError::Repository)?;
|
let count = config.count_users().await.map_err(AuthError::Repository)?;
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ impl DataProjection {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn seed(&self, states: Vec<(WidgetId, WidgetState)>) {
|
||||||
|
let mut current = self.current.lock().await;
|
||||||
|
for (id, state) in states {
|
||||||
|
current.insert(id, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_state(&self, widget_id: WidgetId) -> Option<WidgetState> {
|
pub async fn get_state(&self, widget_id: WidgetId) -> Option<WidgetState> {
|
||||||
self.current.lock().await.get(&widget_id).cloned()
|
self.current.lock().await.get(&widget_id).cloned()
|
||||||
}
|
}
|
||||||
|
|||||||
123
crates/application/src/event_service.rs
Normal file
123
crates/application/src/event_service.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
use crate::DataProjection;
|
||||||
|
use domain::{
|
||||||
|
BroadcastPort, ConfigRepository, DomainEvent, Layout, Value, WidgetConfig, WidgetState,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
pub async fn handle_event<C, B>(
|
||||||
|
event: DomainEvent,
|
||||||
|
config: &Arc<C>,
|
||||||
|
broadcaster: &Arc<B>,
|
||||||
|
projection: &Arc<DataProjection>,
|
||||||
|
) where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Display,
|
||||||
|
B: BroadcastPort,
|
||||||
|
B::Error: std::fmt::Display,
|
||||||
|
{
|
||||||
|
match event {
|
||||||
|
DomainEvent::LayoutChanged { layout } => {
|
||||||
|
handle_layout_changed(&layout, config, broadcaster, projection).await;
|
||||||
|
}
|
||||||
|
DomainEvent::WebhookDataReceived { source_id, data } => {
|
||||||
|
handle_webhook_data(source_id, &data, config, broadcaster, projection).await;
|
||||||
|
}
|
||||||
|
DomainEvent::ThemeChanged { theme } => {
|
||||||
|
if let Err(e) = broadcaster.push_theme_update(&theme).await {
|
||||||
|
error!(error = %e, "failed to push theme update");
|
||||||
|
}
|
||||||
|
info!("theme changed, pushed update to clients");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_layout_changed<C, B>(
|
||||||
|
layout: &Layout,
|
||||||
|
config: &Arc<C>,
|
||||||
|
broadcaster: &Arc<B>,
|
||||||
|
projection: &Arc<DataProjection>,
|
||||||
|
) where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Display,
|
||||||
|
B: BroadcastPort,
|
||||||
|
B::Error: std::fmt::Display,
|
||||||
|
{
|
||||||
|
let widgets = match config.list_widgets().await {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "failed to fetch widgets for screen update");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut widget_states = Vec::new();
|
||||||
|
for w in &widgets {
|
||||||
|
if let Some(s) = projection.get_state(w.id).await {
|
||||||
|
widget_states.push((w.id, w.display_hint.clone(), s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_webhook_data<C, B>(
|
||||||
|
source_id: u16,
|
||||||
|
data: &Value,
|
||||||
|
config: &Arc<C>,
|
||||||
|
broadcaster: &Arc<B>,
|
||||||
|
projection: &Arc<DataProjection>,
|
||||||
|
) where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Display,
|
||||||
|
B: BroadcastPort,
|
||||||
|
B::Error: std::fmt::Display,
|
||||||
|
{
|
||||||
|
let widgets = match config.list_widgets().await {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "failed to fetch widgets for webhook");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let changed = apply_and_broadcast(source_id, data, &widgets, broadcaster, projection).await;
|
||||||
|
if !changed.is_empty() {
|
||||||
|
info!(source_id, count = changed.len(), "webhook data pushed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn apply_and_broadcast<B>(
|
||||||
|
source_id: u16,
|
||||||
|
data: &Value,
|
||||||
|
widgets: &[WidgetConfig],
|
||||||
|
broadcaster: &Arc<B>,
|
||||||
|
projection: &Arc<DataProjection>,
|
||||||
|
) -> Vec<(u16, WidgetState)>
|
||||||
|
where
|
||||||
|
B: BroadcastPort,
|
||||||
|
B::Error: std::fmt::Display,
|
||||||
|
{
|
||||||
|
let changed: Vec<(u16, WidgetState)> =
|
||||||
|
projection.apply_poll_result(source_id, data, widgets).await;
|
||||||
|
|
||||||
|
if !changed.is_empty() {
|
||||||
|
let with_hints: Vec<_> = changed
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, state)| {
|
||||||
|
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
|
||||||
|
Some((*id, hint, state.clone()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Err(e) = broadcaster.push_data_update(&with_hints).await {
|
||||||
|
warn!(error = %e, "failed to push update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod auth_service;
|
pub mod auth_service;
|
||||||
mod config_service;
|
mod config_service;
|
||||||
mod data_projection;
|
mod data_projection;
|
||||||
|
pub mod event_service;
|
||||||
|
pub mod polling_service;
|
||||||
|
|
||||||
pub use config_service::ConfigService;
|
pub use config_service::ConfigService;
|
||||||
pub use data_projection::DataProjection;
|
pub use data_projection::DataProjection;
|
||||||
|
|||||||
254
crates/application/src/polling_service.rs
Normal file
254
crates/application/src/polling_service.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
use crate::DataProjection;
|
||||||
|
use domain::{
|
||||||
|
BroadcastPort, ConfigRepository, DataSource, Value, WidgetConfig, WidgetError, WidgetState,
|
||||||
|
WidgetStateCache,
|
||||||
|
};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
pub async fn run<C, B, P, F>(
|
||||||
|
config: Arc<C>,
|
||||||
|
broadcaster: Arc<B>,
|
||||||
|
projection: Arc<DataProjection>,
|
||||||
|
poller: Arc<P>,
|
||||||
|
) where
|
||||||
|
C: ConfigRepository + WidgetStateCache + Send + Sync + 'static,
|
||||||
|
<C as ConfigRepository>::Error: std::fmt::Display + Send,
|
||||||
|
<C as WidgetStateCache>::Error: std::fmt::Display + Send,
|
||||||
|
B: BroadcastPort + Send + Sync + 'static,
|
||||||
|
B::Error: std::fmt::Display + Send,
|
||||||
|
P: Fn(&DataSource) -> F + Send + Sync + 'static,
|
||||||
|
F: Future<Output = Result<Value, anyhow::Error>> + Send,
|
||||||
|
{
|
||||||
|
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
|
||||||
|
let mut static_done: HashSet<u16> = HashSet::new();
|
||||||
|
|
||||||
|
info!("polling manager started");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let sources = match config.list_data_sources().await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "failed to list data sources");
|
||||||
|
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_ids: Vec<u16> = sources.iter().map(|s| s.id).collect();
|
||||||
|
|
||||||
|
running.retain(|id, handle| {
|
||||||
|
if !current_ids.contains(id) {
|
||||||
|
info!(source_id = id, "stopping poll for removed source");
|
||||||
|
handle.abort();
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
static_done.retain(|id| current_ids.contains(id));
|
||||||
|
|
||||||
|
for source in &sources {
|
||||||
|
if source.source_type == domain::DataSourceType::Webhook {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if source.source_type == domain::DataSourceType::StaticText {
|
||||||
|
if static_done.contains(&source.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
poll_and_broadcast(&*poller, source, &config, &broadcaster, &projection).await;
|
||||||
|
static_done.insert(source.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if running.contains_key(&source.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_id = source.id;
|
||||||
|
let source = source.clone();
|
||||||
|
let config = config.clone();
|
||||||
|
let broadcaster = broadcaster.clone();
|
||||||
|
let projection = projection.clone();
|
||||||
|
let poller = poller.clone();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
source_id = source.id,
|
||||||
|
name = %source.name,
|
||||||
|
interval_secs = source.poll_interval.as_secs(),
|
||||||
|
"starting poll task"
|
||||||
|
);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
poll_loop(source, config, broadcaster, projection, poller).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
running.insert(source_id, handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if running.is_empty() && static_done.is_empty() {
|
||||||
|
debug!("no pollable sources, waiting");
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll_and_broadcast<C, B, P, F>(
|
||||||
|
poller: &P,
|
||||||
|
source: &DataSource,
|
||||||
|
config: &Arc<C>,
|
||||||
|
broadcaster: &Arc<B>,
|
||||||
|
projection: &Arc<DataProjection>,
|
||||||
|
) where
|
||||||
|
C: ConfigRepository + WidgetStateCache,
|
||||||
|
<C as ConfigRepository>::Error: std::fmt::Display,
|
||||||
|
<C as WidgetStateCache>::Error: std::fmt::Display,
|
||||||
|
B: BroadcastPort,
|
||||||
|
B::Error: std::fmt::Display,
|
||||||
|
P: Fn(&DataSource) -> F,
|
||||||
|
F: Future<Output = Result<Value, anyhow::Error>>,
|
||||||
|
{
|
||||||
|
let result = match poller(source).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(source = %source.name, error = %e, "poll failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let widgets = match config.list_widgets().await {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "failed to fetch widgets");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let changed = crate::event_service::apply_and_broadcast(
|
||||||
|
source.id,
|
||||||
|
&result,
|
||||||
|
&widgets,
|
||||||
|
broadcaster,
|
||||||
|
projection,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if !changed.is_empty() {
|
||||||
|
if let Err(e) = config.save_widget_states(&changed).await {
|
||||||
|
warn!(error = %e, "failed to cache widget states");
|
||||||
|
}
|
||||||
|
info!(source = %source.name, count = changed.len(), "pushed widget updates");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll_loop<C, B, P, F>(
|
||||||
|
source: DataSource,
|
||||||
|
config: Arc<C>,
|
||||||
|
broadcaster: Arc<B>,
|
||||||
|
projection: Arc<DataProjection>,
|
||||||
|
poller: Arc<P>,
|
||||||
|
) where
|
||||||
|
C: ConfigRepository + WidgetStateCache,
|
||||||
|
<C as ConfigRepository>::Error: std::fmt::Display,
|
||||||
|
<C as WidgetStateCache>::Error: std::fmt::Display,
|
||||||
|
B: BroadcastPort,
|
||||||
|
B::Error: std::fmt::Display,
|
||||||
|
P: Fn(&DataSource) -> F,
|
||||||
|
F: Future<Output = Result<Value, anyhow::Error>>,
|
||||||
|
{
|
||||||
|
let interval = source.poll_interval;
|
||||||
|
let mut widgets = match config.list_widgets().await {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "failed to fetch initial widget list");
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut last_refresh = tokio::time::Instant::now();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let result = match poller(&source).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(source = %source.name, error = %e, "poll failed");
|
||||||
|
broadcast_errors(&source, &widgets, &broadcaster, &projection).await;
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if last_refresh.elapsed() >= SOURCE_REFRESH_INTERVAL {
|
||||||
|
if let Ok(w) = config.list_widgets().await {
|
||||||
|
widgets = w;
|
||||||
|
}
|
||||||
|
last_refresh = tokio::time::Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = crate::event_service::apply_and_broadcast(
|
||||||
|
source.id,
|
||||||
|
&result,
|
||||||
|
&widgets,
|
||||||
|
&broadcaster,
|
||||||
|
&projection,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if !changed.is_empty() {
|
||||||
|
if let Err(e) = config.save_widget_states(&changed).await {
|
||||||
|
warn!(error = %e, "failed to cache widget states");
|
||||||
|
}
|
||||||
|
info!(source = %source.name, count = changed.len(), "pushed widget updates");
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_errors<B>(
|
||||||
|
source: &DataSource,
|
||||||
|
widgets: &[WidgetConfig],
|
||||||
|
broadcaster: &Arc<B>,
|
||||||
|
projection: &Arc<DataProjection>,
|
||||||
|
) where
|
||||||
|
B: BroadcastPort,
|
||||||
|
B::Error: std::fmt::Display,
|
||||||
|
{
|
||||||
|
let affected: Vec<_> = widgets
|
||||||
|
.iter()
|
||||||
|
.filter(|w| w.data_source_id == source.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if affected.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut error_states = Vec::new();
|
||||||
|
for w in &affected {
|
||||||
|
let mut state = projection
|
||||||
|
.get_state(w.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|| WidgetState {
|
||||||
|
data: std::collections::BTreeMap::new(),
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
state.error = Some(WidgetError::SourceUnavailable);
|
||||||
|
error_states.push((w.id, state));
|
||||||
|
}
|
||||||
|
|
||||||
|
projection.seed(error_states.clone()).await;
|
||||||
|
|
||||||
|
let with_hints: Vec<_> = error_states
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, state)| {
|
||||||
|
let hint = affected.iter().find(|w| w.id == *id)?.display_hint.clone();
|
||||||
|
Some((*id, hint, state.clone()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Err(e) = broadcaster.push_data_update(&with_hints).await {
|
||||||
|
warn!(error = %e, "failed to push error update");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
|
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
|
||||||
LayoutPresetId, ThemeConfig, User, WidgetConfig, WidgetId,
|
LayoutPresetId, ThemeConfig, User, UserRepository, WidgetConfig, WidgetId, WidgetState,
|
||||||
|
WidgetStateCache,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -123,6 +124,10 @@ impl ConfigRepository for InMemoryConfigRepository {
|
|||||||
self.presets.lock().unwrap().remove(&id);
|
self.presets.lock().unwrap().remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRepository for InMemoryConfigRepository {
|
||||||
|
type Error = Never;
|
||||||
|
|
||||||
async fn get_user_by_username(&self, _username: &str) -> Result<Option<User>, Self::Error> {
|
async fn get_user_by_username(&self, _username: &str) -> Result<Option<User>, Self::Error> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -137,6 +142,21 @@ impl ConfigRepository for InMemoryConfigRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl WidgetStateCache for InMemoryConfigRepository {
|
||||||
|
type Error = Never;
|
||||||
|
|
||||||
|
async fn save_widget_states(
|
||||||
|
&self,
|
||||||
|
_states: &[(WidgetId, WidgetState)],
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct InMemoryEventPublisher {
|
pub struct InMemoryEventPublisher {
|
||||||
events: Mutex<Vec<DomainEvent>>,
|
events: Mutex<Vec<DomainEvent>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,4 @@ anyhow.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
dotenvy.workspace = true
|
dotenvy.workspace = true
|
||||||
chrono.workspace = true
|
data-generators.workspace = true
|
||||||
chrono-tz.workspace = true
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ pub struct ServerConfig {
|
|||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub tcp_addr: String,
|
pub tcp_addr: String,
|
||||||
pub http_addr: String,
|
pub http_addr: String,
|
||||||
pub poll_interval_secs: u64,
|
|
||||||
pub spa_dir: Option<String>,
|
pub spa_dir: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,10 +14,6 @@ impl ServerConfig {
|
|||||||
.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").unwrap_or_else(|_| "0.0.0.0:2699".into()),
|
tcp_addr: env::var("KFRAME_TCP_ADDR").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")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(5),
|
|
||||||
spa_dir: env::var("KFRAME_SPA_DIR").ok(),
|
spa_dir: env::var("KFRAME_SPA_DIR").ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use application::DataProjection;
|
use application::DataProjection;
|
||||||
use config_sqlite::SqliteConfigStore;
|
use config_sqlite::SqliteConfigStore;
|
||||||
use domain::{BroadcastPort, ConfigRepository, DomainEvent};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tcp_server::{TcpBroadcaster, TcpEventBus};
|
use tcp_server::{TcpBroadcaster, TcpEventBus};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, warn};
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
event_bus: Arc<TcpEventBus>,
|
event_bus: Arc<TcpEventBus>,
|
||||||
@@ -15,38 +14,10 @@ pub async fn run(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(DomainEvent::LayoutChanged { layout }) => {
|
Ok(event) => {
|
||||||
let widgets = match config.list_widgets().await {
|
application::event_service::handle_event(event, &config, &broadcaster, &projection)
|
||||||
Ok(w) => w,
|
.await;
|
||||||
Err(e) => {
|
|
||||||
error!(error = %e, "failed to fetch widgets for screen update");
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let mut widget_states = Vec::new();
|
|
||||||
for w in &widgets {
|
|
||||||
if let Some(s) = projection.get_state(w.id).await {
|
|
||||||
widget_states.push((w.id, w.display_hint.clone(), s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(DomainEvent::ThemeChanged { theme }) => {
|
|
||||||
if let Err(e) = broadcaster.push_theme_update(&theme).await {
|
|
||||||
error!(error = %e, "failed to push theme update");
|
|
||||||
}
|
|
||||||
info!("theme changed, pushed update to clients");
|
|
||||||
}
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
warn!(skipped = n, "event handler lagged, missed events");
|
warn!(skipped = n, "event handler lagged, missed events");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ mod polling;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use application::DataProjection;
|
use application::DataProjection;
|
||||||
use config_sqlite::SqliteConfigStore;
|
use config_sqlite::SqliteConfigStore;
|
||||||
|
use domain::WidgetStateCache;
|
||||||
use http_api::AppState;
|
use http_api::AppState;
|
||||||
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
|
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
|
||||||
use secret_store::AesSecretStore;
|
use secret_store::AesSecretStore;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server};
|
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
@@ -40,6 +41,15 @@ async fn main() -> Result<()> {
|
|||||||
let auth = Arc::new(JwtAuthService::new(auth_config));
|
let auth = Arc::new(JwtAuthService::new(auth_config));
|
||||||
let hasher = Arc::new(Argon2Hasher);
|
let hasher = Arc::new(Argon2Hasher);
|
||||||
|
|
||||||
|
match config_store.load_widget_states().await {
|
||||||
|
Ok(states) if !states.is_empty() => {
|
||||||
|
info!(count = states.len(), "loaded cached widget states");
|
||||||
|
projection.seed(states).await;
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => warn!(error = %e, "failed to load cached widget states"),
|
||||||
|
}
|
||||||
|
|
||||||
let tcp_addr = cfg.tcp_addr.clone();
|
let tcp_addr = cfg.tcp_addr.clone();
|
||||||
let tcp_bc = broadcaster.clone();
|
let tcp_bc = broadcaster.clone();
|
||||||
let tcp_tracker = tracker.clone();
|
let tcp_tracker = tracker.clone();
|
||||||
@@ -80,11 +90,5 @@ async fn main() -> Result<()> {
|
|||||||
event_handler::run(ev_bus, ev_config, ev_bc, ev_proj).await;
|
event_handler::run(ev_bus, ev_config, ev_bc, ev_proj).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
polling::run(
|
polling::run(config_store, broadcaster, projection).await
|
||||||
config_store,
|
|
||||||
broadcaster,
|
|
||||||
projection,
|
|
||||||
cfg.poll_interval_secs,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,191 +1,77 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use application::DataProjection;
|
use application::DataProjection;
|
||||||
use chrono::Utc;
|
|
||||||
use chrono_tz::Tz;
|
|
||||||
use config_sqlite::SqliteConfigStore;
|
use config_sqlite::SqliteConfigStore;
|
||||||
use domain::{
|
use data_generators::{ClockGenerator, StaticTextGenerator};
|
||||||
BroadcastPort, ConfigRepository, DataSource, DataSourceConfig, DataSourcePort, DataSourceType,
|
use domain::{DataSource, DataSourcePort, DataSourceType, Value};
|
||||||
Value, WidgetState,
|
|
||||||
};
|
|
||||||
use http_json::HttpJsonAdapter;
|
use http_json::HttpJsonAdapter;
|
||||||
use media_adapter::MediaAdapter;
|
use media_adapter::MediaAdapter;
|
||||||
use rss_adapter::RssAdapter;
|
use rss_adapter::RssAdapter;
|
||||||
use std::collections::{BTreeMap, HashMap};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
|
||||||
use tcp_server::TcpBroadcaster;
|
use tcp_server::TcpBroadcaster;
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
|
|
||||||
const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
|
#[derive(Clone)]
|
||||||
|
struct Adapters {
|
||||||
pub async fn run(
|
http: Arc<HttpJsonAdapter>,
|
||||||
config: Arc<SqliteConfigStore>,
|
media: Arc<MediaAdapter>,
|
||||||
broadcaster: Arc<TcpBroadcaster>,
|
rss: Arc<RssAdapter>,
|
||||||
projection: Arc<DataProjection>,
|
clock: Arc<ClockGenerator>,
|
||||||
_poll_interval_secs: u64,
|
static_text: Arc<StaticTextGenerator>,
|
||||||
) -> Result<()> {
|
|
||||||
let http_adapter = Arc::new(HttpJsonAdapter::new());
|
|
||||||
let media_adapter = Arc::new(MediaAdapter::new());
|
|
||||||
let rss_adapter = Arc::new(RssAdapter::new());
|
|
||||||
|
|
||||||
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
|
|
||||||
|
|
||||||
info!("polling manager started");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let sources = config
|
|
||||||
.list_data_sources()
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
||||||
|
|
||||||
let current_ids: Vec<u16> = sources.iter().map(|s| s.id).collect();
|
|
||||||
|
|
||||||
running.retain(|id, handle| {
|
|
||||||
if !current_ids.contains(id) {
|
|
||||||
info!(source_id = id, "stopping poll for removed source");
|
|
||||||
handle.abort();
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for source in &sources {
|
|
||||||
if source.source_type == DataSourceType::Webhook {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if running.contains_key(&source.id) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let source_id = source.id;
|
impl Adapters {
|
||||||
let source = source.clone();
|
async fn poll(&self, source: &DataSource) -> Result<Value> {
|
||||||
let config = config.clone();
|
|
||||||
let broadcaster = broadcaster.clone();
|
|
||||||
let projection = projection.clone();
|
|
||||||
let http = http_adapter.clone();
|
|
||||||
let media = media_adapter.clone();
|
|
||||||
let rss = rss_adapter.clone();
|
|
||||||
|
|
||||||
info!(
|
|
||||||
source_id = source.id,
|
|
||||||
name = %source.name,
|
|
||||||
interval_secs = source.poll_interval.as_secs(),
|
|
||||||
"starting poll task"
|
|
||||||
);
|
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
poll_loop(source, config, broadcaster, projection, http, media, rss).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
running.insert(source_id, handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if running.is_empty() {
|
|
||||||
debug!("no pollable sources, waiting");
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn poll_loop(
|
|
||||||
source: DataSource,
|
|
||||||
config: Arc<SqliteConfigStore>,
|
|
||||||
broadcaster: Arc<TcpBroadcaster>,
|
|
||||||
projection: Arc<DataProjection>,
|
|
||||||
http_adapter: Arc<HttpJsonAdapter>,
|
|
||||||
media_adapter: Arc<MediaAdapter>,
|
|
||||||
rss_adapter: Arc<RssAdapter>,
|
|
||||||
) {
|
|
||||||
let interval = source.poll_interval;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(interval).await;
|
|
||||||
|
|
||||||
let result = match poll_source(&http_adapter, &media_adapter, &rss_adapter, &source).await {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(source = %source.name, error = %e, "poll failed");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let widgets = match config.list_widgets().await {
|
|
||||||
Ok(w) => w,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(error = %e, "failed to fetch widgets");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let changed: Vec<(u16, WidgetState)> = projection
|
|
||||||
.apply_poll_result(source.id, &result, &widgets)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if !changed.is_empty() {
|
|
||||||
let with_hints: Vec<_> = changed
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(id, state)| {
|
|
||||||
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
|
|
||||||
Some((*id, hint, state.clone()))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if let Err(e) = broadcaster.push_data_update(&with_hints).await {
|
|
||||||
warn!(error = %e, "failed to push update");
|
|
||||||
}
|
|
||||||
info!(source = %source.name, count = changed.len(), "pushed widget updates");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn poll_source(
|
|
||||||
http_adapter: &HttpJsonAdapter,
|
|
||||||
media_adapter: &MediaAdapter,
|
|
||||||
rss_adapter: &RssAdapter,
|
|
||||||
source: &DataSource,
|
|
||||||
) -> Result<Value> {
|
|
||||||
match source.source_type {
|
match source.source_type {
|
||||||
DataSourceType::HttpJson | DataSourceType::Weather => http_adapter
|
DataSourceType::HttpJson | DataSourceType::Weather => self
|
||||||
|
.http
|
||||||
.poll(source)
|
.poll(source)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}")),
|
.map_err(|e| anyhow::anyhow!("{e}")),
|
||||||
DataSourceType::Media => media_adapter
|
DataSourceType::Media => self
|
||||||
|
.media
|
||||||
.poll(source)
|
.poll(source)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}")),
|
.map_err(|e| anyhow::anyhow!("{e}")),
|
||||||
DataSourceType::Rss => rss_adapter
|
DataSourceType::Rss => self
|
||||||
|
.rss
|
||||||
|
.poll(source)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}")),
|
||||||
|
DataSourceType::Clock => self
|
||||||
|
.clock
|
||||||
|
.poll(source)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}")),
|
||||||
|
DataSourceType::StaticText => self
|
||||||
|
.static_text
|
||||||
.poll(source)
|
.poll(source)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}")),
|
.map_err(|e| anyhow::anyhow!("{e}")),
|
||||||
DataSourceType::Clock => Ok(generate_clock(&source.config)),
|
|
||||||
DataSourceType::StaticText => Ok(generate_static_text(&source.config)),
|
|
||||||
DataSourceType::Webhook => Err(anyhow::anyhow!(
|
DataSourceType::Webhook => Err(anyhow::anyhow!(
|
||||||
"webhook sources are push-based, not polled"
|
"webhook sources are push-based, not polled"
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_clock(config: &DataSourceConfig) -> Value {
|
|
||||||
let (fmt, tz_name) = match config {
|
|
||||||
DataSourceConfig::Clock { format, timezone } => (format.as_str(), timezone.as_str()),
|
|
||||||
_ => ("%H:%M:%S", "UTC"),
|
|
||||||
};
|
|
||||||
let tz: Tz = tz_name.parse().unwrap_or(chrono_tz::UTC);
|
|
||||||
let now = Utc::now().with_timezone(&tz);
|
|
||||||
let formatted = now.format(fmt).to_string();
|
|
||||||
let mut map = BTreeMap::new();
|
|
||||||
map.insert("time".into(), Value::String(formatted));
|
|
||||||
Value::Object(map)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_static_text(config: &DataSourceConfig) -> Value {
|
pub async fn run(
|
||||||
let text = match config {
|
config: Arc<SqliteConfigStore>,
|
||||||
DataSourceConfig::StaticText { text } => text.clone(),
|
broadcaster: Arc<TcpBroadcaster>,
|
||||||
_ => String::new(),
|
projection: Arc<DataProjection>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let adapters = Adapters {
|
||||||
|
http: Arc::new(HttpJsonAdapter::new()),
|
||||||
|
media: Arc::new(MediaAdapter::new()),
|
||||||
|
rss: Arc::new(RssAdapter::new()),
|
||||||
|
clock: Arc::new(ClockGenerator::new()),
|
||||||
|
static_text: Arc::new(StaticTextGenerator::new()),
|
||||||
};
|
};
|
||||||
let mut map = BTreeMap::new();
|
|
||||||
map.insert("text".into(), Value::String(text));
|
let poller = Arc::new(move |source: &DataSource| {
|
||||||
Value::Object(map)
|
let adapters = adapters.clone();
|
||||||
|
let source = source.clone();
|
||||||
|
async move { adapters.poll(&source).await }
|
||||||
|
});
|
||||||
|
|
||||||
|
application::polling_service::run(config, broadcaster, projection, poller).await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
|
use crate::conversions::{wire_to_display_hint, wire_to_layout, wire_to_widget_state};
|
||||||
use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
|
use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
|
||||||
use domain::LayoutNode;
|
use domain::{DisplayHint, Value, WidgetError, WidgetState};
|
||||||
use protocol::{
|
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode};
|
||||||
ServerMessage, WidgetDescriptor, WireColor, WireDisplayHint, WireLayoutNode, WireWidgetState,
|
|
||||||
};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub struct ClientApp {
|
pub struct ClientApp {
|
||||||
screen: BoundingBox,
|
screen: BoundingBox,
|
||||||
render_tree: Option<RenderTree>,
|
render_tree: Option<RenderTree>,
|
||||||
widget_states: HashMap<u16, (WireDisplayHint, WireWidgetState)>,
|
widget_states: HashMap<u16, (DisplayHint, WidgetState)>,
|
||||||
theme: ThemeConfig,
|
theme: ThemeConfig,
|
||||||
theme_changed: bool,
|
theme_changed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RepaintCommand {
|
pub struct RepaintCommand {
|
||||||
pub widget_id: u16,
|
pub widget_id: u16,
|
||||||
pub bounds: BoundingBox,
|
pub bounds: BoundingBox,
|
||||||
pub display_hint: WireDisplayHint,
|
pub display_hint: DisplayHint,
|
||||||
pub state: WireWidgetState,
|
pub data: Vec<(String, Value)>,
|
||||||
|
pub error: Option<WidgetError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientApp {
|
impl ClientApp {
|
||||||
@@ -73,13 +73,14 @@ impl ClientApp {
|
|||||||
wire_layout: WireLayoutNode,
|
wire_layout: WireLayoutNode,
|
||||||
widgets: Vec<WidgetDescriptor>,
|
widgets: Vec<WidgetDescriptor>,
|
||||||
) -> Vec<RepaintCommand> {
|
) -> Vec<RepaintCommand> {
|
||||||
let layout: LayoutNode = wire_layout.into();
|
let layout = wire_to_layout(wire_layout);
|
||||||
let new_tree = LayoutEngine::compute(&layout, self.screen);
|
let new_tree = LayoutEngine::compute(&layout, self.screen);
|
||||||
|
|
||||||
self.widget_states.clear();
|
self.widget_states.clear();
|
||||||
for w in &widgets {
|
for w in widgets {
|
||||||
self.widget_states
|
let hint = wire_to_display_hint(w.display_hint);
|
||||||
.insert(w.id, (w.display_hint.clone(), w.state.clone()));
|
let state = wire_to_widget_state(w.state);
|
||||||
|
self.widget_states.insert(w.id, (hint, state));
|
||||||
}
|
}
|
||||||
|
|
||||||
let repaints = self.build_repaints_for_all(&new_tree);
|
let repaints = self.build_repaints_for_all(&new_tree);
|
||||||
@@ -96,21 +97,19 @@ impl ClientApp {
|
|||||||
let mut repaints = Vec::new();
|
let mut repaints = Vec::new();
|
||||||
|
|
||||||
for w in widgets {
|
for w in widgets {
|
||||||
|
let hint = wire_to_display_hint(w.display_hint);
|
||||||
|
let state = wire_to_widget_state(w.state);
|
||||||
|
|
||||||
let changed = self
|
let changed = self
|
||||||
.widget_states
|
.widget_states
|
||||||
.get(&w.id)
|
.get(&w.id)
|
||||||
.is_none_or(|(_, prev_state)| *prev_state != w.state);
|
.is_none_or(|(_, prev)| *prev != 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) {
|
||||||
repaints.push(RepaintCommand {
|
repaints.push(Self::make_repaint(w.id, *bounds, &hint, &state));
|
||||||
widget_id: w.id,
|
|
||||||
bounds: *bounds,
|
|
||||||
display_hint: w.display_hint.clone(),
|
|
||||||
state: w.state.clone(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
self.widget_states.insert(w.id, (w.display_hint, w.state));
|
self.widget_states.insert(w.id, (hint, state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,18 +121,32 @@ impl ClientApp {
|
|||||||
|
|
||||||
for (id, (hint, state)) in &self.widget_states {
|
for (id, (hint, state)) in &self.widget_states {
|
||||||
if let Some(bounds) = tree.get_widget_bounds(*id) {
|
if let Some(bounds) = tree.get_widget_bounds(*id) {
|
||||||
repaints.push(RepaintCommand {
|
repaints.push(Self::make_repaint(*id, *bounds, hint, state));
|
||||||
widget_id: *id,
|
|
||||||
bounds: *bounds,
|
|
||||||
display_hint: hint.clone(),
|
|
||||||
state: state.clone(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repaints.sort_by_key(|r| r.widget_id);
|
repaints.sort_by_key(|r| r.widget_id);
|
||||||
repaints
|
repaints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_repaint(
|
||||||
|
id: u16,
|
||||||
|
bounds: BoundingBox,
|
||||||
|
hint: &DisplayHint,
|
||||||
|
state: &WidgetState,
|
||||||
|
) -> RepaintCommand {
|
||||||
|
RepaintCommand {
|
||||||
|
widget_id: id,
|
||||||
|
bounds,
|
||||||
|
display_hint: hint.clone(),
|
||||||
|
data: state
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect(),
|
||||||
|
error: state.error.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wire_color(c: WireColor) -> Color {
|
fn wire_color(c: WireColor) -> Color {
|
||||||
|
|||||||
42
crates/client-application/src/connection_loop.rs
Normal file
42
crates/client-application/src/connection_loop.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use client_domain::NetworkPort;
|
||||||
|
use protocol::{ServerMessage, decode_server_message};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub fn run_connection_loop<N: NetworkPort>(
|
||||||
|
net: &mut N,
|
||||||
|
server_addr: &str,
|
||||||
|
poll_interval: Duration,
|
||||||
|
reconnect_delay: Duration,
|
||||||
|
mut on_message: impl FnMut(ServerMessage),
|
||||||
|
mut on_connection_change: impl FnMut(bool),
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
if !net.is_connected() {
|
||||||
|
match net.connect(server_addr) {
|
||||||
|
Ok(()) => on_connection_change(true),
|
||||||
|
Err(_) => {
|
||||||
|
on_connection_change(false);
|
||||||
|
thread::sleep(reconnect_delay);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match net.receive() {
|
||||||
|
Ok(Some(payload)) => {
|
||||||
|
if let Ok(msg) = decode_server_message(&payload) {
|
||||||
|
on_message(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
thread::sleep(poll_interval);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = net.disconnect();
|
||||||
|
on_connection_change(false);
|
||||||
|
thread::sleep(reconnect_delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
crates/client-application/src/conversions.rs
Normal file
100
crates/client-application/src/conversions.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use domain::value_objects::{
|
||||||
|
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
|
||||||
|
LayoutChild, LayoutNode, Sizing, VAlign, Value, WidgetError, WidgetState,
|
||||||
|
};
|
||||||
|
use protocol::{
|
||||||
|
WireAlignItems, WireDirection, WireDisplayHint, WireDisplayHintKind, WireHAlign,
|
||||||
|
WireJustifyContent, WireLayoutNode, WireSizing, WireVAlign, WireValue, WireWidgetError,
|
||||||
|
WireWidgetState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn wire_to_value(w: WireValue) -> Value {
|
||||||
|
match w {
|
||||||
|
WireValue::Null => Value::Null,
|
||||||
|
WireValue::Bool(b) => Value::Bool(b),
|
||||||
|
WireValue::Number(n) => Value::Number(n),
|
||||||
|
WireValue::String(s) => Value::String(s),
|
||||||
|
WireValue::Array(arr) => Value::Array(arr.into_iter().map(wire_to_value).collect()),
|
||||||
|
WireValue::Object(map) => Value::Object(
|
||||||
|
map.into_iter()
|
||||||
|
.map(|(k, v)| (k, wire_to_value(v)))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wire_to_widget_error(w: WireWidgetError) -> WidgetError {
|
||||||
|
match w {
|
||||||
|
WireWidgetError::SourceUnavailable => WidgetError::SourceUnavailable,
|
||||||
|
WireWidgetError::ExtractionFailed => WidgetError::ExtractionFailed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wire_to_widget_state(w: WireWidgetState) -> WidgetState {
|
||||||
|
WidgetState {
|
||||||
|
data: w
|
||||||
|
.data
|
||||||
|
.into_iter()
|
||||||
|
.map(|kv| (kv.key, wire_to_value(kv.value)))
|
||||||
|
.collect(),
|
||||||
|
error: w.error.map(wire_to_widget_error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wire_to_display_hint(w: WireDisplayHint) -> DisplayHint {
|
||||||
|
DisplayHint {
|
||||||
|
kind: match w.kind {
|
||||||
|
WireDisplayHintKind::IconValue => DisplayHintKind::IconValue,
|
||||||
|
WireDisplayHintKind::TextBlock => DisplayHintKind::TextBlock,
|
||||||
|
WireDisplayHintKind::KeyValue => DisplayHintKind::KeyValue,
|
||||||
|
},
|
||||||
|
h_align: match w.h_align {
|
||||||
|
WireHAlign::Left => HAlign::Left,
|
||||||
|
WireHAlign::Center => HAlign::Center,
|
||||||
|
WireHAlign::Right => HAlign::Right,
|
||||||
|
},
|
||||||
|
v_align: match w.v_align {
|
||||||
|
WireVAlign::Top => VAlign::Top,
|
||||||
|
WireVAlign::Middle => VAlign::Middle,
|
||||||
|
WireVAlign::Bottom => VAlign::Bottom,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wire_to_layout(w: WireLayoutNode) -> LayoutNode {
|
||||||
|
match w {
|
||||||
|
WireLayoutNode::Leaf(id) => LayoutNode::Leaf(id),
|
||||||
|
WireLayoutNode::Container(c) => LayoutNode::Container(ContainerNode {
|
||||||
|
direction: match c.direction {
|
||||||
|
WireDirection::Row => Direction::Row,
|
||||||
|
WireDirection::Column => Direction::Column,
|
||||||
|
},
|
||||||
|
gap: c.gap,
|
||||||
|
padding: c.padding,
|
||||||
|
justify_content: match c.justify_content {
|
||||||
|
WireJustifyContent::Start => JustifyContent::Start,
|
||||||
|
WireJustifyContent::Center => JustifyContent::Center,
|
||||||
|
WireJustifyContent::End => JustifyContent::End,
|
||||||
|
WireJustifyContent::SpaceBetween => JustifyContent::SpaceBetween,
|
||||||
|
WireJustifyContent::SpaceEvenly => JustifyContent::SpaceEvenly,
|
||||||
|
},
|
||||||
|
align_items: match c.align_items {
|
||||||
|
WireAlignItems::Start => AlignItems::Start,
|
||||||
|
WireAlignItems::Center => AlignItems::Center,
|
||||||
|
WireAlignItems::End => AlignItems::End,
|
||||||
|
WireAlignItems::Stretch => AlignItems::Stretch,
|
||||||
|
},
|
||||||
|
children: c
|
||||||
|
.children
|
||||||
|
.into_iter()
|
||||||
|
.map(|ch| LayoutChild {
|
||||||
|
sizing: match ch.sizing {
|
||||||
|
WireSizing::Fixed(px) => Sizing::Fixed(px),
|
||||||
|
WireSizing::Flex(weight) => Sizing::Flex(weight),
|
||||||
|
},
|
||||||
|
node: wire_to_layout(ch.node),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
mod client_app;
|
mod client_app;
|
||||||
|
mod connection_loop;
|
||||||
|
pub mod conversions;
|
||||||
|
|
||||||
pub use client_app::{ClientApp, RepaintCommand};
|
pub use client_app::{ClientApp, RepaintCommand};
|
||||||
|
pub use connection_loop::run_connection_loop;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use client_application::{ClientApp, RepaintCommand};
|
use client_application::ClientApp;
|
||||||
use client_domain::BoundingBox;
|
use client_domain::BoundingBox;
|
||||||
use protocol::{
|
use protocol::{
|
||||||
ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode, WireDirection,
|
ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode, WireDirection,
|
||||||
@@ -84,8 +84,8 @@ fn data_update_only_repaints_changed_widgets() {
|
|||||||
assert_eq!(repaints.len(), 1);
|
assert_eq!(repaints.len(), 1);
|
||||||
assert_eq!(repaints[0].widget_id, 1);
|
assert_eq!(repaints[0].widget_id, 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
repaints[0].state.data[0].value,
|
repaints[0].data[0],
|
||||||
WireValue::String("6.1°C".into())
|
("temperature".into(), domain::Value::String("6.1°C".into()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
151
crates/client-application/tests/conversion_tests.rs
Normal file
151
crates/client-application/tests/conversion_tests.rs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
use client_application::conversions::{
|
||||||
|
wire_to_display_hint, wire_to_layout, wire_to_value, wire_to_widget_state,
|
||||||
|
};
|
||||||
|
use domain::{
|
||||||
|
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, JustifyContent,
|
||||||
|
LayoutChild, LayoutNode, Sizing, Value, WidgetError, WidgetState,
|
||||||
|
};
|
||||||
|
use protocol::{
|
||||||
|
WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild,
|
||||||
|
WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState,
|
||||||
|
};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn value_to_wire(v: &Value) -> WireValue {
|
||||||
|
match v {
|
||||||
|
Value::Null => WireValue::Null,
|
||||||
|
Value::Bool(b) => WireValue::Bool(*b),
|
||||||
|
Value::Number(n) => WireValue::Number(*n),
|
||||||
|
Value::String(s) => WireValue::String(s.clone()),
|
||||||
|
Value::Array(arr) => WireValue::Array(arr.iter().map(value_to_wire).collect()),
|
||||||
|
Value::Object(map) => WireValue::Object(
|
||||||
|
map.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), value_to_wire(v)))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_converts_to_wire_and_back() {
|
||||||
|
let original = Value::Object(BTreeMap::from([(
|
||||||
|
"items".into(),
|
||||||
|
Value::Array(vec![
|
||||||
|
Value::String("hello".into()),
|
||||||
|
Value::Number(42.0),
|
||||||
|
Value::Bool(true),
|
||||||
|
Value::Null,
|
||||||
|
]),
|
||||||
|
)]));
|
||||||
|
|
||||||
|
let wire = value_to_wire(&original);
|
||||||
|
let roundtripped = wire_to_value(wire);
|
||||||
|
assert_eq!(original, roundtripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn widget_state_with_error_converts_to_wire_and_back() {
|
||||||
|
let original = WidgetState {
|
||||||
|
data: BTreeMap::from([("temp".into(), Value::Number(5.4))]),
|
||||||
|
error: Some(WidgetError::SourceUnavailable),
|
||||||
|
};
|
||||||
|
|
||||||
|
let wire = WireWidgetState {
|
||||||
|
data: original
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| WireKeyValue {
|
||||||
|
key: k.clone(),
|
||||||
|
value: value_to_wire(v),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
error: Some(WireWidgetError::SourceUnavailable),
|
||||||
|
};
|
||||||
|
let roundtripped = wire_to_widget_state(wire);
|
||||||
|
assert_eq!(original, roundtripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_tree_converts_to_wire_and_back() {
|
||||||
|
let original = LayoutNode::Container(ContainerNode {
|
||||||
|
direction: Direction::Row,
|
||||||
|
gap: 4,
|
||||||
|
padding: 2,
|
||||||
|
justify_content: JustifyContent::Start,
|
||||||
|
align_items: AlignItems::Stretch,
|
||||||
|
children: vec![
|
||||||
|
LayoutChild {
|
||||||
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(1),
|
||||||
|
},
|
||||||
|
LayoutChild {
|
||||||
|
sizing: Sizing::Fixed(100),
|
||||||
|
node: LayoutNode::Container(ContainerNode {
|
||||||
|
direction: Direction::Column,
|
||||||
|
gap: 2,
|
||||||
|
padding: 0,
|
||||||
|
justify_content: JustifyContent::Start,
|
||||||
|
align_items: AlignItems::Stretch,
|
||||||
|
children: vec![LayoutChild {
|
||||||
|
sizing: Sizing::Flex(1),
|
||||||
|
node: LayoutNode::Leaf(2),
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let wire = WireLayoutNode::Container(WireContainerNode {
|
||||||
|
direction: WireDirection::Row,
|
||||||
|
gap: 4,
|
||||||
|
padding: 2,
|
||||||
|
justify_content: protocol::WireJustifyContent::Start,
|
||||||
|
align_items: protocol::WireAlignItems::Stretch,
|
||||||
|
children: vec![
|
||||||
|
WireLayoutChild {
|
||||||
|
sizing: WireSizing::Flex(1),
|
||||||
|
node: WireLayoutNode::Leaf(1),
|
||||||
|
},
|
||||||
|
WireLayoutChild {
|
||||||
|
sizing: WireSizing::Fixed(100),
|
||||||
|
node: WireLayoutNode::Container(WireContainerNode {
|
||||||
|
direction: WireDirection::Column,
|
||||||
|
gap: 2,
|
||||||
|
padding: 0,
|
||||||
|
justify_content: protocol::WireJustifyContent::Start,
|
||||||
|
align_items: protocol::WireAlignItems::Stretch,
|
||||||
|
children: vec![WireLayoutChild {
|
||||||
|
sizing: WireSizing::Flex(1),
|
||||||
|
node: WireLayoutNode::Leaf(2),
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let roundtripped = wire_to_layout(wire);
|
||||||
|
assert_eq!(original, roundtripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_hint_converts_to_wire_and_back() {
|
||||||
|
for (hint, wire_kind) in [
|
||||||
|
(
|
||||||
|
DisplayHintKind::IconValue,
|
||||||
|
protocol::WireDisplayHintKind::IconValue,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
DisplayHintKind::TextBlock,
|
||||||
|
protocol::WireDisplayHintKind::TextBlock,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
DisplayHintKind::KeyValue,
|
||||||
|
protocol::WireDisplayHintKind::KeyValue,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let original = DisplayHint::new(hint);
|
||||||
|
let wire = WireDisplayHint::new(wire_kind);
|
||||||
|
let roundtripped = wire_to_display_hint(wire);
|
||||||
|
assert_eq!(original, roundtripped);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
use client_application::ClientApp;
|
use client_application::{ClientApp, RepaintCommand, run_connection_loop};
|
||||||
use client_domain::NetworkPort;
|
use client_domain::{
|
||||||
use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig};
|
BoundingBox, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
|
||||||
|
WidgetRenderer,
|
||||||
|
};
|
||||||
use display_terminal::TerminalDisplay;
|
use display_terminal::TerminalDisplay;
|
||||||
use domain::DisplayHint;
|
use protocol::ServerMessage;
|
||||||
use protocol::decode_server_message;
|
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
use tcp_client::StdTcpClient;
|
use tcp_client::StdTcpClient;
|
||||||
|
|
||||||
|
fn to_request(cmd: &RepaintCommand) -> RepaintRequest {
|
||||||
|
RepaintRequest {
|
||||||
|
widget_id: cmd.widget_id,
|
||||||
|
bounds: cmd.bounds,
|
||||||
|
display_hint: cmd.display_hint.clone(),
|
||||||
|
data: cmd.data.clone(),
|
||||||
|
error: cmd.error.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let screen = BoundingBox::screen(240, 320);
|
let screen = BoundingBox::screen(240, 320);
|
||||||
let mut app = ClientApp::new(screen);
|
let mut app = ClientApp::new(screen);
|
||||||
@@ -18,52 +29,33 @@ fn main() {
|
|||||||
large: (10, 20),
|
large: (10, 20),
|
||||||
};
|
};
|
||||||
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
|
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
|
||||||
|
let mut renderer = WidgetRenderer::new();
|
||||||
|
|
||||||
println!("=== K-Frame Desktop Client ===");
|
println!("=== K-Frame Desktop Client ===");
|
||||||
println!("Screen: {}x{}", screen.width, screen.height);
|
println!("Screen: {}x{}", screen.width, screen.height);
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel::<ServerMessage>();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let server_addr = "127.0.0.1:2699";
|
|
||||||
let mut net = StdTcpClient::new();
|
let mut net = StdTcpClient::new();
|
||||||
|
let tx_clone = tx.clone();
|
||||||
loop {
|
run_connection_loop(
|
||||||
if !net.is_connected() {
|
&mut net,
|
||||||
println!("[NET] Connecting to {server_addr}...");
|
"127.0.0.1:2699",
|
||||||
match net.connect(server_addr) {
|
Duration::from_millis(50),
|
||||||
Ok(()) => println!("[NET] Connected!"),
|
Duration::from_secs(2),
|
||||||
Err(e) => {
|
move |msg| {
|
||||||
println!("[NET] Connection failed: {e}, retrying in 2s...");
|
let _ = tx_clone.send(msg);
|
||||||
thread::sleep(Duration::from_secs(2));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match net.receive() {
|
|
||||||
Ok(Some(payload)) => match decode_server_message(&payload) {
|
|
||||||
Ok(msg) => {
|
|
||||||
let _ = tx.send(msg);
|
|
||||||
}
|
|
||||||
Err(e) => println!("[NET] Decode error: {e}"),
|
|
||||||
},
|
},
|
||||||
Ok(None) => {
|
|_connected| {},
|
||||||
thread::sleep(Duration::from_millis(50));
|
);
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("[NET] Receive error: {e}, reconnecting...");
|
|
||||||
let _ = net.disconnect();
|
|
||||||
thread::sleep(Duration::from_secs(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
println!("[RENDER] Render loop started");
|
println!("[RENDER] Render loop started");
|
||||||
|
let mut last_tick = Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
match rx.recv_timeout(Duration::from_millis(50)) {
|
||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
let repaints = app.handle_message(msg);
|
let repaints = app.handle_message(msg);
|
||||||
|
|
||||||
@@ -73,20 +65,13 @@ fn main() {
|
|||||||
|
|
||||||
if !repaints.is_empty() {
|
if !repaints.is_empty() {
|
||||||
println!("\n--- Repaint ({} widgets) ---", repaints.len());
|
println!("\n--- Repaint ({} widgets) ---", repaints.len());
|
||||||
|
|
||||||
|
let requests: Vec<_> = repaints.iter().map(to_request).collect();
|
||||||
let bg = engine.theme().background;
|
let bg = engine.theme().background;
|
||||||
for cmd in &repaints {
|
let updates = renderer.apply_repaints(&engine, requests);
|
||||||
display.fill_rect(cmd.bounds, bg).unwrap();
|
for update in &updates {
|
||||||
|
display.fill_rect(update.bounds, bg).unwrap();
|
||||||
let hint: DisplayHint = cmd.display_hint.clone().into();
|
for dc in &update.commands {
|
||||||
let data: Vec<(String, domain::Value)> = cmd
|
|
||||||
.state
|
|
||||||
.data
|
|
||||||
.iter()
|
|
||||||
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let draw_cmds = engine.render_widget(&hint, &data, cmd.bounds, 0);
|
|
||||||
for dc in &draw_cmds {
|
|
||||||
display
|
display
|
||||||
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
|
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -98,5 +83,23 @@ fn main() {
|
|||||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let elapsed = now.duration_since(last_tick);
|
||||||
|
last_tick = now;
|
||||||
|
|
||||||
|
let scroll_updates = renderer.tick_scroll(&engine, elapsed);
|
||||||
|
if !scroll_updates.is_empty() {
|
||||||
|
let bg = engine.theme().background;
|
||||||
|
for update in &scroll_updates {
|
||||||
|
display.fill_rect(update.bounds, bg).unwrap();
|
||||||
|
for dc in &update.commands {
|
||||||
|
display
|
||||||
|
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
display.flush().unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod render_tree;
|
|||||||
mod scroll;
|
mod scroll;
|
||||||
mod text_layout;
|
mod text_layout;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
mod widget_renderer;
|
||||||
|
|
||||||
pub use alignment::align_offset;
|
pub use alignment::align_offset;
|
||||||
pub use bounding_box::BoundingBox;
|
pub use bounding_box::BoundingBox;
|
||||||
@@ -18,9 +19,10 @@ pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign};
|
|||||||
pub use font::{FontMetrics, FontSize};
|
pub use font::{FontMetrics, FontSize};
|
||||||
pub use layout_engine::LayoutEngine;
|
pub use layout_engine::LayoutEngine;
|
||||||
pub use markup::{TextSpan, parse_markup};
|
pub use markup::{TextSpan, parse_markup};
|
||||||
pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort};
|
pub use ports::{DisplayPort, NetworkPort};
|
||||||
pub use render_engine::{DrawCommand, RenderEngine};
|
pub use render_engine::{DrawCommand, RenderEngine};
|
||||||
pub use render_tree::RenderTree;
|
pub use render_tree::RenderTree;
|
||||||
pub use scroll::ScrollState;
|
pub use scroll::ScrollState;
|
||||||
pub use text_layout::wrap_lines;
|
pub use text_layout::wrap_lines;
|
||||||
pub use theme::ThemeConfig;
|
pub use theme::ThemeConfig;
|
||||||
|
pub use widget_renderer::{RenderUpdate, RepaintRequest, WidgetRenderer};
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
mod display;
|
mod display;
|
||||||
mod network;
|
mod network;
|
||||||
mod storage;
|
|
||||||
|
|
||||||
pub use display::DisplayPort;
|
pub use display::DisplayPort;
|
||||||
pub use network::NetworkPort;
|
pub use network::NetworkPort;
|
||||||
pub use storage::{ClientConfig, StoragePort};
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
pub struct ClientConfig {
|
|
||||||
pub wifi_ssid: String,
|
|
||||||
pub wifi_password: String,
|
|
||||||
pub server_addr: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait StoragePort {
|
|
||||||
type Error;
|
|
||||||
|
|
||||||
fn load_config(&self) -> Result<ClientConfig, Self::Error>;
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
BoundingBox, Color, FontMetrics, FontSize, ThemeConfig, alignment::align_offset,
|
BoundingBox, Color, FontMetrics, FontSize, ThemeConfig, alignment::align_offset,
|
||||||
markup::parse_markup, text_layout::wrap_lines,
|
markup::parse_markup, text_layout::wrap_lines,
|
||||||
};
|
};
|
||||||
use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value};
|
use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value, WidgetError};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct DrawCommand {
|
pub struct DrawCommand {
|
||||||
@@ -92,26 +92,38 @@ impl RenderEngine {
|
|||||||
data: &[(String, Value)],
|
data: &[(String, Value)],
|
||||||
bounds: BoundingBox,
|
bounds: BoundingBox,
|
||||||
scroll_offset: u16,
|
scroll_offset: u16,
|
||||||
|
error: Option<&WidgetError>,
|
||||||
) -> Vec<DrawCommand> {
|
) -> Vec<DrawCommand> {
|
||||||
let text = self.format_widget(hint, data);
|
let mut text = self.format_widget(hint, data);
|
||||||
|
if error.is_some() {
|
||||||
|
text = format!("{{accent}}[offline]{{/}}\n{text}");
|
||||||
|
}
|
||||||
let mut cmds = self.render_text(&text, bounds, hint.h_align, hint.v_align);
|
let mut cmds = self.render_text(&text, bounds, hint.h_align, hint.v_align);
|
||||||
|
|
||||||
if scroll_offset > 0 {
|
if scroll_offset > 0 {
|
||||||
for cmd in &mut cmds {
|
for cmd in &mut cmds {
|
||||||
cmd.y = cmd.y.saturating_sub(scroll_offset);
|
cmd.y = cmd.y.saturating_sub(scroll_offset);
|
||||||
}
|
}
|
||||||
// Drop commands that scrolled above bounds
|
|
||||||
cmds.retain(|cmd| {
|
|
||||||
cmd.y + self.metrics.char_height(cmd.font) > bounds.y
|
|
||||||
&& cmd.y < bounds.y + bounds.height
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
cmds.retain(|cmd| {
|
||||||
|
let line_h = self.metrics.char_height(cmd.font);
|
||||||
|
cmd.y >= bounds.y && cmd.y + line_h <= bounds.y + bounds.height
|
||||||
|
});
|
||||||
|
|
||||||
cmds
|
cmds
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn content_height(&self, hint: &DisplayHint, data: &[(String, Value)], width: u16) -> u16 {
|
pub fn content_height(
|
||||||
let text = self.format_widget(hint, data);
|
&self,
|
||||||
|
hint: &DisplayHint,
|
||||||
|
data: &[(String, Value)],
|
||||||
|
width: u16,
|
||||||
|
error: Option<&WidgetError>,
|
||||||
|
) -> u16 {
|
||||||
|
let mut text = self.format_widget(hint, data);
|
||||||
|
if error.is_some() {
|
||||||
|
text = format!("{{accent}}[offline]{{/}}\n{text}");
|
||||||
|
}
|
||||||
let plain: String = parse_markup(&text, &self.theme)
|
let plain: String = parse_markup(&text, &self.theme)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.text.as_str())
|
.map(|s| s.text.as_str())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const PAUSE_DURATION: Duration = Duration::from_secs(2);
|
const PAUSE_DURATION: Duration = Duration::from_secs(3);
|
||||||
const SCROLL_SPEED_PX_PER_SEC: f32 = 30.0;
|
const SCROLL_SPEED_PX_PER_SEC: f32 = 15.0;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ScrollState {
|
pub struct ScrollState {
|
||||||
|
|||||||
113
crates/client-domain/src/widget_renderer.rs
Normal file
113
crates/client-domain/src/widget_renderer.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use crate::{BoundingBox, DrawCommand, RenderEngine, ScrollState};
|
||||||
|
use domain::{DisplayHint, Value, WidgetError};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub struct RenderUpdate {
|
||||||
|
pub bounds: BoundingBox,
|
||||||
|
pub commands: Vec<DrawCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WidgetCache {
|
||||||
|
hint: DisplayHint,
|
||||||
|
data: Vec<(String, Value)>,
|
||||||
|
error: Option<WidgetError>,
|
||||||
|
bounds: BoundingBox,
|
||||||
|
scroll: ScrollState,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RepaintRequest {
|
||||||
|
pub widget_id: u16,
|
||||||
|
pub bounds: BoundingBox,
|
||||||
|
pub display_hint: DisplayHint,
|
||||||
|
pub data: Vec<(String, Value)>,
|
||||||
|
pub error: Option<WidgetError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WidgetRenderer {
|
||||||
|
widgets: HashMap<u16, WidgetCache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WidgetRenderer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetRenderer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
widgets: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_repaints(
|
||||||
|
&mut self,
|
||||||
|
engine: &RenderEngine,
|
||||||
|
repaints: Vec<RepaintRequest>,
|
||||||
|
) -> Vec<RenderUpdate> {
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
for req in repaints {
|
||||||
|
let content_h = engine.content_height(
|
||||||
|
&req.display_hint,
|
||||||
|
&req.data,
|
||||||
|
req.bounds.width,
|
||||||
|
req.error.as_ref(),
|
||||||
|
);
|
||||||
|
let scroll = ScrollState::new(req.bounds.height, content_h);
|
||||||
|
|
||||||
|
let cmds = engine.render_widget(
|
||||||
|
&req.display_hint,
|
||||||
|
&req.data,
|
||||||
|
req.bounds,
|
||||||
|
scroll.offset(),
|
||||||
|
req.error.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
updates.push(RenderUpdate {
|
||||||
|
bounds: req.bounds,
|
||||||
|
commands: cmds,
|
||||||
|
});
|
||||||
|
|
||||||
|
self.widgets.insert(
|
||||||
|
req.widget_id,
|
||||||
|
WidgetCache {
|
||||||
|
hint: req.display_hint,
|
||||||
|
data: req.data,
|
||||||
|
error: req.error,
|
||||||
|
bounds: req.bounds,
|
||||||
|
scroll,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updates
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tick_scroll(&mut self, engine: &RenderEngine, elapsed: Duration) -> Vec<RenderUpdate> {
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
for cache in self.widgets.values_mut() {
|
||||||
|
if cache.scroll.tick(elapsed) {
|
||||||
|
let cmds = engine.render_widget(
|
||||||
|
&cache.hint,
|
||||||
|
&cache.data,
|
||||||
|
cache.bounds,
|
||||||
|
cache.scroll.offset(),
|
||||||
|
cache.error.as_ref(),
|
||||||
|
);
|
||||||
|
updates.push(RenderUpdate {
|
||||||
|
bounds: cache.bounds,
|
||||||
|
commands: cmds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_active_scrollers(&self) -> bool {
|
||||||
|
self.widgets.values().any(|c| c.scroll.is_active())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.widgets.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
crates/client-esp32/Cargo.lock
generated
31
crates/client-esp32/Cargo.lock
generated
@@ -238,17 +238,13 @@ dependencies = [
|
|||||||
"client-domain",
|
"client-domain",
|
||||||
"domain",
|
"domain",
|
||||||
"embedded-graphics",
|
"embedded-graphics",
|
||||||
"embedded-hal-bus",
|
|
||||||
"embedded-text",
|
|
||||||
"embuild",
|
"embuild",
|
||||||
"esp-idf-hal",
|
"esp-idf-hal",
|
||||||
"esp-idf-svc",
|
"esp-idf-svc",
|
||||||
"esp-idf-sys",
|
"esp-idf-sys",
|
||||||
"log",
|
"log",
|
||||||
"mipidsi",
|
"mipidsi",
|
||||||
"postcard",
|
|
||||||
"protocol",
|
"protocol",
|
||||||
"serde",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -489,16 +485,6 @@ dependencies = [
|
|||||||
"embedded-hal 1.0.0",
|
"embedded-hal 1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "embedded-hal-bus"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "513e0b3a8fb7d3013a8ae17a834283f170deaf7d0eeab0a7c1a36ad4dd356d22"
|
|
||||||
dependencies = [
|
|
||||||
"critical-section",
|
|
||||||
"embedded-hal 1.0.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embedded-hal-nb"
|
name = "embedded-hal-nb"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -561,17 +547,6 @@ dependencies = [
|
|||||||
"strum 0.27.2",
|
"strum 0.27.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "embedded-text"
|
|
||||||
version = "0.7.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6cf5c72c52db2f7dbe4a9c1ed81cd21301e8d66311b194fa41c04fb4f71843ba"
|
|
||||||
dependencies = [
|
|
||||||
"az",
|
|
||||||
"embedded-graphics",
|
|
||||||
"object-chain",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embuild"
|
name = "embuild"
|
||||||
version = "0.33.1"
|
version = "0.33.1"
|
||||||
@@ -1126,12 +1101,6 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "object-chain"
|
|
||||||
version = "0.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "41af26158b0f5530f7b79955006c2727cd23d0d8e7c3109dc316db0a919784dd"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
|
|||||||
@@ -15,16 +15,18 @@ esp-idf-sys = "0.37"
|
|||||||
|
|
||||||
mipidsi = "0.10"
|
mipidsi = "0.10"
|
||||||
embedded-graphics = "0.8"
|
embedded-graphics = "0.8"
|
||||||
embedded-text = "0.7"
|
|
||||||
embedded-hal-bus = "0.3"
|
|
||||||
|
|
||||||
serde = { version = "1.0", default-features = false, features = [
|
|
||||||
"derive",
|
|
||||||
"alloc",
|
|
||||||
] }
|
|
||||||
postcard = { version = "1.1", default-features = false, features = ["alloc"] }
|
|
||||||
|
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
|
panic = "abort"
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
embuild = "0.33"
|
embuild = "0.33"
|
||||||
|
|||||||
@@ -19,3 +19,13 @@ CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
|
|||||||
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
|
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
|
||||||
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n
|
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n
|
||||||
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n
|
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n
|
||||||
|
|
||||||
|
# Disable Bluetooth (unused)
|
||||||
|
CONFIG_BT_ENABLED=n
|
||||||
|
|
||||||
|
# Disable ESP-MESH (unused)
|
||||||
|
CONFIG_ESP_WIFI_MESH_SUPPORT=n
|
||||||
|
|
||||||
|
# Reduce log verbosity
|
||||||
|
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||||
|
CONFIG_LOG_MAXIMUM_LEVEL_INFO=y
|
||||||
|
|||||||
@@ -41,12 +41,6 @@ fn fill_triangles<D: DrawTarget<Color = Rgb565>>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outer hexagon vertices (150x150 viewBox)
|
|
||||||
const OUTER_HEX: [(f32, f32); 6] = [
|
|
||||||
(75.0, 0.0), (150.0, 37.5), (150.0, 112.5),
|
|
||||||
(75.0, 150.0), (0.0, 112.5), (0.0, 37.5),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Converted from SVG: (150,75)(112.5,150)(37.5,150)(0,75)(37.5,0)(112.5,0)
|
// Converted from SVG: (150,75)(112.5,150)(37.5,150)(0,75)(37.5,0)(112.5,0)
|
||||||
// Reordered to start from top for cleaner fan
|
// Reordered to start from top for cleaner fan
|
||||||
const OUTER_HEX_SVG: [(f32, f32); 6] = [
|
const OUTER_HEX_SVG: [(f32, f32); 6] = [
|
||||||
|
|||||||
@@ -1,56 +1,26 @@
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use client_domain::NetworkPort;
|
use client_application::run_connection_loop;
|
||||||
use protocol::decode_server_message;
|
|
||||||
use super::RenderEvent;
|
use super::RenderEvent;
|
||||||
use crate::config::{NET_THREAD_STACK_SIZE, NET_POLL_INTERVAL, NET_RECONNECT_DELAY};
|
use crate::config::{NET_THREAD_STACK_SIZE, NET_POLL_INTERVAL, NET_RECONNECT_DELAY};
|
||||||
use crate::adapters::network::Esp32Network;
|
use crate::adapters::network::Esp32Network;
|
||||||
use log::*;
|
|
||||||
|
|
||||||
pub fn spawn(server_addr: String, tx: mpsc::Sender<RenderEvent>) {
|
pub fn spawn(server_addr: String, tx: mpsc::Sender<RenderEvent>) {
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.stack_size(NET_THREAD_STACK_SIZE)
|
.stack_size(NET_THREAD_STACK_SIZE)
|
||||||
.name("net".into())
|
.name("net".into())
|
||||||
.spawn(move || run(server_addr, tx))
|
.spawn(move || {
|
||||||
|
let mut net = Esp32Network::new();
|
||||||
|
let tx_msg = tx.clone();
|
||||||
|
let tx_status = tx.clone();
|
||||||
|
run_connection_loop(
|
||||||
|
&mut net,
|
||||||
|
&server_addr,
|
||||||
|
NET_POLL_INTERVAL,
|
||||||
|
NET_RECONNECT_DELAY,
|
||||||
|
move |msg| { let _ = tx_msg.send(RenderEvent::Server(msg)); },
|
||||||
|
move |connected| { let _ = tx_status.send(RenderEvent::ConnectionStatus(connected)); },
|
||||||
|
);
|
||||||
|
})
|
||||||
.expect("failed to spawn network thread");
|
.expect("failed to spawn network thread");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(server_addr: String, tx: mpsc::Sender<RenderEvent>) {
|
|
||||||
let mut net = Esp32Network::new();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if !net.is_connected() {
|
|
||||||
info!("Connecting to server {server_addr}...");
|
|
||||||
match net.connect(&server_addr) {
|
|
||||||
Ok(()) => {
|
|
||||||
info!("Server connected");
|
|
||||||
let _ = tx.send(RenderEvent::ConnectionStatus(true));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Connection failed: {e}, retrying...");
|
|
||||||
let _ = tx.send(RenderEvent::ConnectionStatus(false));
|
|
||||||
thread::sleep(NET_RECONNECT_DELAY);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match net.receive() {
|
|
||||||
Ok(Some(payload)) => {
|
|
||||||
match decode_server_message(&payload) {
|
|
||||||
Ok(msg) => { let _ = tx.send(RenderEvent::Server(msg)); }
|
|
||||||
Err(e) => error!("Decode error: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
thread::sleep(NET_POLL_INTERVAL);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Receive error: {e}, reconnecting...");
|
|
||||||
let _ = net.disconnect();
|
|
||||||
let _ = tx.send(RenderEvent::ConnectionStatus(false));
|
|
||||||
thread::sleep(NET_RECONNECT_DELAY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use std::collections::HashMap;
|
|
||||||
|
use client_application::RepaintCommand;
|
||||||
use client_domain::{
|
use client_domain::{
|
||||||
BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, ScrollState, ThemeConfig,
|
BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
|
||||||
|
WidgetRenderer,
|
||||||
};
|
};
|
||||||
use client_application::{ClientApp, RepaintCommand};
|
use client_application::ClientApp;
|
||||||
use domain::{DisplayHint, Value};
|
|
||||||
use protocol::ServerMessage;
|
use protocol::ServerMessage;
|
||||||
use super::RenderEvent;
|
use super::RenderEvent;
|
||||||
use crate::config::RENDER_POLL_INTERVAL;
|
use crate::config::RENDER_POLL_INTERVAL;
|
||||||
@@ -18,11 +19,14 @@ const INDICATOR_MARGIN: u16 = 4;
|
|||||||
const COLOR_CONNECTED: Color = Color(0, 200, 0);
|
const COLOR_CONNECTED: Color = Color(0, 200, 0);
|
||||||
const COLOR_DISCONNECTED: Color = Color(200, 0, 0);
|
const COLOR_DISCONNECTED: Color = Color(200, 0, 0);
|
||||||
|
|
||||||
struct WidgetCache {
|
fn to_request(cmd: &RepaintCommand) -> RepaintRequest {
|
||||||
hint: DisplayHint,
|
RepaintRequest {
|
||||||
data: Vec<(String, Value)>,
|
widget_id: cmd.widget_id,
|
||||||
bounds: BoundingBox,
|
bounds: cmd.bounds,
|
||||||
scroll: ScrollState,
|
display_hint: cmd.display_hint.clone(),
|
||||||
|
data: cmd.data.clone(),
|
||||||
|
error: cmd.error.clone(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(
|
pub fn run(
|
||||||
@@ -36,7 +40,7 @@ pub fn run(
|
|||||||
};
|
};
|
||||||
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
|
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
|
||||||
let mut app = ClientApp::new(screen);
|
let mut app = ClientApp::new(screen);
|
||||||
let mut widgets: HashMap<u16, WidgetCache> = HashMap::new();
|
let mut renderer = WidgetRenderer::new();
|
||||||
let mut first_update = true;
|
let mut first_update = true;
|
||||||
let mut last_tick = Instant::now();
|
let mut last_tick = Instant::now();
|
||||||
let mut connected = false;
|
let mut connected = false;
|
||||||
@@ -46,7 +50,12 @@ pub fn run(
|
|||||||
display.flush().unwrap();
|
display.flush().unwrap();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let timeout = RENDER_POLL_INTERVAL.min(SCROLL_TICK);
|
let has_scrollers = renderer.has_active_scrollers();
|
||||||
|
let timeout = if has_scrollers {
|
||||||
|
SCROLL_TICK
|
||||||
|
} else {
|
||||||
|
RENDER_POLL_INTERVAL
|
||||||
|
};
|
||||||
match rx.recv_timeout(timeout) {
|
match rx.recv_timeout(timeout) {
|
||||||
Ok(RenderEvent::ConnectionStatus(status)) => {
|
Ok(RenderEvent::ConnectionStatus(status)) => {
|
||||||
if status != connected {
|
if status != connected {
|
||||||
@@ -69,14 +78,19 @@ pub fn run(
|
|||||||
display.fill_rect(screen, bg).unwrap();
|
display.fill_rect(screen, bg).unwrap();
|
||||||
first_update = false;
|
first_update = false;
|
||||||
}
|
}
|
||||||
for cmd in &repaints {
|
|
||||||
let cache = update_cache(&engine, cmd);
|
let requests: Vec<_> = repaints.iter().map(to_request).collect();
|
||||||
display.fill_rect(cache.bounds, bg).unwrap();
|
let updates = renderer.apply_repaints(&engine, requests);
|
||||||
draw_widget(&engine, &mut display, &cache);
|
for update in &updates {
|
||||||
widgets.insert(cmd.widget_id, cache);
|
display.fill_rect(update.bounds, bg).unwrap();
|
||||||
|
for dc in &update.commands {
|
||||||
|
display
|
||||||
|
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !repaints.is_empty() {
|
if !updates.is_empty() {
|
||||||
draw_indicator(&mut display, screen, connected);
|
draw_indicator(&mut display, screen, connected);
|
||||||
display.flush().unwrap();
|
display.flush().unwrap();
|
||||||
}
|
}
|
||||||
@@ -92,16 +106,17 @@ pub fn run(
|
|||||||
let elapsed = now.duration_since(last_tick);
|
let elapsed = now.duration_since(last_tick);
|
||||||
last_tick = now;
|
last_tick = now;
|
||||||
|
|
||||||
let mut needs_flush = false;
|
let scroll_updates = renderer.tick_scroll(&engine, elapsed);
|
||||||
for cache in widgets.values_mut() {
|
if !scroll_updates.is_empty() {
|
||||||
if cache.scroll.tick(elapsed) {
|
|
||||||
let bg = engine.theme().background;
|
let bg = engine.theme().background;
|
||||||
display.fill_rect(cache.bounds, bg).unwrap();
|
for update in &scroll_updates {
|
||||||
draw_widget(&engine, &mut display, cache);
|
display.fill_rect(update.bounds, bg).unwrap();
|
||||||
needs_flush = true;
|
for dc in &update.commands {
|
||||||
|
display
|
||||||
|
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if needs_flush {
|
|
||||||
draw_indicator(&mut display, screen, connected);
|
draw_indicator(&mut display, screen, connected);
|
||||||
display.flush().unwrap();
|
display.flush().unwrap();
|
||||||
}
|
}
|
||||||
@@ -109,43 +124,14 @@ pub fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_indicator(display: &mut Esp32DisplayAdapter, screen: BoundingBox, connected: bool) {
|
fn draw_indicator(display: &mut Esp32DisplayAdapter, screen: BoundingBox, connected: bool) {
|
||||||
let color = if connected { COLOR_CONNECTED } else { COLOR_DISCONNECTED };
|
let color = if connected {
|
||||||
|
COLOR_CONNECTED
|
||||||
|
} else {
|
||||||
|
COLOR_DISCONNECTED
|
||||||
|
};
|
||||||
let x = screen.x + screen.width - INDICATOR_DIAMETER - INDICATOR_MARGIN;
|
let x = screen.x + screen.width - INDICATOR_DIAMETER - INDICATOR_MARGIN;
|
||||||
let y = screen.y + screen.height - INDICATOR_DIAMETER - INDICATOR_MARGIN;
|
let y = screen.y + screen.height - INDICATOR_DIAMETER - INDICATOR_MARGIN;
|
||||||
display.fill_circle(x, y, INDICATOR_DIAMETER, color).unwrap();
|
display
|
||||||
}
|
.fill_circle(x, y, INDICATOR_DIAMETER, color)
|
||||||
|
.unwrap();
|
||||||
fn update_cache(engine: &RenderEngine, cmd: &RepaintCommand) -> WidgetCache {
|
|
||||||
let hint: DisplayHint = cmd.display_hint.clone().into();
|
|
||||||
let data: Vec<(String, Value)> = cmd.state.data
|
|
||||||
.iter()
|
|
||||||
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let content_h = engine.content_height(&hint, &data, cmd.bounds.width);
|
|
||||||
let scroll = ScrollState::new(cmd.bounds.height, content_h);
|
|
||||||
|
|
||||||
WidgetCache {
|
|
||||||
hint,
|
|
||||||
data,
|
|
||||||
bounds: cmd.bounds,
|
|
||||||
scroll,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_widget(
|
|
||||||
engine: &RenderEngine,
|
|
||||||
display: &mut Esp32DisplayAdapter,
|
|
||||||
cache: &WidgetCache,
|
|
||||||
) {
|
|
||||||
let draw_cmds = engine.render_widget(
|
|
||||||
&cache.hint,
|
|
||||||
&cache.data,
|
|
||||||
cache.bounds,
|
|
||||||
cache.scroll.offset(),
|
|
||||||
);
|
|
||||||
|
|
||||||
for dc in &draw_cmds {
|
|
||||||
display.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font).unwrap();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ name = "domain"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
json = ["serde_json"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
serde_json = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
use crate::entities::{DataSourceId, LayoutPresetId, WidgetId};
|
use crate::entities::{DataSourceId, LayoutPresetId, WidgetId};
|
||||||
use crate::value_objects::{Layout, ThemeConfig};
|
use crate::value_objects::{Layout, ThemeConfig, Value};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum DomainEvent {
|
pub enum DomainEvent {
|
||||||
WidgetCreated { id: WidgetId },
|
WidgetCreated {
|
||||||
WidgetUpdated { id: WidgetId },
|
id: WidgetId,
|
||||||
WidgetDeleted { id: WidgetId },
|
},
|
||||||
DataSourceAdded { id: DataSourceId },
|
WidgetUpdated {
|
||||||
DataSourceUpdated { id: DataSourceId },
|
id: WidgetId,
|
||||||
DataSourceRemoved { id: DataSourceId },
|
},
|
||||||
LayoutChanged { layout: Layout },
|
WidgetDeleted {
|
||||||
ThemeChanged { theme: ThemeConfig },
|
id: WidgetId,
|
||||||
LayoutPresetSaved { id: LayoutPresetId },
|
},
|
||||||
LayoutPresetLoaded { id: LayoutPresetId },
|
DataSourceAdded {
|
||||||
LayoutPresetDeleted { id: LayoutPresetId },
|
id: DataSourceId,
|
||||||
|
},
|
||||||
|
DataSourceUpdated {
|
||||||
|
id: DataSourceId,
|
||||||
|
},
|
||||||
|
DataSourceRemoved {
|
||||||
|
id: DataSourceId,
|
||||||
|
},
|
||||||
|
LayoutChanged {
|
||||||
|
layout: Layout,
|
||||||
|
},
|
||||||
|
ThemeChanged {
|
||||||
|
theme: ThemeConfig,
|
||||||
|
},
|
||||||
|
LayoutPresetSaved {
|
||||||
|
id: LayoutPresetId,
|
||||||
|
},
|
||||||
|
LayoutPresetLoaded {
|
||||||
|
id: LayoutPresetId,
|
||||||
|
},
|
||||||
|
LayoutPresetDeleted {
|
||||||
|
id: LayoutPresetId,
|
||||||
|
},
|
||||||
|
WebhookDataReceived {
|
||||||
|
source_id: DataSourceId,
|
||||||
|
data: Value,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ pub use entities::{
|
|||||||
pub use events::DomainEvent;
|
pub use events::DomainEvent;
|
||||||
pub use ports::{
|
pub use ports::{
|
||||||
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
|
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
|
||||||
EventPublisher, PasswordHashPort, SecretStore, WidgetStateReader,
|
EventPublisher, PasswordHashPort, SecretStore, UserRepository, WidgetStateCache,
|
||||||
|
WidgetStateReader,
|
||||||
};
|
};
|
||||||
pub use value_objects::{
|
pub use value_objects::{
|
||||||
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
|
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::entities::{
|
use crate::entities::{
|
||||||
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId,
|
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
use crate::value_objects::{Layout, ThemeConfig};
|
use crate::value_objects::{Layout, ThemeConfig};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
@@ -56,11 +56,4 @@ pub trait ConfigRepository {
|
|||||||
&self,
|
&self,
|
||||||
theme: &ThemeConfig,
|
theme: &ThemeConfig,
|
||||||
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
|
||||||
fn get_user_by_username(
|
|
||||||
&self,
|
|
||||||
username: &str,
|
|
||||||
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
|
|
||||||
fn save_user(&self, user: &User) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
|
||||||
fn count_users(&self) -> impl Future<Output = Result<u32, Self::Error>> + Send;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ mod config_repository;
|
|||||||
mod data_source_port;
|
mod data_source_port;
|
||||||
mod event;
|
mod event;
|
||||||
mod secret_store;
|
mod secret_store;
|
||||||
|
mod user_repository;
|
||||||
|
mod widget_state_cache;
|
||||||
mod widget_state_reader;
|
mod widget_state_reader;
|
||||||
|
|
||||||
pub use auth::{AuthPort, PasswordHashPort};
|
pub use auth::{AuthPort, PasswordHashPort};
|
||||||
@@ -14,4 +16,6 @@ pub use config_repository::ConfigRepository;
|
|||||||
pub use data_source_port::DataSourcePort;
|
pub use data_source_port::DataSourcePort;
|
||||||
pub use event::EventPublisher;
|
pub use event::EventPublisher;
|
||||||
pub use secret_store::SecretStore;
|
pub use secret_store::SecretStore;
|
||||||
|
pub use user_repository::UserRepository;
|
||||||
|
pub use widget_state_cache::WidgetStateCache;
|
||||||
pub use widget_state_reader::WidgetStateReader;
|
pub use widget_state_reader::WidgetStateReader;
|
||||||
|
|||||||
13
crates/domain/src/ports/user_repository.rs
Normal file
13
crates/domain/src/ports/user_repository.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use crate::entities::User;
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
pub trait UserRepository {
|
||||||
|
type Error;
|
||||||
|
|
||||||
|
fn get_user_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
|
||||||
|
fn save_user(&self, user: &User) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
fn count_users(&self) -> impl Future<Output = Result<u32, Self::Error>> + Send;
|
||||||
|
}
|
||||||
15
crates/domain/src/ports/widget_state_cache.rs
Normal file
15
crates/domain/src/ports/widget_state_cache.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use crate::entities::WidgetId;
|
||||||
|
use crate::value_objects::WidgetState;
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
pub trait WidgetStateCache {
|
||||||
|
type Error;
|
||||||
|
|
||||||
|
fn save_widget_states(
|
||||||
|
&self,
|
||||||
|
states: &[(WidgetId, WidgetState)],
|
||||||
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
fn load_widget_states(
|
||||||
|
&self,
|
||||||
|
) -> impl Future<Output = Result<Vec<(WidgetId, WidgetState)>, Self::Error>> + Send;
|
||||||
|
}
|
||||||
@@ -1,5 +1,39 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[cfg(feature = "json")]
|
||||||
|
impl From<serde_json::Value> for Value {
|
||||||
|
fn from(json: serde_json::Value) -> Self {
|
||||||
|
match json {
|
||||||
|
serde_json::Value::Null => Value::Null,
|
||||||
|
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::String(s) => Value::String(s),
|
||||||
|
serde_json::Value::Array(arr) => {
|
||||||
|
Value::Array(arr.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
Value::Object(map.into_iter().map(|(k, v)| (k, v.into())).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "json")]
|
||||||
|
impl From<&Value> for serde_json::Value {
|
||||||
|
fn from(v: &Value) -> Self {
|
||||||
|
match v {
|
||||||
|
Value::Null => serde_json::Value::Null,
|
||||||
|
Value::Bool(b) => serde_json::Value::Bool(*b),
|
||||||
|
Value::Number(n) => serde_json::json!(*n),
|
||||||
|
Value::String(s) => serde_json::Value::String(s.clone()),
|
||||||
|
Value::Array(arr) => serde_json::Value::Array(arr.iter().map(Into::into).collect()),
|
||||||
|
Value::Object(map) => {
|
||||||
|
serde_json::Value::Object(map.iter().map(|(k, v)| (k.clone(), v.into())).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum Value {
|
pub enum Value {
|
||||||
Null,
|
Null,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain.workspace = true
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
postcard.workspace = true
|
postcard.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
use domain::value_objects::{
|
|
||||||
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
|
|
||||||
LayoutChild, LayoutNode, Sizing, VAlign, Value, WidgetError, WidgetState,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
@@ -15,60 +11,12 @@ pub enum WireValue {
|
|||||||
Object(BTreeMap<String, WireValue>),
|
Object(BTreeMap<String, WireValue>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Value> for WireValue {
|
|
||||||
fn from(v: &Value) -> Self {
|
|
||||||
match v {
|
|
||||||
Value::Null => WireValue::Null,
|
|
||||||
Value::Bool(b) => WireValue::Bool(*b),
|
|
||||||
Value::Number(n) => WireValue::Number(*n),
|
|
||||||
Value::String(s) => WireValue::String(s.clone()),
|
|
||||||
Value::Array(arr) => WireValue::Array(arr.iter().map(Into::into).collect()),
|
|
||||||
Value::Object(map) => {
|
|
||||||
WireValue::Object(map.iter().map(|(k, v)| (k.clone(), v.into())).collect())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireValue> for Value {
|
|
||||||
fn from(w: WireValue) -> Self {
|
|
||||||
match w {
|
|
||||||
WireValue::Null => Value::Null,
|
|
||||||
WireValue::Bool(b) => Value::Bool(b),
|
|
||||||
WireValue::Number(n) => Value::Number(n),
|
|
||||||
WireValue::String(s) => Value::String(s),
|
|
||||||
WireValue::Array(arr) => Value::Array(arr.into_iter().map(Into::into).collect()),
|
|
||||||
WireValue::Object(map) => {
|
|
||||||
Value::Object(map.into_iter().map(|(k, v)| (k, v.into())).collect())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireWidgetError {
|
pub enum WireWidgetError {
|
||||||
SourceUnavailable,
|
SourceUnavailable,
|
||||||
ExtractionFailed,
|
ExtractionFailed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&WidgetError> for WireWidgetError {
|
|
||||||
fn from(e: &WidgetError) -> Self {
|
|
||||||
match e {
|
|
||||||
WidgetError::SourceUnavailable => WireWidgetError::SourceUnavailable,
|
|
||||||
WidgetError::ExtractionFailed => WireWidgetError::ExtractionFailed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireWidgetError> for WidgetError {
|
|
||||||
fn from(w: WireWidgetError) -> Self {
|
|
||||||
match w {
|
|
||||||
WireWidgetError::SourceUnavailable => WidgetError::SourceUnavailable,
|
|
||||||
WireWidgetError::ExtractionFailed => WidgetError::ExtractionFailed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct WireKeyValue {
|
pub struct WireKeyValue {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
@@ -81,35 +29,6 @@ pub struct WireWidgetState {
|
|||||||
pub error: Option<WireWidgetError>,
|
pub error: Option<WireWidgetError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&WidgetState> for WireWidgetState {
|
|
||||||
fn from(s: &WidgetState) -> Self {
|
|
||||||
WireWidgetState {
|
|
||||||
data: s
|
|
||||||
.data
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| WireKeyValue {
|
|
||||||
key: k.clone(),
|
|
||||||
value: v.into(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
error: s.error.as_ref().map(Into::into),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireWidgetState> for WidgetState {
|
|
||||||
fn from(w: WireWidgetState) -> Self {
|
|
||||||
WidgetState {
|
|
||||||
data: w
|
|
||||||
.data
|
|
||||||
.into_iter()
|
|
||||||
.map(|kv| (kv.key, kv.value.into()))
|
|
||||||
.collect(),
|
|
||||||
error: w.error.map(Into::into),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireDisplayHintKind {
|
pub enum WireDisplayHintKind {
|
||||||
IconValue,
|
IconValue,
|
||||||
@@ -148,134 +67,18 @@ impl WireDisplayHint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&DisplayHintKind> for WireDisplayHintKind {
|
|
||||||
fn from(k: &DisplayHintKind) -> Self {
|
|
||||||
match k {
|
|
||||||
DisplayHintKind::IconValue => WireDisplayHintKind::IconValue,
|
|
||||||
DisplayHintKind::TextBlock => WireDisplayHintKind::TextBlock,
|
|
||||||
DisplayHintKind::KeyValue => WireDisplayHintKind::KeyValue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireDisplayHintKind> for DisplayHintKind {
|
|
||||||
fn from(w: WireDisplayHintKind) -> Self {
|
|
||||||
match w {
|
|
||||||
WireDisplayHintKind::IconValue => DisplayHintKind::IconValue,
|
|
||||||
WireDisplayHintKind::TextBlock => DisplayHintKind::TextBlock,
|
|
||||||
WireDisplayHintKind::KeyValue => DisplayHintKind::KeyValue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&HAlign> for WireHAlign {
|
|
||||||
fn from(h: &HAlign) -> Self {
|
|
||||||
match h {
|
|
||||||
HAlign::Left => WireHAlign::Left,
|
|
||||||
HAlign::Center => WireHAlign::Center,
|
|
||||||
HAlign::Right => WireHAlign::Right,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireHAlign> for HAlign {
|
|
||||||
fn from(w: WireHAlign) -> Self {
|
|
||||||
match w {
|
|
||||||
WireHAlign::Left => HAlign::Left,
|
|
||||||
WireHAlign::Center => HAlign::Center,
|
|
||||||
WireHAlign::Right => HAlign::Right,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&VAlign> for WireVAlign {
|
|
||||||
fn from(v: &VAlign) -> Self {
|
|
||||||
match v {
|
|
||||||
VAlign::Top => WireVAlign::Top,
|
|
||||||
VAlign::Middle => WireVAlign::Middle,
|
|
||||||
VAlign::Bottom => WireVAlign::Bottom,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireVAlign> for VAlign {
|
|
||||||
fn from(w: WireVAlign) -> Self {
|
|
||||||
match w {
|
|
||||||
WireVAlign::Top => VAlign::Top,
|
|
||||||
WireVAlign::Middle => VAlign::Middle,
|
|
||||||
WireVAlign::Bottom => VAlign::Bottom,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&DisplayHint> for WireDisplayHint {
|
|
||||||
fn from(h: &DisplayHint) -> Self {
|
|
||||||
WireDisplayHint {
|
|
||||||
kind: (&h.kind).into(),
|
|
||||||
h_align: (&h.h_align).into(),
|
|
||||||
v_align: (&h.v_align).into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireDisplayHint> for DisplayHint {
|
|
||||||
fn from(w: WireDisplayHint) -> Self {
|
|
||||||
DisplayHint {
|
|
||||||
kind: w.kind.into(),
|
|
||||||
h_align: w.h_align.into(),
|
|
||||||
v_align: w.v_align.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireSizing {
|
pub enum WireSizing {
|
||||||
Fixed(u16),
|
Fixed(u16),
|
||||||
Flex(u8),
|
Flex(u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Sizing> for WireSizing {
|
|
||||||
fn from(s: &Sizing) -> Self {
|
|
||||||
match s {
|
|
||||||
Sizing::Fixed(px) => WireSizing::Fixed(*px),
|
|
||||||
Sizing::Flex(w) => WireSizing::Flex(*w),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireSizing> for Sizing {
|
|
||||||
fn from(w: WireSizing) -> Self {
|
|
||||||
match w {
|
|
||||||
WireSizing::Fixed(px) => Sizing::Fixed(px),
|
|
||||||
WireSizing::Flex(weight) => Sizing::Flex(weight),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireDirection {
|
pub enum WireDirection {
|
||||||
Row,
|
Row,
|
||||||
Column,
|
Column,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Direction> for WireDirection {
|
|
||||||
fn from(d: &Direction) -> Self {
|
|
||||||
match d {
|
|
||||||
Direction::Row => WireDirection::Row,
|
|
||||||
Direction::Column => WireDirection::Column,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireDirection> for Direction {
|
|
||||||
fn from(w: WireDirection) -> Self {
|
|
||||||
match w {
|
|
||||||
WireDirection::Row => Direction::Row,
|
|
||||||
WireDirection::Column => Direction::Column,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireJustifyContent {
|
pub enum WireJustifyContent {
|
||||||
Start,
|
Start,
|
||||||
@@ -285,30 +88,6 @@ pub enum WireJustifyContent {
|
|||||||
SpaceEvenly,
|
SpaceEvenly,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&JustifyContent> for WireJustifyContent {
|
|
||||||
fn from(j: &JustifyContent) -> Self {
|
|
||||||
match j {
|
|
||||||
JustifyContent::Start => WireJustifyContent::Start,
|
|
||||||
JustifyContent::Center => WireJustifyContent::Center,
|
|
||||||
JustifyContent::End => WireJustifyContent::End,
|
|
||||||
JustifyContent::SpaceBetween => WireJustifyContent::SpaceBetween,
|
|
||||||
JustifyContent::SpaceEvenly => WireJustifyContent::SpaceEvenly,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireJustifyContent> for JustifyContent {
|
|
||||||
fn from(w: WireJustifyContent) -> Self {
|
|
||||||
match w {
|
|
||||||
WireJustifyContent::Start => JustifyContent::Start,
|
|
||||||
WireJustifyContent::Center => JustifyContent::Center,
|
|
||||||
WireJustifyContent::End => JustifyContent::End,
|
|
||||||
WireJustifyContent::SpaceBetween => JustifyContent::SpaceBetween,
|
|
||||||
WireJustifyContent::SpaceEvenly => JustifyContent::SpaceEvenly,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum WireAlignItems {
|
pub enum WireAlignItems {
|
||||||
Start,
|
Start,
|
||||||
@@ -317,28 +96,6 @@ pub enum WireAlignItems {
|
|||||||
Stretch,
|
Stretch,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&AlignItems> for WireAlignItems {
|
|
||||||
fn from(a: &AlignItems) -> Self {
|
|
||||||
match a {
|
|
||||||
AlignItems::Start => WireAlignItems::Start,
|
|
||||||
AlignItems::Center => WireAlignItems::Center,
|
|
||||||
AlignItems::End => WireAlignItems::End,
|
|
||||||
AlignItems::Stretch => WireAlignItems::Stretch,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireAlignItems> for AlignItems {
|
|
||||||
fn from(w: WireAlignItems) -> Self {
|
|
||||||
match w {
|
|
||||||
WireAlignItems::Start => AlignItems::Start,
|
|
||||||
WireAlignItems::Center => AlignItems::Center,
|
|
||||||
WireAlignItems::End => AlignItems::End,
|
|
||||||
WireAlignItems::Stretch => AlignItems::Stretch,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct WireContainerNode {
|
pub struct WireContainerNode {
|
||||||
pub direction: WireDirection,
|
pub direction: WireDirection,
|
||||||
@@ -360,49 +117,3 @@ pub enum WireLayoutNode {
|
|||||||
Container(WireContainerNode),
|
Container(WireContainerNode),
|
||||||
Leaf(u16),
|
Leaf(u16),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&LayoutNode> for WireLayoutNode {
|
|
||||||
fn from(n: &LayoutNode) -> Self {
|
|
||||||
match n {
|
|
||||||
LayoutNode::Leaf(id) => WireLayoutNode::Leaf(*id),
|
|
||||||
LayoutNode::Container(c) => WireLayoutNode::Container(WireContainerNode {
|
|
||||||
direction: (&c.direction).into(),
|
|
||||||
gap: c.gap,
|
|
||||||
padding: c.padding,
|
|
||||||
justify_content: (&c.justify_content).into(),
|
|
||||||
align_items: (&c.align_items).into(),
|
|
||||||
children: c
|
|
||||||
.children
|
|
||||||
.iter()
|
|
||||||
.map(|ch| WireLayoutChild {
|
|
||||||
sizing: (&ch.sizing).into(),
|
|
||||||
node: (&ch.node).into(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WireLayoutNode> for LayoutNode {
|
|
||||||
fn from(w: WireLayoutNode) -> Self {
|
|
||||||
match w {
|
|
||||||
WireLayoutNode::Leaf(id) => LayoutNode::Leaf(id),
|
|
||||||
WireLayoutNode::Container(c) => LayoutNode::Container(ContainerNode {
|
|
||||||
direction: c.direction.into(),
|
|
||||||
gap: c.gap,
|
|
||||||
padding: c.padding,
|
|
||||||
justify_content: c.justify_content.into(),
|
|
||||||
align_items: c.align_items.into(),
|
|
||||||
children: c
|
|
||||||
.children
|
|
||||||
.into_iter()
|
|
||||||
.map(|ch| LayoutChild {
|
|
||||||
sizing: ch.sizing.into(),
|
|
||||||
node: ch.node.into(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
use domain::{
|
|
||||||
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, JustifyContent,
|
|
||||||
LayoutChild, LayoutNode, Sizing, Value, WidgetError, WidgetState,
|
|
||||||
};
|
|
||||||
use protocol::{
|
|
||||||
WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild,
|
|
||||||
WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState,
|
|
||||||
};
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn value_converts_to_wire_and_back() {
|
|
||||||
let original = Value::Object(BTreeMap::from([(
|
|
||||||
"items".into(),
|
|
||||||
Value::Array(vec![
|
|
||||||
Value::String("hello".into()),
|
|
||||||
Value::Number(42.0),
|
|
||||||
Value::Bool(true),
|
|
||||||
Value::Null,
|
|
||||||
]),
|
|
||||||
)]));
|
|
||||||
|
|
||||||
let wire: WireValue = (&original).into();
|
|
||||||
let roundtripped: Value = wire.into();
|
|
||||||
assert_eq!(original, roundtripped);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn widget_state_with_error_converts_to_wire_and_back() {
|
|
||||||
let original = WidgetState {
|
|
||||||
data: BTreeMap::from([("temp".into(), Value::Number(5.4))]),
|
|
||||||
error: Some(WidgetError::SourceUnavailable),
|
|
||||||
};
|
|
||||||
|
|
||||||
let wire: WireWidgetState = (&original).into();
|
|
||||||
let roundtripped: WidgetState = wire.into();
|
|
||||||
assert_eq!(original, roundtripped);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn layout_tree_converts_to_wire_and_back() {
|
|
||||||
let original = LayoutNode::Container(ContainerNode {
|
|
||||||
direction: Direction::Row,
|
|
||||||
gap: 4,
|
|
||||||
padding: 2,
|
|
||||||
justify_content: JustifyContent::Start,
|
|
||||||
align_items: AlignItems::Stretch,
|
|
||||||
children: vec![
|
|
||||||
LayoutChild {
|
|
||||||
sizing: Sizing::Flex(1),
|
|
||||||
node: LayoutNode::Leaf(1),
|
|
||||||
},
|
|
||||||
LayoutChild {
|
|
||||||
sizing: Sizing::Fixed(100),
|
|
||||||
node: LayoutNode::Container(ContainerNode {
|
|
||||||
direction: Direction::Column,
|
|
||||||
gap: 2,
|
|
||||||
padding: 0,
|
|
||||||
justify_content: JustifyContent::Start,
|
|
||||||
align_items: AlignItems::Stretch,
|
|
||||||
children: vec![LayoutChild {
|
|
||||||
sizing: Sizing::Flex(1),
|
|
||||||
node: LayoutNode::Leaf(2),
|
|
||||||
}],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
let wire: WireLayoutNode = (&original).into();
|
|
||||||
let roundtripped: LayoutNode = wire.into();
|
|
||||||
assert_eq!(original, roundtripped);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn display_hint_converts_to_wire_and_back() {
|
|
||||||
for hint in [
|
|
||||||
DisplayHint::new(DisplayHintKind::IconValue),
|
|
||||||
DisplayHint::new(DisplayHintKind::TextBlock),
|
|
||||||
DisplayHint::new(DisplayHintKind::KeyValue),
|
|
||||||
] {
|
|
||||||
let wire: WireDisplayHint = (&hint).into();
|
|
||||||
let roundtripped: DisplayHint = wire.into();
|
|
||||||
assert_eq!(hint, roundtripped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6410
other/logo_splash.h
6410
other/logo_splash.h
File diff suppressed because it is too large
Load Diff
@@ -1,43 +0,0 @@
|
|||||||
from PIL import Image
|
|
||||||
|
|
||||||
INPUT = "/home/gabriel/Downloads/Logo.png"
|
|
||||||
OUTPUT = "/home/gabriel/Downloads/logo_splash.h"
|
|
||||||
DISPLAY_W, DISPLAY_H = 240, 320
|
|
||||||
BG_COLOR = (30, 30, 30) # dark background, change to (0,0,0) for pure black
|
|
||||||
|
|
||||||
img = Image.open(INPUT).convert("RGB")
|
|
||||||
|
|
||||||
# Scale logo to fit within display, preserving aspect ratio
|
|
||||||
img.thumbnail((DISPLAY_W, DISPLAY_H), Image.LANCZOS)
|
|
||||||
|
|
||||||
# Center on display canvas
|
|
||||||
canvas = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BG_COLOR)
|
|
||||||
x = (DISPLAY_W - img.width) // 2
|
|
||||||
y = (DISPLAY_H - img.height) // 2
|
|
||||||
canvas.paste(img, (x, y))
|
|
||||||
|
|
||||||
# Convert to RGB565
|
|
||||||
pixels = []
|
|
||||||
for r, g, b in canvas.getdata():
|
|
||||||
rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
|
|
||||||
# Swap bytes for ILI9341 (big-endian over SPI)
|
|
||||||
pixels.append(((rgb565 & 0xFF) << 8) | (rgb565 >> 8))
|
|
||||||
|
|
||||||
# Write header file
|
|
||||||
with open(OUTPUT, "w") as f:
|
|
||||||
f.write("#pragma once\n")
|
|
||||||
f.write("#include <pgmspace.h>\n\n")
|
|
||||||
f.write(f"// {DISPLAY_W}x{DISPLAY_H} RGB565, big-endian\n")
|
|
||||||
f.write(f"const uint16_t SPLASH_WIDTH = {DISPLAY_W};\n")
|
|
||||||
f.write(f"const uint16_t SPLASH_HEIGHT = {DISPLAY_H};\n\n")
|
|
||||||
f.write("const uint16_t splash_logo[] PROGMEM = {\n")
|
|
||||||
for i, p in enumerate(pixels):
|
|
||||||
if i % 12 == 0:
|
|
||||||
f.write(" ")
|
|
||||||
f.write(f"0x{p:04X},")
|
|
||||||
if i % 12 == 11:
|
|
||||||
f.write("\n")
|
|
||||||
f.write("\n};\n")
|
|
||||||
|
|
||||||
print(f"Done — {len(pixels)} pixels, {len(pixels)*2/1024:.1f} KB")
|
|
||||||
print(f"Output: {OUTPUT}")
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useRef } from "react"
|
import { useMemo, useRef } from "react"
|
||||||
import type { LayoutNode, ThemeConfig, Widget } from "@/api/types"
|
import type { LayoutNode, ThemeConfig, Widget } from "@/api/types"
|
||||||
import { computeLayout } from "@/lib/layout-engine"
|
import { computeLayout } from "@/lib/layout-engine"
|
||||||
|
import { useWidgetPreview } from "@/api/widgets"
|
||||||
|
|
||||||
interface LayoutPreviewProps {
|
interface LayoutPreviewProps {
|
||||||
layout: LayoutNode
|
layout: LayoutNode
|
||||||
@@ -19,6 +20,91 @@ function collectWidgetIds(node: LayoutNode): number[] {
|
|||||||
return (node.children ?? []).flatMap((c) => collectWidgetIds(c.node))
|
return (node.children ?? []).flatMap((c) => collectWidgetIds(c.node))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPreviewData(
|
||||||
|
kind: string,
|
||||||
|
data: Record<string, unknown> | undefined,
|
||||||
|
): string {
|
||||||
|
if (!data) return ""
|
||||||
|
const entries = Object.entries(data)
|
||||||
|
if (entries.length === 0) return ""
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case "key_value":
|
||||||
|
return entries.map(([k, v]) => `${k}: ${v}`).join("\n")
|
||||||
|
case "text_block":
|
||||||
|
return entries.map(([, v]) => String(v ?? "")).join("\n")
|
||||||
|
case "icon_value":
|
||||||
|
return entries.map(([, v]) => String(v ?? "")).join(" ")
|
||||||
|
default:
|
||||||
|
return entries.map(([, v]) => String(v ?? "")).join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetCell({ wid, widget, scale, theme }: {
|
||||||
|
wid: number
|
||||||
|
widget: Widget | undefined
|
||||||
|
scale: number
|
||||||
|
theme: ThemeConfig
|
||||||
|
}) {
|
||||||
|
const { data } = useWidgetPreview(wid, true)
|
||||||
|
const hAlign = widget?.display_hint?.h_align ?? "left"
|
||||||
|
const vAlign = widget?.display_hint?.v_align ?? "top"
|
||||||
|
const flexAlign = hAlign === "center" ? "center" : hAlign === "right" ? "flex-end" : "flex-start"
|
||||||
|
const flexJustify = vAlign === "middle" ? "center" : vAlign === "bottom" ? "flex-end" : "flex-start"
|
||||||
|
const textAlign = hAlign === "center" ? "center" as const : hAlign === "right" ? "right" as const : "left" as const
|
||||||
|
|
||||||
|
const kind = widget?.display_hint?.kind ?? "text_block"
|
||||||
|
const previewText = formatPreviewData(kind, data as Record<string, unknown> | undefined)
|
||||||
|
const hasData = previewText.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: flexAlign,
|
||||||
|
justifyContent: flexJustify,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasData ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 8 * scale,
|
||||||
|
color: colorToCSS(theme.text),
|
||||||
|
textAlign,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewText}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10 * scale,
|
||||||
|
color: colorToCSS(theme.text),
|
||||||
|
textAlign,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{widget?.name ?? `#${wid}`}
|
||||||
|
</span>
|
||||||
|
{widget && (
|
||||||
|
<span style={{ fontSize: 7 * scale, color: colorToCSS(theme.accent), textAlign, opacity: 0.5 }}>
|
||||||
|
{kind}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function LayoutPreview({
|
export function LayoutPreview({
|
||||||
layout,
|
layout,
|
||||||
screenWidth,
|
screenWidth,
|
||||||
@@ -54,11 +140,6 @@ export function LayoutPreview({
|
|||||||
const box = bounds.get(wid)
|
const box = bounds.get(wid)
|
||||||
if (!box) return null
|
if (!box) return null
|
||||||
const w = widgets.find((w) => w.id === wid)
|
const w = widgets.find((w) => w.id === wid)
|
||||||
const hAlign = w?.display_hint?.h_align ?? "left"
|
|
||||||
const vAlign = w?.display_hint?.v_align ?? "top"
|
|
||||||
const flexAlign = hAlign === "center" ? "center" : hAlign === "right" ? "flex-end" : "flex-start"
|
|
||||||
const flexJustify = vAlign === "middle" ? "center" : vAlign === "bottom" ? "flex-end" : "flex-start"
|
|
||||||
const textAlign = hAlign === "center" ? "center" as const : hAlign === "right" ? "right" as const : "left" as const
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={wid}
|
key={wid}
|
||||||
@@ -70,35 +151,11 @@ export function LayoutPreview({
|
|||||||
height: box.height * scale,
|
height: box.height * scale,
|
||||||
border: `1px solid ${colorToCSS(theme.secondary)}`,
|
border: `1px solid ${colorToCSS(theme.secondary)}`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: flexAlign,
|
|
||||||
justifyContent: flexJustify,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
padding: 2 * scale,
|
padding: 2 * scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<WidgetCell wid={wid} widget={w} scale={scale} theme={theme} />
|
||||||
style={{
|
|
||||||
fontSize: 10 * scale,
|
|
||||||
color: colorToCSS(theme.text),
|
|
||||||
textAlign,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{w?.name ?? `#${wid}`}
|
|
||||||
</span>
|
|
||||||
{w && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 8 * scale,
|
|
||||||
color: colorToCSS(theme.accent),
|
|
||||||
textAlign,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{w.display_hint.kind}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import {
|
import {
|
||||||
useDataSources,
|
useDataSources,
|
||||||
useCreateDataSource,
|
useCreateDataSource,
|
||||||
@@ -14,13 +14,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
import {
|
||||||
@@ -30,28 +23,12 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Plus, Pencil, Trash2, X, Eye, EyeOff } from "lucide-react"
|
import { Plus, Pencil, Trash2, X, Eye, EyeOff, ChevronUp } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
const SOURCE_TYPES: SourceType[] = [
|
const SOURCE_TYPES: SourceType[] = [
|
||||||
"weather",
|
"weather", "media", "rss", "http_json", "webhook", "clock", "static_text",
|
||||||
"media",
|
|
||||||
"rss",
|
|
||||||
"http_json",
|
|
||||||
"webhook",
|
|
||||||
"clock",
|
|
||||||
"static_text",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const EXTERNAL_TYPES: SourceType[] = ["weather", "media", "rss", "http_json", "webhook"]
|
const EXTERNAL_TYPES: SourceType[] = ["weather", "media", "rss", "http_json", "webhook"]
|
||||||
@@ -70,83 +47,158 @@ const EMPTY: DataSource = {
|
|||||||
config: { type: "external", url: null, api_key: null, headers: [] },
|
config: { type: "external", url: null, api_key: null, headers: [] },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_TIMEZONES = new Set(Intl.supportedValuesOf("timeZone"))
|
||||||
|
|
||||||
|
const STRFTIME_MAP: Record<string, (d: Date) => string> = {
|
||||||
|
"%H": (d) => String(d.getHours()).padStart(2, "0"),
|
||||||
|
"%M": (d) => String(d.getMinutes()).padStart(2, "0"),
|
||||||
|
"%S": (d) => String(d.getSeconds()).padStart(2, "0"),
|
||||||
|
"%I": (d) => String(d.getHours() % 12 || 12).padStart(2, "0"),
|
||||||
|
"%p": (d) => (d.getHours() >= 12 ? "PM" : "AM"),
|
||||||
|
"%Y": (d) => String(d.getFullYear()),
|
||||||
|
"%m": (d) => String(d.getMonth() + 1).padStart(2, "0"),
|
||||||
|
"%d": (d) => String(d.getDate()).padStart(2, "0"),
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClockPreview(fmt: string, tz: string): string {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric", month: "2-digit", day: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(new Date())
|
||||||
|
|
||||||
|
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""
|
||||||
|
const h24 = Number(get("hour"))
|
||||||
|
const fakeDate = new Date(2000, 0, 1, h24, Number(get("minute")), Number(get("second")))
|
||||||
|
|
||||||
|
let result = fmt
|
||||||
|
for (const [token, fn] of Object.entries(STRFTIME_MAP)) {
|
||||||
|
if (token === "%Y") result = result.replaceAll(token, get("year"))
|
||||||
|
else if (token === "%m") result = result.replaceAll(token, get("month"))
|
||||||
|
else if (token === "%d") result = result.replaceAll(token, get("day"))
|
||||||
|
else result = result.replaceAll(token, fn(fakeDate))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
return "invalid timezone"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSave(ds: DataSource): boolean {
|
||||||
|
if (!ds.name) return false
|
||||||
|
if (EXTERNAL_TYPES.includes(ds.source_type) && ds.source_type !== "webhook" && ds.poll_interval_secs <= 0) return false
|
||||||
|
if (EXTERNAL_TYPES.includes(ds.source_type) && ds.source_type !== "webhook" && ds.config.type === "external" && !ds.config.url) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function DataSourcesPage() {
|
export function DataSourcesPage() {
|
||||||
const { data: sources = [], isLoading } = useDataSources()
|
const { data: sources = [], isLoading } = useDataSources()
|
||||||
const create = useCreateDataSource()
|
const create = useCreateDataSource()
|
||||||
const update = useUpdateDataSource()
|
const update = useUpdateDataSource()
|
||||||
const del = useDeleteDataSource()
|
const del = useDeleteDataSource()
|
||||||
|
|
||||||
const [editing, setEditing] = useState<DataSource | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [deleting, setDeleting] = useState<number | null>(null)
|
const [editingData, setEditingData] = useState<DataSource | null>(null)
|
||||||
|
const [newSource, setNewSource] = useState<DataSource | null>(null)
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
|
||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
const nextId =
|
const nextId = sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
|
||||||
sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
|
setNewSource({ ...EMPTY, id: nextId })
|
||||||
setEditing({ ...EMPTY, id: nextId })
|
setEditingId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(ds: DataSource) {
|
function startEdit(ds: DataSource) {
|
||||||
setEditing({ ...ds })
|
setEditingId(ds.id)
|
||||||
|
setEditingData({ ...ds })
|
||||||
|
setNewSource(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
function cancelEdit() {
|
||||||
if (!editing) return
|
setEditingId(null)
|
||||||
const isNew = !sources.some((s) => s.id === editing.id)
|
setEditingData(null)
|
||||||
|
setNewSource(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExisting() {
|
||||||
|
if (!editingData) return
|
||||||
try {
|
try {
|
||||||
if (isNew) {
|
await update.mutateAsync(editingData)
|
||||||
await create.mutateAsync(editing)
|
|
||||||
toast.success("Data source created")
|
|
||||||
} else {
|
|
||||||
await update.mutateAsync(editing)
|
|
||||||
toast.success("Data source updated")
|
toast.success("Data source updated")
|
||||||
}
|
setEditingId(null)
|
||||||
setEditing(null)
|
setEditingData(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function saveNew() {
|
||||||
if (deleting == null) return
|
if (!newSource) return
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync(deleting)
|
await create.mutateAsync(newSource)
|
||||||
|
toast.success("Data source created")
|
||||||
|
setNewSource(null)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete(id: number) {
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(id)
|
||||||
toast.success("Data source deleted")
|
toast.success("Data source deleted")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
}
|
}
|
||||||
setDeleting(null)
|
setConfirmingDelete(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) return <div className="text-muted-foreground p-4">Loading…</div>
|
if (isLoading) return <div className="text-muted-foreground p-4">Loading...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">Data Sources</h1>
|
||||||
Data Sources
|
<p className="text-muted-foreground text-sm">Configure data feeds</p>
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Configure external data feeds
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openNew}>
|
<Button onClick={openNew} disabled={!!newSource}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Source
|
Add Source
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sources.length === 0 ? (
|
<div className="grid gap-3">
|
||||||
<Card>
|
{newSource && (
|
||||||
<CardContent className="py-12 text-center">
|
<Card className="border-primary">
|
||||||
<p className="text-muted-foreground">
|
<CardHeader className="py-3">
|
||||||
No data sources configured yet.
|
<CardTitle className="text-base">New Data Source</CardTitle>
|
||||||
</p>
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<DataSourceForm value={newSource} onChange={setNewSource} />
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
|
||||||
|
<Button size="sm" onClick={saveNew} disabled={!isValidSave(newSource)}>Save</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
)}
|
||||||
<div className="grid gap-3">
|
|
||||||
{sources.map((ds) => (
|
{sources.length === 0 && !newSource && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground">No data sources configured yet.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sources.map((ds) => {
|
||||||
|
const isEditing = editingId === ds.id
|
||||||
|
const isDeleting = confirmingDelete === ds.id
|
||||||
|
|
||||||
|
return (
|
||||||
<Card key={ds.id}>
|
<Card key={ds.id}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -155,91 +207,43 @@ export function DataSourcesPage() {
|
|||||||
<Badge variant="secondary">{ds.source_type}</Badge>
|
<Badge variant="secondary">{ds.source_type}</Badge>
|
||||||
{ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>}
|
{ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>}
|
||||||
{ds.config.type === "external" && ds.config.url && (
|
{ds.config.type === "external" && ds.config.url && (
|
||||||
<span className="text-muted-foreground max-w-xs truncate text-xs">
|
<span className="text-muted-foreground max-w-xs truncate text-xs">{ds.config.url}</span>
|
||||||
{ds.config.url}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(ds)}>
|
||||||
variant="ghost"
|
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
||||||
size="icon"
|
|
||||||
onClick={() => openEdit(ds)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : ds.id)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setDeleting(ds.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
|
||||||
))}
|
{isDeleting && (
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex gap-2 items-center p-2 bg-destructive/10 rounded">
|
||||||
|
<span className="text-sm flex-1">Delete? Widgets referencing it will lose their feed.</span>
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => confirmDelete(ds.id)}>Delete</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setConfirmingDelete(null)}>Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit / Create Dialog */}
|
{isEditing && editingData && (
|
||||||
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
|
<CardContent className="space-y-4 pt-0">
|
||||||
<DialogContent>
|
<DataSourceForm value={editingData} onChange={setEditingData} />
|
||||||
<DialogHeader>
|
<div className="flex gap-2 justify-end">
|
||||||
<DialogTitle>
|
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
|
||||||
{editing && sources.some((s) => s.id === editing.id)
|
<Button size="sm" onClick={saveExisting} disabled={!isValidSave(editingData)}>Save</Button>
|
||||||
? "Edit Data Source"
|
</div>
|
||||||
: "New Data Source"}
|
</CardContent>
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{editing && (
|
|
||||||
<DataSourceForm value={editing} onChange={setEditing} />
|
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
</Card>
|
||||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
)
|
||||||
Cancel
|
})}
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
|
||||||
onClick={save}
|
|
||||||
disabled={
|
|
||||||
!editing?.name ||
|
|
||||||
(EXTERNAL_TYPES.includes(editing.source_type) &&
|
|
||||||
editing.source_type !== "webhook" &&
|
|
||||||
editing.poll_interval_secs <= 0) ||
|
|
||||||
(EXTERNAL_TYPES.includes(editing.source_type) &&
|
|
||||||
editing.source_type !== "webhook" &&
|
|
||||||
editing.config.type === "external" &&
|
|
||||||
!editing.config.url)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
|
||||||
<AlertDialog
|
|
||||||
open={deleting != null}
|
|
||||||
onOpenChange={(o) => !o && setDeleting(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete data source?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will permanently remove this data source. Widgets referencing
|
|
||||||
it will lose their feed.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={confirmDelete}>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -251,11 +255,7 @@ function isSensitiveKey(key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HeaderRow({
|
function HeaderRow({
|
||||||
headerKey,
|
headerKey, headerValue, onChangeKey, onChangeValue, onRemove,
|
||||||
headerValue,
|
|
||||||
onChangeKey,
|
|
||||||
onChangeValue,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
}: {
|
||||||
headerKey: string
|
headerKey: string
|
||||||
headerValue: string
|
headerValue: string
|
||||||
@@ -268,12 +268,7 @@ function HeaderRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input value={headerKey} onChange={(e) => onChangeKey(e.target.value)} placeholder="key" className="flex-1" />
|
||||||
value={headerKey}
|
|
||||||
onChange={(e) => onChangeKey(e.target.value)}
|
|
||||||
placeholder="key"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
type={sensitive && !visible ? "password" : "text"}
|
type={sensitive && !visible ? "password" : "text"}
|
||||||
@@ -283,17 +278,8 @@ function HeaderRow({
|
|||||||
className={sensitive ? "pr-9" : ""}
|
className={sensitive ? "pr-9" : ""}
|
||||||
/>
|
/>
|
||||||
{sensitive && (
|
{sensitive && (
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="absolute top-0 right-0 h-full w-9" onClick={() => setVisible((v) => !v)}>
|
||||||
variant="ghost"
|
{visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||||
size="icon"
|
|
||||||
className="absolute top-0 right-0 h-full w-9"
|
|
||||||
onClick={() => setVisible((v) => !v)}
|
|
||||||
>
|
|
||||||
{visible ? (
|
|
||||||
<EyeOff className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -304,13 +290,28 @@ function HeaderRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataSourceForm({
|
function ClockPreview({ format, timezone }: { format: string; timezone: string }) {
|
||||||
value,
|
const [preview, setPreview] = useState("")
|
||||||
onChange,
|
|
||||||
}: {
|
useEffect(() => {
|
||||||
value: DataSource
|
setPreview(formatClockPreview(format, timezone))
|
||||||
onChange: (ds: DataSource) => void
|
const id = setInterval(() => setPreview(formatClockPreview(format, timezone)), 1000)
|
||||||
}) {
|
return () => clearInterval(id)
|
||||||
|
}, [format, timezone])
|
||||||
|
|
||||||
|
const validTz = VALID_TIMEZONES.has(timezone)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-mono">{preview}</p>
|
||||||
|
{timezone && !validTz && (
|
||||||
|
<p className="text-destructive text-xs">Unknown timezone</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataSourceForm({ value, onChange }: { value: DataSource; onChange: (ds: DataSource) => void }) {
|
||||||
const set = <K extends keyof DataSource>(k: K, v: DataSource[K]) =>
|
const set = <K extends keyof DataSource>(k: K, v: DataSource[K]) =>
|
||||||
onChange({ ...value, [k]: v })
|
onChange({ ...value, [k]: v })
|
||||||
|
|
||||||
@@ -326,29 +327,18 @@ function DataSourceForm({
|
|||||||
const isStaticText = value.config.type === "static_text"
|
const isStaticText = value.config.type === "static_text"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 py-2">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Name</Label>
|
<Label>Name</Label>
|
||||||
<Input
|
<Input value={value.name} onChange={(e) => set("name", e.target.value)} placeholder="e.g. weather" />
|
||||||
value={value.name}
|
|
||||||
onChange={(e) => set("name", e.target.value)}
|
|
||||||
placeholder="e.g. weather"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Source Type</Label>
|
<Label>Source Type</Label>
|
||||||
<Select
|
<Select value={value.source_type} onValueChange={(v) => onSourceTypeChange(v as SourceType)}>
|
||||||
value={value.source_type}
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
onValueChange={(v) => onSourceTypeChange(v as SourceType)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SOURCE_TYPES.map((t) => (
|
{SOURCE_TYPES.map((t) => (
|
||||||
<SelectItem key={t} value={t}>
|
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||||
{t}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -358,20 +348,11 @@ function DataSourceForm({
|
|||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>URL</Label>
|
<Label>URL</Label>
|
||||||
<Input
|
<Input value={value.config.url ?? ""} onChange={(e) => setConfig({ url: e.target.value || null })} placeholder="https://..." />
|
||||||
value={value.config.url ?? ""}
|
|
||||||
onChange={(e) => setConfig({ url: e.target.value || null })}
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>API Key</Label>
|
<Label>API Key</Label>
|
||||||
<Input
|
<Input type="password" value={value.config.api_key ?? ""} onChange={(e) => setConfig({ api_key: e.target.value || null })} placeholder="Optional" />
|
||||||
type="password"
|
|
||||||
value={value.config.api_key ?? ""}
|
|
||||||
onChange={(e) => setConfig({ api_key: e.target.value || null })}
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -380,55 +361,33 @@ function DataSourceForm({
|
|||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Format</Label>
|
<Label>Format</Label>
|
||||||
<Input
|
<Input value={value.config.format} onChange={(e) => setConfig({ format: e.target.value })} placeholder="%H:%M:%S" />
|
||||||
value={value.config.format}
|
|
||||||
onChange={(e) => setConfig({ format: e.target.value })}
|
|
||||||
placeholder="%H:%M:%S"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Timezone</Label>
|
<Label>Timezone</Label>
|
||||||
<Input
|
<Input value={value.config.timezone} onChange={(e) => setConfig({ timezone: e.target.value })} placeholder="Europe/Warsaw" />
|
||||||
value={value.config.timezone}
|
|
||||||
onChange={(e) => setConfig({ timezone: e.target.value })}
|
|
||||||
placeholder="Europe/Warsaw"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ClockPreview format={value.config.format} timezone={value.config.timezone} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isStaticText && (
|
{isStaticText && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Text</Label>
|
<Label>Text</Label>
|
||||||
<Input
|
<Input value={value.config.text} onChange={(e) => setConfig({ text: e.target.value })} placeholder="Hello world" />
|
||||||
value={value.config.text}
|
|
||||||
onChange={(e) => setConfig({ text: e.target.value })}
|
|
||||||
placeholder="Hello world"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Poll Interval (seconds)</Label>
|
<Label>Poll Interval (seconds)</Label>
|
||||||
<Input
|
<Input type="number" value={value.poll_interval_secs} onChange={(e) => set("poll_interval_secs", Number(e.target.value))} min={1} />
|
||||||
type="number"
|
|
||||||
value={value.poll_interval_secs}
|
|
||||||
onChange={(e) => set("poll_interval_secs", Number(e.target.value))}
|
|
||||||
min={1}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExternal && (
|
{isExternal && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Headers</Label>
|
<Label>Headers</Label>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => setConfig({ headers: [...value.config.headers, ["", ""]] })}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setConfig({ headers: [...value.config.headers, ["", ""]] })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -448,9 +407,7 @@ function DataSourceForm({
|
|||||||
next[i] = [k, newVal]
|
next[i] = [k, newVal]
|
||||||
setConfig({ headers: next })
|
setConfig({ headers: next })
|
||||||
}}
|
}}
|
||||||
onRemove={() =>
|
onRemove={() => setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })}
|
||||||
setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,16 +18,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -287,7 +277,15 @@ export function LayoutBuilderPage() {
|
|||||||
onAddContainer={(path, dir) =>
|
onAddContainer={(path, dir) =>
|
||||||
addChild(path, makeContainerChild(dir))
|
addChild(path, makeContainerChild(dir))
|
||||||
}
|
}
|
||||||
onRemove={() => setPendingDelete(selected)}
|
pendingDelete={pendingDelete}
|
||||||
|
onRequestDelete={() => setPendingDelete(selected)}
|
||||||
|
onConfirmDelete={() => {
|
||||||
|
if (pendingDelete !== null) {
|
||||||
|
removeChild(pendingDelete)
|
||||||
|
setPendingDelete(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancelDelete={() => setPendingDelete(null)}
|
||||||
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
||||||
isRoot={selected.length === 0}
|
isRoot={selected.length === 0}
|
||||||
widgets={widgets}
|
widgets={widgets}
|
||||||
@@ -296,7 +294,15 @@ export function LayoutBuilderPage() {
|
|||||||
<LeafProps
|
<LeafProps
|
||||||
path={selected}
|
path={selected}
|
||||||
widgetId={selectedNode?.widget_id ?? 0}
|
widgetId={selectedNode?.widget_id ?? 0}
|
||||||
onRemove={() => setPendingDelete(selected)}
|
pendingDelete={pendingDelete}
|
||||||
|
onRequestDelete={() => setPendingDelete(selected)}
|
||||||
|
onConfirmDelete={() => {
|
||||||
|
if (pendingDelete !== null) {
|
||||||
|
removeChild(pendingDelete)
|
||||||
|
setPendingDelete(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancelDelete={() => setPendingDelete(null)}
|
||||||
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
||||||
widgets={widgets}
|
widgets={widgets}
|
||||||
sizing={
|
sizing={
|
||||||
@@ -317,39 +323,6 @@ export function LayoutBuilderPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={pendingDelete !== null}
|
|
||||||
onOpenChange={(o) => !o && setPendingDelete(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{pendingDelete?.length === 0
|
|
||||||
? "Clear entire layout?"
|
|
||||||
: "Remove this node?"}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{pendingDelete?.length === 0
|
|
||||||
? "This will remove the entire layout tree. You can rebuild it afterward."
|
|
||||||
: "This will remove the selected node and all its children."}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => {
|
|
||||||
if (pendingDelete !== null) {
|
|
||||||
removeChild(pendingDelete)
|
|
||||||
setPendingDelete(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pendingDelete?.length === 0 ? "Clear" : "Remove"}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
{showPreview && root && theme && (
|
{showPreview && root && theme && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -493,7 +466,10 @@ function ContainerProps({
|
|||||||
onUpdateProp,
|
onUpdateProp,
|
||||||
onAddWidget,
|
onAddWidget,
|
||||||
onAddContainer,
|
onAddContainer,
|
||||||
onRemove,
|
pendingDelete,
|
||||||
|
onRequestDelete,
|
||||||
|
onConfirmDelete,
|
||||||
|
onCancelDelete,
|
||||||
onUpdateSizing,
|
onUpdateSizing,
|
||||||
isRoot,
|
isRoot,
|
||||||
widgets,
|
widgets,
|
||||||
@@ -503,7 +479,10 @@ function ContainerProps({
|
|||||||
onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void
|
onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void
|
||||||
onAddWidget: (path: Path, widgetId: number) => void
|
onAddWidget: (path: Path, widgetId: number) => void
|
||||||
onAddContainer: (path: Path, direction: Direction) => void
|
onAddContainer: (path: Path, direction: Direction) => void
|
||||||
onRemove: () => void
|
pendingDelete: Path | null
|
||||||
|
onRequestDelete: () => void
|
||||||
|
onConfirmDelete: () => void
|
||||||
|
onCancelDelete: () => void
|
||||||
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
|
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
|
||||||
isRoot: boolean
|
isRoot: boolean
|
||||||
widgets: { id: number; name: string }[]
|
widgets: { id: number; name: string }[]
|
||||||
@@ -622,14 +601,37 @@ function ContainerProps({
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{pendingDelete !== null &&
|
||||||
|
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{isRoot
|
||||||
|
? "Clear entire layout? You can rebuild afterward."
|
||||||
|
: "Remove this node and all its children?"}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onRemove}
|
onClick={onConfirmDelete}
|
||||||
|
>
|
||||||
|
{isRoot ? "Clear" : "Remove"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onCancelDelete}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRequestDelete}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-1 h-3 w-3" />
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
{isRoot ? "Clear Layout" : "Remove"}
|
{isRoot ? "Clear Layout" : "Remove"}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -637,14 +639,20 @@ function ContainerProps({
|
|||||||
function LeafProps({
|
function LeafProps({
|
||||||
path,
|
path,
|
||||||
widgetId,
|
widgetId,
|
||||||
onRemove,
|
pendingDelete,
|
||||||
|
onRequestDelete,
|
||||||
|
onConfirmDelete,
|
||||||
|
onCancelDelete,
|
||||||
onUpdateSizing,
|
onUpdateSizing,
|
||||||
widgets,
|
widgets,
|
||||||
sizing,
|
sizing,
|
||||||
}: {
|
}: {
|
||||||
path: Path
|
path: Path
|
||||||
widgetId: number
|
widgetId: number
|
||||||
onRemove: () => void
|
pendingDelete: Path | null
|
||||||
|
onRequestDelete: () => void
|
||||||
|
onConfirmDelete: () => void
|
||||||
|
onCancelDelete: () => void
|
||||||
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
|
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
|
||||||
widgets: { id: number; name: string }[]
|
widgets: { id: number; name: string }[]
|
||||||
sizing?: LayoutChild["sizing"]
|
sizing?: LayoutChild["sizing"]
|
||||||
@@ -657,10 +665,31 @@ function LeafProps({
|
|||||||
<p className="text-sm">{w?.name ?? `#${widgetId}`}</p>
|
<p className="text-sm">{w?.name ?? `#${widgetId}`}</p>
|
||||||
</div>
|
</div>
|
||||||
{sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />}
|
{sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />}
|
||||||
<Button variant="destructive" size="sm" onClick={onRemove}>
|
{pendingDelete !== null &&
|
||||||
|
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Remove this widget?
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={onConfirmDelete}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onCancelDelete}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="destructive" size="sm" onClick={onRequestDelete}>
|
||||||
<Trash2 className="mr-1 h-3 w-3" />
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,26 +15,8 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Save, Upload, Trash2, ChevronUp } from "lucide-react"
|
||||||
import { Save, Upload, Trash2 } from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export function PresetsPage() {
|
export function PresetsPage() {
|
||||||
@@ -45,22 +27,22 @@ export function PresetsPage() {
|
|||||||
const loadPreset = useLoadPreset()
|
const loadPreset = useLoadPreset()
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saveName, setSaveName] = useState("")
|
const [presetName, setPresetName] = useState("")
|
||||||
const [deleting, setDeleting] = useState<number | null>(null)
|
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
|
||||||
|
|
||||||
async function saveAsPreset() {
|
async function saveAsPreset() {
|
||||||
if (!layout || !saveName) return
|
if (!layout || !presetName) return
|
||||||
const nextId =
|
const nextId =
|
||||||
presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1
|
presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1
|
||||||
try {
|
try {
|
||||||
await createPreset.mutateAsync({
|
await createPreset.mutateAsync({
|
||||||
id: nextId,
|
id: nextId,
|
||||||
name: saveName,
|
name: presetName,
|
||||||
layout,
|
layout,
|
||||||
})
|
})
|
||||||
toast.success("Preset saved")
|
toast.success("Preset saved")
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
setSaveName("")
|
setPresetName("")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
}
|
}
|
||||||
@@ -75,15 +57,14 @@ export function PresetsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function confirmDelete(id: number) {
|
||||||
if (deleting == null) return
|
|
||||||
try {
|
try {
|
||||||
await deletePreset.mutateAsync(deleting)
|
await deletePreset.mutateAsync(id)
|
||||||
toast.success("Preset deleted")
|
toast.success("Preset deleted")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
}
|
}
|
||||||
setDeleting(null)
|
setConfirmingDelete(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeCount(node: Preset["layout"]["root"]): number {
|
function nodeCount(node: Preset["layout"]["root"]): number {
|
||||||
@@ -104,12 +85,50 @@ export function PresetsPage() {
|
|||||||
Save and restore layout configurations
|
Save and restore layout configurations
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setSaving(true)} disabled={!layout}>
|
<Button
|
||||||
|
onClick={() => setSaving((v) => !v)}
|
||||||
|
disabled={!layout}
|
||||||
|
variant={saving ? "secondary" : "default"}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<ChevronUp className="mr-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
Save Current Layout
|
)}
|
||||||
|
{saving ? "Close" : "Save Current Layout"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{saving && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<CardTitle className="text-base">Save Current Layout as Preset</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center gap-2 pb-3">
|
||||||
|
<Input
|
||||||
|
value={presetName}
|
||||||
|
onChange={(e) => setPresetName(e.target.value)}
|
||||||
|
placeholder="e.g. dashboard"
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && saveAsPreset()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button onClick={saveAsPreset} disabled={!presetName} size="sm">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSaving(false)
|
||||||
|
setPresetName("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{presets.length === 0 ? (
|
{presets.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center">
|
<CardContent className="py-12 text-center">
|
||||||
@@ -128,6 +147,28 @@ export function PresetsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
{confirmingDelete === p.id ? (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-muted-foreground self-center mr-1">
|
||||||
|
Delete?
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => confirmDelete(p.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfirmingDelete(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => load(p.id)}>
|
<Button variant="outline" size="sm" onClick={() => load(p.id)}>
|
||||||
<Upload className="mr-1 h-3 w-3" />
|
<Upload className="mr-1 h-3 w-3" />
|
||||||
Load
|
Load
|
||||||
@@ -135,62 +176,18 @@ export function PresetsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setDeleting(p.id)}
|
onClick={() => setConfirmingDelete(p.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={saving} onOpenChange={(o) => !o && setSaving(false)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Save Current Layout as Preset</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4 py-2">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>Preset Name</Label>
|
|
||||||
<Input
|
|
||||||
value={saveName}
|
|
||||||
onChange={(e) => setSaveName(e.target.value)}
|
|
||||||
placeholder="e.g. dashboard"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setSaving(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={saveAsPreset} disabled={!saveName}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={deleting != null}
|
|
||||||
onOpenChange={(o) => !o && setDeleting(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete preset?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will permanently remove this preset.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={confirmDelete}>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
import {
|
||||||
@@ -32,18 +25,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Plus, Pencil, Trash2, X, Eye } from "lucide-react"
|
import { Plus, Pencil, Trash2, X, Eye, ChevronUp } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
const DISPLAY_HINT_KINDS: DisplayHintKind[] = ["icon_value", "text_block", "key_value"]
|
const DISPLAY_HINT_KINDS: DisplayHintKind[] = ["icon_value", "text_block", "key_value"]
|
||||||
@@ -64,52 +47,72 @@ export function WidgetsPage() {
|
|||||||
const update = useUpdateWidget()
|
const update = useUpdateWidget()
|
||||||
const del = useDeleteWidget()
|
const del = useDeleteWidget()
|
||||||
|
|
||||||
const [editing, setEditing] = useState<Widget | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [deleting, setDeleting] = useState<number | null>(null)
|
const [editingData, setEditingData] = useState<Widget | null>(null)
|
||||||
const [previewing, setPreviewing] = useState<number | null>(null)
|
const [newWidget, setNewWidget] = useState<Widget | null>(null)
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
|
||||||
|
const [previewingId, setPreviewingId] = useState<number | null>(null)
|
||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
const nextId =
|
const nextId =
|
||||||
widgets.length > 0 ? Math.max(...widgets.map((w) => w.id)) + 1 : 1
|
widgets.length > 0 ? Math.max(...widgets.map((w) => w.id)) + 1 : 1
|
||||||
setEditing({
|
setNewWidget({ ...EMPTY, id: nextId, data_source_id: sources[0]?.id ?? 0 })
|
||||||
...EMPTY,
|
setEditingId(null)
|
||||||
id: nextId,
|
|
||||||
data_source_id: sources[0]?.id ?? 0,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
function startEdit(w: Widget) {
|
||||||
if (!editing) return
|
setEditingId(w.id)
|
||||||
const isNew = !widgets.some((w) => w.id === editing.id)
|
setEditingData({ ...w })
|
||||||
try {
|
setNewWidget(null)
|
||||||
if (isNew) {
|
|
||||||
await create.mutateAsync(editing)
|
|
||||||
toast.success("Widget created")
|
|
||||||
} else {
|
|
||||||
await update.mutateAsync(editing)
|
|
||||||
toast.success("Widget updated")
|
|
||||||
}
|
}
|
||||||
setEditing(null)
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingId(null)
|
||||||
|
setEditingData(null)
|
||||||
|
setNewWidget(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExisting() {
|
||||||
|
if (!editingData) return
|
||||||
|
try {
|
||||||
|
await update.mutateAsync(editingData)
|
||||||
|
toast.success("Widget updated")
|
||||||
|
setEditingId(null)
|
||||||
|
setEditingData(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function saveNew() {
|
||||||
if (deleting == null) return
|
if (!newWidget) return
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync(deleting)
|
await create.mutateAsync(newWidget)
|
||||||
|
toast.success("Widget created")
|
||||||
|
setNewWidget(null)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete(id: number) {
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(id)
|
||||||
toast.success("Widget deleted")
|
toast.success("Widget deleted")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
}
|
}
|
||||||
setDeleting(null)
|
setConfirmingDelete(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePreview(id: number) {
|
||||||
|
setPreviewingId(previewingId === id ? null : id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceName = (id: number) =>
|
const sourceName = (id: number) =>
|
||||||
sources.find((s) => s.id === id)?.name ?? `#${id}`
|
sources.find((s) => s.id === id)?.name ?? `#${id}`
|
||||||
|
|
||||||
if (isLoading) return <div className="text-muted-foreground p-4">Loading…</div>
|
if (isLoading) return <div className="text-muted-foreground p-4">Loading...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -120,21 +123,42 @@ export function WidgetsPage() {
|
|||||||
Display primitives bound to data sources
|
Display primitives bound to data sources
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openNew}>
|
<Button onClick={openNew} disabled={!!newWidget}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Widget
|
Add Widget
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{widgets.length === 0 ? (
|
<div className="grid gap-3">
|
||||||
|
{newWidget && (
|
||||||
|
<Card className="border-primary">
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<CardTitle className="text-base">New Widget</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<WidgetForm value={newWidget} onChange={setNewWidget} sources={sources} />
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
|
||||||
|
<Button size="sm" onClick={saveNew} disabled={!newWidget.name || !newWidget.data_source_id}>Save</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{widgets.length === 0 && !newWidget && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center">
|
<CardContent className="py-12 text-center">
|
||||||
<p className="text-muted-foreground">No widgets configured yet.</p>
|
<p className="text-muted-foreground">No widgets configured yet.</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
)}
|
||||||
<div className="grid gap-3">
|
|
||||||
{widgets.map((w) => (
|
{widgets.map((w) => {
|
||||||
|
const isEditing = editingId === w.id
|
||||||
|
const isDeleting = confirmingDelete === w.id
|
||||||
|
const isPreviewing = previewingId === w.id
|
||||||
|
|
||||||
|
return (
|
||||||
<Card key={w.id}>
|
<Card key={w.id}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -142,143 +166,65 @@ export function WidgetsPage() {
|
|||||||
<CardDescription className="flex items-center gap-2">
|
<CardDescription className="flex items-center gap-2">
|
||||||
<Badge variant="secondary">{w.display_hint.kind}</Badge>
|
<Badge variant="secondary">{w.display_hint.kind}</Badge>
|
||||||
<span>source: {sourceName(w.data_source_id)}</span>
|
<span>source: {sourceName(w.data_source_id)}</span>
|
||||||
<span>{w.mappings.length} mapping(s)</span>
|
{w.mappings.length > 0 && <span>{w.mappings.length} mapping(s)</span>}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => togglePreview(w.id)} title="Preview data">
|
||||||
variant="ghost"
|
{isPreviewing ? <ChevronUp className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
size="icon"
|
|
||||||
onClick={() => setPreviewing(w.id)}
|
|
||||||
title="Preview current data"
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(w)}>
|
||||||
variant="ghost"
|
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
||||||
size="icon"
|
|
||||||
onClick={() => setEditing({ ...w })}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : w.id)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setDeleting(w.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
|
||||||
))}
|
{isDeleting && (
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex gap-2 items-center p-2 bg-destructive/10 rounded">
|
||||||
|
<span className="text-sm flex-1">Delete this widget? Layout references will become dangling.</span>
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => confirmDelete(w.id)}>Delete</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setConfirmingDelete(null)}>Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
|
{isPreviewing && !isEditing && (
|
||||||
<DialogContent className="max-w-lg">
|
<CardContent className="pt-0">
|
||||||
<DialogHeader>
|
<WidgetPreviewInline widgetId={w.id} />
|
||||||
<DialogTitle>
|
</CardContent>
|
||||||
{editing && widgets.some((w) => w.id === editing.id)
|
|
||||||
? "Edit Widget"
|
|
||||||
: "New Widget"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{editing && (
|
|
||||||
<WidgetForm
|
|
||||||
value={editing}
|
|
||||||
onChange={setEditing}
|
|
||||||
sources={sources}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={save}
|
|
||||||
disabled={
|
|
||||||
!editing?.name ||
|
|
||||||
!editing.data_source_id
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AlertDialog
|
{isEditing && editingData && (
|
||||||
open={deleting != null}
|
<CardContent className="space-y-4 pt-0">
|
||||||
onOpenChange={(o) => !o && setDeleting(null)}
|
<WidgetForm value={editingData} onChange={setEditingData} sources={sources} />
|
||||||
>
|
<div className="flex gap-2 justify-end">
|
||||||
<AlertDialogContent>
|
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
|
||||||
<AlertDialogHeader>
|
<Button size="sm" onClick={saveExisting} disabled={!editingData.name || !editingData.data_source_id}>Save</Button>
|
||||||
<AlertDialogTitle>Delete widget?</AlertDialogTitle>
|
</div>
|
||||||
<AlertDialogDescription>
|
</CardContent>
|
||||||
This will permanently remove this widget. Layout references will
|
|
||||||
become dangling.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={confirmDelete}>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
{previewing != null && (
|
|
||||||
<WidgetPreviewDialog
|
|
||||||
widgetId={previewing}
|
|
||||||
widgetName={widgets.find((w) => w.id === previewing)?.name ?? ""}
|
|
||||||
onClose={() => setPreviewing(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WidgetPreviewDialog({
|
function WidgetPreviewInline({ widgetId }: { widgetId: number }) {
|
||||||
widgetId,
|
|
||||||
widgetName,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
widgetId: number
|
|
||||||
widgetName: string
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const { data, isLoading, isError } = useWidgetPreview(widgetId, true)
|
const { data, isLoading, isError } = useWidgetPreview(widgetId, true)
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-muted-foreground text-sm">Loading...</p>
|
||||||
|
if (isError) return <p className="text-muted-foreground text-sm">No data yet</p>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Preview: {widgetName}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="py-2">
|
|
||||||
{isLoading && (
|
|
||||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
|
||||||
)}
|
|
||||||
{isError && (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
No data yet — widget hasn't been polled
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{data && (
|
|
||||||
<pre className="bg-muted rounded-md p-3 text-xs">
|
<pre className="bg-muted rounded-md p-3 text-xs">
|
||||||
{JSON.stringify(data, null, 2)}
|
{JSON.stringify(data, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +258,7 @@ function WidgetForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 py-2">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Name</Label>
|
<Label>Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -327,14 +273,10 @@ function WidgetForm({
|
|||||||
value={value.display_hint.kind}
|
value={value.display_hint.kind}
|
||||||
onValueChange={(v) => set("display_hint", { ...value.display_hint, kind: v as DisplayHintKind })}
|
onValueChange={(v) => set("display_hint", { ...value.display_hint, kind: v as DisplayHintKind })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{DISPLAY_HINT_KINDS.map((h) => (
|
{DISPLAY_HINT_KINDS.map((h) => (
|
||||||
<SelectItem key={h} value={h}>
|
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||||
{h}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -346,9 +288,7 @@ function WidgetForm({
|
|||||||
value={value.display_hint.h_align}
|
value={value.display_hint.h_align}
|
||||||
onValueChange={(v) => set("display_hint", { ...value.display_hint, h_align: v as HAlign })}
|
onValueChange={(v) => set("display_hint", { ...value.display_hint, h_align: v as HAlign })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="left">Left</SelectItem>
|
<SelectItem value="left">Left</SelectItem>
|
||||||
<SelectItem value="center">Center</SelectItem>
|
<SelectItem value="center">Center</SelectItem>
|
||||||
@@ -362,9 +302,7 @@ function WidgetForm({
|
|||||||
value={value.display_hint.v_align}
|
value={value.display_hint.v_align}
|
||||||
onValueChange={(v) => set("display_hint", { ...value.display_hint, v_align: v as VAlign })}
|
onValueChange={(v) => set("display_hint", { ...value.display_hint, v_align: v as VAlign })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="top">Top</SelectItem>
|
<SelectItem value="top">Top</SelectItem>
|
||||||
<SelectItem value="middle">Middle</SelectItem>
|
<SelectItem value="middle">Middle</SelectItem>
|
||||||
@@ -379,14 +317,10 @@ function WidgetForm({
|
|||||||
value={String(value.data_source_id)}
|
value={String(value.data_source_id)}
|
||||||
onValueChange={(v) => set("data_source_id", Number(v))}
|
onValueChange={(v) => set("data_source_id", Number(v))}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{sources.map((s) => (
|
{sources.map((s) => (
|
||||||
<SelectItem key={s.id} value={String(s.id)}>
|
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||||
{s.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -412,26 +346,18 @@ function WidgetForm({
|
|||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={m.source_path}
|
value={m.source_path}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateMapping(i, { ...m, source_path: e.target.value })}
|
||||||
updateMapping(i, { ...m, source_path: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="$.path.to.value"
|
placeholder="$.path.to.value"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-sm">→</span>
|
<span className="text-muted-foreground text-sm">→</span>
|
||||||
<Input
|
<Input
|
||||||
value={m.target_key}
|
value={m.target_key}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateMapping(i, { ...m, target_key: e.target.value })}
|
||||||
updateMapping(i, { ...m, target_key: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="target_key"
|
placeholder="target_key"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => removeMapping(i)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => removeMapping(i)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user