arch: split ConfigRepository, extract polling, consolidate conversions, decouple protocol
- Value↔JSON: From impls on domain Value behind `json` feature, delete 4 duplicate converters - ConfigRepository split into ConfigRepository (12), UserRepository (3), WidgetStateCache (2) - polling orchestration moved from bootstrap to application::polling_service - WidgetRenderer in client-domain owns scroll/cache, both clients use it - network loop consolidated into client-application::run_connection_loop - protocol crate drops domain dep, Wire↔Domain conversions move to adapters
This commit is contained in:
@@ -5,7 +5,7 @@ mod polling;
|
||||
use anyhow::Result;
|
||||
use application::DataProjection;
|
||||
use config_sqlite::SqliteConfigStore;
|
||||
use domain::ConfigRepository;
|
||||
use domain::WidgetStateCache;
|
||||
use http_api::AppState;
|
||||
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
|
||||
use secret_store::AesSecretStore;
|
||||
|
||||
@@ -2,21 +2,12 @@ use anyhow::Result;
|
||||
use application::DataProjection;
|
||||
use config_sqlite::SqliteConfigStore;
|
||||
use data_generators::{ClockGenerator, StaticTextGenerator};
|
||||
use domain::{
|
||||
BroadcastPort, ConfigRepository, DataSource, DataSourcePort, DataSourceType, Value,
|
||||
WidgetError, WidgetState,
|
||||
};
|
||||
use domain::{DataSource, DataSourcePort, DataSourceType, Value};
|
||||
use http_json::HttpJsonAdapter;
|
||||
use media_adapter::MediaAdapter;
|
||||
use rss_adapter::RssAdapter;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
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 {
|
||||
@@ -76,219 +67,12 @@ pub async fn run(
|
||||
static_text: Arc::new(StaticTextGenerator::new()),
|
||||
};
|
||||
|
||||
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
|
||||
let mut static_done: std::collections::HashSet<u16> = std::collections::HashSet::new();
|
||||
let poller = Arc::new(move |source: &DataSource| {
|
||||
let adapters = adapters.clone();
|
||||
let source = source.clone();
|
||||
async move { adapters.poll(&source).await }
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
static_done.retain(|id| current_ids.contains(id));
|
||||
|
||||
for source in &sources {
|
||||
if source.source_type == DataSourceType::Webhook {
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let source_id = source.id;
|
||||
let source = source.clone();
|
||||
let config = config.clone();
|
||||
let broadcaster = broadcaster.clone();
|
||||
let projection = projection.clone();
|
||||
let adapters = adapters.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, adapters).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_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(
|
||||
source: DataSource,
|
||||
config: Arc<SqliteConfigStore>,
|
||||
broadcaster: Arc<TcpBroadcaster>,
|
||||
projection: Arc<DataProjection>,
|
||||
adapters: Adapters,
|
||||
) {
|
||||
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 adapters.poll(&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();
|
||||
}
|
||||
|
||||
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
|
||||
.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");
|
||||
}
|
||||
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 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");
|
||||
}
|
||||
application::polling_service::run(config, broadcaster, projection, poller).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user