state recovery, polling optimizations, error rendering
widget states cached to SQLite, loaded on startup to seed DataProjection so server restart preserves last-known data for reconnecting clients. polling: first poll runs immediately, widget list cached per-task with 30s refresh, static text polled once inline instead of looping. poll failures propagate WidgetError::SourceUnavailable to clients. render engine prepends [offline] prefix in accent color, stale data preserved below.
This commit is contained in:
@@ -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, WidgetConfig, WidgetId, WidgetState,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
@@ -203,4 +203,15 @@ impl ConfigRepository for MemoryConfigStore {
|
|||||||
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
Ok(guard.len() as u32)
|
Ok(guard.len() as u32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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![])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, WidgetConfig, WidgetId, WidgetState,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl ConfigRepository for SqliteConfigStore {
|
impl ConfigRepository for SqliteConfigStore {
|
||||||
@@ -90,4 +91,15 @@ impl ConfigRepository for SqliteConfigStore {
|
|||||||
async fn count_users(&self) -> Result<u32, Self::Error> {
|
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||||
self.count_users_impl().await
|
self.count_users_impl().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,99 @@
|
|||||||
|
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_value_to_json(v: &Value) -> serde_json::Value {
|
||||||
|
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(domain_value_to_json).collect())
|
||||||
|
}
|
||||||
|
Value::Object(map) => {
|
||||||
|
let obj: serde_json::Map<String, serde_json::Value> = map
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
|
||||||
|
.collect();
|
||||||
|
serde_json::Value::Object(obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_value_to_domain(v: &serde_json::Value) -> Value {
|
||||||
|
match v {
|
||||||
|
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.clone()),
|
||||||
|
serde_json::Value::Array(arr) => {
|
||||||
|
Value::Array(arr.iter().map(json_value_to_domain).collect())
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(map) => Value::Object(
|
||||||
|
map.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), json_value_to_domain(v)))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(), domain_value_to_json(v)))
|
||||||
|
.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
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), json_value_to_domain(v)))
|
||||||
|
.collect();
|
||||||
|
Ok(WidgetState { data, error: None })
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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, WidgetConfig, WidgetId, WidgetState,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -135,6 +135,17 @@ impl ConfigRepository for InMemoryConfigRepository {
|
|||||||
async fn count_users(&self) -> Result<u32, Self::Error> {
|
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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::ConfigRepository;
|
||||||
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();
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use application::DataProjection;
|
|||||||
use config_sqlite::SqliteConfigStore;
|
use config_sqlite::SqliteConfigStore;
|
||||||
use data_generators::{ClockGenerator, StaticTextGenerator};
|
use data_generators::{ClockGenerator, StaticTextGenerator};
|
||||||
use domain::{
|
use domain::{
|
||||||
BroadcastPort, ConfigRepository, DataSource, DataSourcePort, DataSourceType, Value, WidgetState,
|
BroadcastPort, ConfigRepository, DataSource, DataSourcePort, DataSourceType, Value,
|
||||||
|
WidgetError, WidgetState,
|
||||||
};
|
};
|
||||||
use http_json::HttpJsonAdapter;
|
use http_json::HttpJsonAdapter;
|
||||||
use media_adapter::MediaAdapter;
|
use media_adapter::MediaAdapter;
|
||||||
@@ -76,6 +77,7 @@ pub async fn run(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
|
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
|
||||||
|
let mut static_done: std::collections::HashSet<u16> = std::collections::HashSet::new();
|
||||||
|
|
||||||
info!("polling manager started");
|
info!("polling manager started");
|
||||||
|
|
||||||
@@ -96,11 +98,23 @@ pub async fn run(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
static_done.retain(|id| current_ids.contains(id));
|
||||||
|
|
||||||
for source in &sources {
|
for source in &sources {
|
||||||
if source.source_type == DataSourceType::Webhook {
|
if source.source_type == DataSourceType::Webhook {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static text: poll once inline, never spawn a task
|
||||||
|
if source.source_type == DataSourceType::StaticText {
|
||||||
|
if static_done.contains(&source.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
poll_once(&adapters, source, &config, &broadcaster, &projection).await;
|
||||||
|
static_done.insert(source.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if running.contains_key(&source.id) {
|
if running.contains_key(&source.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -126,7 +140,7 @@ pub async fn run(
|
|||||||
running.insert(source_id, handle);
|
running.insert(source_id, handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
if running.is_empty() {
|
if running.is_empty() && static_done.is_empty() {
|
||||||
debug!("no pollable sources, waiting");
|
debug!("no pollable sources, waiting");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +148,30 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn poll_once(
|
||||||
|
adapters: &Adapters,
|
||||||
|
source: &DataSource,
|
||||||
|
config: &Arc<SqliteConfigStore>,
|
||||||
|
broadcaster: &Arc<TcpBroadcaster>,
|
||||||
|
projection: &Arc<DataProjection>,
|
||||||
|
) {
|
||||||
|
let result = match adapters.poll(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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
broadcast_changes(source, &result, &widgets, broadcaster, projection, config).await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn poll_loop(
|
async fn poll_loop(
|
||||||
source: DataSource,
|
source: DataSource,
|
||||||
config: Arc<SqliteConfigStore>,
|
config: Arc<SqliteConfigStore>,
|
||||||
@@ -142,28 +180,57 @@ async fn poll_loop(
|
|||||||
adapters: Adapters,
|
adapters: Adapters,
|
||||||
) {
|
) {
|
||||||
let interval = source.poll_interval;
|
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 {
|
loop {
|
||||||
tokio::time::sleep(interval).await;
|
|
||||||
|
|
||||||
let result = match adapters.poll(&source).await {
|
let result = match adapters.poll(&source).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(source = %source.name, error = %e, "poll failed");
|
warn!(source = %source.name, error = %e, "poll failed");
|
||||||
|
broadcast_errors(&source, &widgets, &broadcaster, &projection).await;
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let widgets = match config.list_widgets().await {
|
if last_refresh.elapsed() >= SOURCE_REFRESH_INTERVAL {
|
||||||
Ok(w) => w,
|
if let Ok(w) = config.list_widgets().await {
|
||||||
Err(e) => {
|
widgets = w;
|
||||||
warn!(error = %e, "failed to fetch widgets");
|
}
|
||||||
continue;
|
last_refresh = tokio::time::Instant::now();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
broadcast_changes(
|
||||||
|
&source,
|
||||||
|
&result,
|
||||||
|
&widgets,
|
||||||
|
&broadcaster,
|
||||||
|
&projection,
|
||||||
|
&config,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_changes(
|
||||||
|
source: &DataSource,
|
||||||
|
result: &Value,
|
||||||
|
widgets: &[domain::WidgetConfig],
|
||||||
|
broadcaster: &Arc<TcpBroadcaster>,
|
||||||
|
projection: &Arc<DataProjection>,
|
||||||
|
config: &Arc<SqliteConfigStore>,
|
||||||
|
) {
|
||||||
let changed: Vec<(u16, WidgetState)> = projection
|
let changed: Vec<(u16, WidgetState)> = projection
|
||||||
.apply_poll_result(source.id, &result, &widgets)
|
.apply_poll_result(source.id, result, widgets)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if !changed.is_empty() {
|
if !changed.is_empty() {
|
||||||
@@ -177,7 +244,51 @@ async fn poll_loop(
|
|||||||
if let Err(e) = broadcaster.push_data_update(&with_hints).await {
|
if let Err(e) = broadcaster.push_data_update(&with_hints).await {
|
||||||
warn!(error = %e, "failed to push update");
|
warn!(error = %e, "failed to push update");
|
||||||
}
|
}
|
||||||
|
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");
|
info!(source = %source.name, count = changed.len(), "pushed widget updates");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_errors(
|
||||||
|
source: &DataSource,
|
||||||
|
widgets: &[domain::WidgetConfig],
|
||||||
|
broadcaster: &Arc<TcpBroadcaster>,
|
||||||
|
projection: &Arc<DataProjection>,
|
||||||
|
) {
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use client_application::ClientApp;
|
|||||||
use client_domain::NetworkPort;
|
use client_domain::NetworkPort;
|
||||||
use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig};
|
use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig};
|
||||||
use display_terminal::TerminalDisplay;
|
use display_terminal::TerminalDisplay;
|
||||||
use domain::DisplayHint;
|
use domain::{DisplayHint, WidgetError};
|
||||||
use protocol::decode_server_message;
|
use protocol::decode_server_message;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
@@ -85,7 +85,10 @@ fn main() {
|
|||||||
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
|
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let draw_cmds = engine.render_widget(&hint, &data, cmd.bounds, 0);
|
let error: Option<WidgetError> =
|
||||||
|
cmd.state.error.as_ref().map(|e| e.clone().into());
|
||||||
|
let draw_cmds =
|
||||||
|
engine.render_widget(&hint, &data, cmd.bounds, 0, error.as_ref());
|
||||||
for dc in &draw_cmds {
|
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)
|
||||||
|
|||||||
@@ -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,8 +92,12 @@ 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 {
|
||||||
@@ -110,8 +114,17 @@ impl RenderEngine {
|
|||||||
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())
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use client_domain::{
|
|||||||
BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, ScrollState, ThemeConfig,
|
BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, ScrollState, ThemeConfig,
|
||||||
};
|
};
|
||||||
use client_application::{ClientApp, RepaintCommand};
|
use client_application::{ClientApp, RepaintCommand};
|
||||||
use domain::{DisplayHint, Value};
|
use domain::{DisplayHint, Value, WidgetError};
|
||||||
use protocol::ServerMessage;
|
use protocol::ServerMessage;
|
||||||
use super::RenderEvent;
|
use super::RenderEvent;
|
||||||
use crate::config::RENDER_POLL_INTERVAL;
|
use crate::config::RENDER_POLL_INTERVAL;
|
||||||
@@ -21,6 +21,7 @@ const COLOR_DISCONNECTED: Color = Color(200, 0, 0);
|
|||||||
struct WidgetCache {
|
struct WidgetCache {
|
||||||
hint: DisplayHint,
|
hint: DisplayHint,
|
||||||
data: Vec<(String, Value)>,
|
data: Vec<(String, Value)>,
|
||||||
|
error: Option<WidgetError>,
|
||||||
bounds: BoundingBox,
|
bounds: BoundingBox,
|
||||||
scroll: ScrollState,
|
scroll: ScrollState,
|
||||||
}
|
}
|
||||||
@@ -121,13 +122,15 @@ fn update_cache(engine: &RenderEngine, cmd: &RepaintCommand) -> WidgetCache {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
|
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
|
||||||
.collect();
|
.collect();
|
||||||
|
let error: Option<WidgetError> = cmd.state.error.as_ref().map(|e| e.clone().into());
|
||||||
|
|
||||||
let content_h = engine.content_height(&hint, &data, cmd.bounds.width);
|
let content_h = engine.content_height(&hint, &data, cmd.bounds.width, error.as_ref());
|
||||||
let scroll = ScrollState::new(cmd.bounds.height, content_h);
|
let scroll = ScrollState::new(cmd.bounds.height, content_h);
|
||||||
|
|
||||||
WidgetCache {
|
WidgetCache {
|
||||||
hint,
|
hint,
|
||||||
data,
|
data,
|
||||||
|
error,
|
||||||
bounds: cmd.bounds,
|
bounds: cmd.bounds,
|
||||||
scroll,
|
scroll,
|
||||||
}
|
}
|
||||||
@@ -143,6 +146,7 @@ fn draw_widget(
|
|||||||
&cache.data,
|
&cache.data,
|
||||||
cache.bounds,
|
cache.bounds,
|
||||||
cache.scroll.offset(),
|
cache.scroll.offset(),
|
||||||
|
cache.error.as_ref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
for dc in &draw_cmds {
|
for dc in &draw_cmds {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::entities::{
|
use crate::entities::{
|
||||||
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId,
|
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
use crate::value_objects::{Layout, ThemeConfig};
|
use crate::value_objects::{Layout, ThemeConfig, WidgetState};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
|
||||||
pub trait ConfigRepository {
|
pub trait ConfigRepository {
|
||||||
@@ -63,4 +63,12 @@ pub trait ConfigRepository {
|
|||||||
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
|
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
|
||||||
fn save_user(&self, user: &User) -> impl Future<Output = Result<(), 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;
|
fn count_users(&self) -> impl Future<Output = Result<u32, Self::Error>> + Send;
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user