GUI plugins #9

Merged
NotAShelf merged 46 commits from notashelf/push-mytsqvppsvxu into main 2026-03-12 16:53:43 +00:00
Showing only changes of commit e46a8943cb - Show all commits

pinakes-ui: add plugin data fetching

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie28a544cf0df1a23b905e89b69db19c06a6a6964
raf 2026-03-09 22:01:08 +03:00
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View 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());
}
}