GUI plugins #9
1 changed files with 273 additions and 0 deletions
pinakes-ui: add plugin data fetching
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ie28a544cf0df1a23b905e89b69db19c06a6a6964
commit
e46a8943cb
273
crates/pinakes-ui/src/plugin_ui/data.rs
Normal file
273
crates/pinakes-ui/src/plugin_ui/data.rs
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
//! Data fetching system for plugin UI pages
|
||||||
|
//!
|
||||||
|
//! Provides data fetching and caching for plugin data sources.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use pinakes_plugin_api::{DataSource, HttpMethod};
|
||||||
|
|
||||||
|
use crate::client::ApiClient;
|
||||||
|
|
||||||
|
/// Cached data for a plugin page
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct PluginPageData {
|
||||||
|
data: HashMap<String, serde_json::Value>,
|
||||||
|
loading: HashMap<String, bool>,
|
||||||
|
errors: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginPageData {
|
||||||
|
/// Get data for a specific source
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(&self, source: &str) -> Option<&serde_json::Value> {
|
||||||
|
self.data.get(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a source is currently loading
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_loading(&self, source: &str) -> bool {
|
||||||
|
self.loading.get(source).copied().unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get error for a specific source
|
||||||
|
#[must_use]
|
||||||
|
pub fn error(&self, source: &str) -> Option<&String> {
|
||||||
|
self.errors.get(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if there's data for a specific source
|
||||||
|
#[must_use]
|
||||||
|
pub fn has_data(&self, source: &str) -> bool {
|
||||||
|
self.data.contains_key(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set data for a source
|
||||||
|
pub fn set_data(&mut self, source: String, value: serde_json::Value) {
|
||||||
|
self.data.insert(source, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set loading state for a source
|
||||||
|
pub fn set_loading(&mut self, source: &str, loading: bool) {
|
||||||
|
if loading {
|
||||||
|
self.loading.insert(source.to_string(), true);
|
||||||
|
self.errors.remove(source);
|
||||||
|
} else {
|
||||||
|
self.loading.remove(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set error for a source
|
||||||
|
pub fn set_error(&mut self, source: String, error: String) {
|
||||||
|
self.errors.insert(source, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all data
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.data.clear();
|
||||||
|
self.loading.clear();
|
||||||
|
self.errors.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch data from an endpoint
|
||||||
|
async fn fetch_endpoint(
|
||||||
|
client: &ApiClient,
|
||||||
|
path: &str,
|
||||||
|
method: HttpMethod,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let reqwest_method = match method {
|
||||||
|
HttpMethod::Get => reqwest::Method::GET,
|
||||||
|
HttpMethod::Post => reqwest::Method::POST,
|
||||||
|
HttpMethod::Put => reqwest::Method::PUT,
|
||||||
|
HttpMethod::Patch => reqwest::Method::PATCH,
|
||||||
|
HttpMethod::Delete => reqwest::Method::DELETE,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send request and parse response
|
||||||
|
let response = client
|
||||||
|
.raw_request(reqwest_method, path)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("HTTP {status}: {body}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse JSON: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all data sources for a page
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if any data source fails to fetch
|
||||||
|
pub async fn fetch_page_data(
|
||||||
|
client: &ApiClient,
|
||||||
|
data_sources: &HashMap<String, DataSource>,
|
||||||
|
) -> Result<HashMap<String, serde_json::Value>, String> {
|
||||||
|
let mut results = HashMap::new();
|
||||||
|
|
||||||
|
for (name, source) in data_sources {
|
||||||
|
let value = match source {
|
||||||
|
DataSource::Endpoint { path, method, .. } => {
|
||||||
|
// Fetch from endpoint (ignoring params, poll_interval, transform for
|
||||||
|
// now)
|
||||||
|
fetch_endpoint(client, path, method.clone()).await?
|
||||||
|
},
|
||||||
|
DataSource::Static { value } => value.clone(),
|
||||||
|
DataSource::Transform {
|
||||||
|
source_name,
|
||||||
|
expression,
|
||||||
|
} => {
|
||||||
|
// Get source data and apply transform
|
||||||
|
let source_data = results
|
||||||
|
.get(source_name)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::Value::Null);
|
||||||
|
// TODO: Actually evaluate expression against source_data
|
||||||
|
// For now, return source_data unchanged
|
||||||
|
let _ = expression;
|
||||||
|
source_data
|
||||||
|
},
|
||||||
|
};
|
||||||
|
results.insert(name.clone(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook to fetch and cache plugin page data
|
||||||
|
///
|
||||||
|
/// Returns a signal containing the data state
|
||||||
|
pub fn use_plugin_data(
|
||||||
|
client: Signal<ApiClient>,
|
||||||
|
data_sources: HashMap<String, DataSource>,
|
||||||
|
) -> Signal<PluginPageData> {
|
||||||
|
let mut data = use_signal(PluginPageData::default);
|
||||||
|
|
||||||
|
use_effect(move || {
|
||||||
|
let sources = data_sources.clone();
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
// Clear previous data
|
||||||
|
data.write().clear();
|
||||||
|
|
||||||
|
// Mark all sources as loading
|
||||||
|
for name in sources.keys() {
|
||||||
|
data.write().set_loading(name, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch data
|
||||||
|
match fetch_page_data(&client.read(), &sources).await {
|
||||||
|
Ok(results) => {
|
||||||
|
for (name, value) in results {
|
||||||
|
data.write().set_data(name, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
for name in sources.keys() {
|
||||||
|
data.write().set_error(name.clone(), e.clone());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a value from JSON by path (dot notation)
|
||||||
|
///
|
||||||
|
/// Supports object keys and array indices
|
||||||
|
#[must_use]
|
||||||
|
pub fn get_json_path<'a>(
|
||||||
|
value: &'a serde_json::Value,
|
||||||
|
path: &str,
|
||||||
|
) -> Option<&'a serde_json::Value> {
|
||||||
|
let mut current = value;
|
||||||
|
|
||||||
|
for key in path.split('.') {
|
||||||
|
match current {
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
current = map.get(key)?;
|
||||||
|
},
|
||||||
|
serde_json::Value::Array(arr) => {
|
||||||
|
let idx = key.parse::<usize>().ok()?;
|
||||||
|
current = arr.get(idx)?;
|
||||||
|
},
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_page_data() {
|
||||||
|
let mut data = PluginPageData::default();
|
||||||
|
|
||||||
|
// Test empty state
|
||||||
|
assert!(!data.has_data("test"));
|
||||||
|
assert!(!data.is_loading("test"));
|
||||||
|
assert!(data.error("test").is_none());
|
||||||
|
|
||||||
|
// Test setting data
|
||||||
|
data.set_data("test".to_string(), serde_json::json!({"key": "value"}));
|
||||||
|
assert!(data.has_data("test"));
|
||||||
|
assert_eq!(data.get("test"), Some(&serde_json::json!({"key": "value"})));
|
||||||
|
|
||||||
|
// Test loading state
|
||||||
|
data.set_loading("loading", true);
|
||||||
|
assert!(data.is_loading("loading"));
|
||||||
|
data.set_loading("loading", false);
|
||||||
|
assert!(!data.is_loading("loading"));
|
||||||
|
|
||||||
|
// Test error state
|
||||||
|
data.set_error("error".to_string(), "oops".to_string());
|
||||||
|
assert_eq!(data.error("error"), Some(&"oops".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_json_path_object() {
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"user": {
|
||||||
|
"name": "John",
|
||||||
|
"age": 30
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_json_path(&data, "user.name"),
|
||||||
|
Some(&serde_json::Value::String("John".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_json_path_array() {
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"items": ["a", "b", "c"]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_json_path(&data, "items.1"),
|
||||||
|
Some(&serde_json::Value::String("b".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_json_path_invalid() {
|
||||||
|
let data = serde_json::json!({"foo": "bar"});
|
||||||
|
assert!(get_json_path(&data, "nonexistent").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue