diff --git a/crates/cassette-core/Cargo.toml b/crates/cassette-core/Cargo.toml index bc0290e..0d9d506 100644 --- a/crates/cassette-core/Cargo.toml +++ b/crates/cassette-core/Cargo.toml @@ -22,7 +22,7 @@ workspace = true [features] default = [] api = ["dep:actix-web"] -ui = ["dep:gloo-net", "dep:yew"] +ui = ["dep:gloo-net", "dep:patternfly-yew", "dep:yew"] # net stream = ["dep:anyhow", "dep:wasm-streams"] @@ -38,6 +38,7 @@ garde = { workspace = true } gloo-net = { workspace = true, optional = true } k8s-openapi = { workspace = true } kube = { workspace = true, features = ["derive"] } +patternfly-yew = { workspace = true, optional = true } schemars = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive", "rc"] } serde_json = { workspace = true } diff --git a/crates/cassette-core/src/cassette.rs b/crates/cassette-core/src/cassette.rs index 24a3dee..46235b2 100644 --- a/crates/cassette-core/src/cassette.rs +++ b/crates/cassette-core/src/cassette.rs @@ -16,7 +16,7 @@ use uuid::Uuid; #[cfg(feature = "ui")] use yew::prelude::*; -use crate::component::CassetteComponentSpec; +use crate::components::CassetteComponentSpec; #[cfg(feature = "ui")] use crate::net::fetch::{FetchState, FetchStateHandle}; diff --git a/crates/cassette-core/src/component.rs b/crates/cassette-core/src/component.rs index f26653a..8b13789 100644 --- a/crates/cassette-core/src/component.rs +++ b/crates/cassette-core/src/component.rs @@ -1,115 +1 @@ -use kube::CustomResource; -use schemars::JsonSchema; -#[cfg(feature = "ui")] -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; -use crate::task::CassetteTask; - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, CustomResource)] -#[kube( - group = "cassette.ulagbulag.io", - version = "v1alpha1", - kind = "CassetteComponent", - root = "CassetteComponentCrd", - shortname = "casc", - namespaced, - printcolumn = r#"{ - "name": "created-at", - "type": "date", - "description": "created time", - "jsonPath": ".metadata.creationTimestamp" - }"#, - printcolumn = r#"{ - "name": "version", - "type": "integer", - "description": "component version", - "jsonPath": ".metadata.generation" - }"# -)] -#[serde(rename_all = "camelCase")] -pub struct CassetteComponentSpec { - #[serde(default)] - pub tasks: Vec, -} - -#[cfg(feature = "ui")] -pub trait ComponentRenderer { - fn render( - self, - ctx: &mut crate::cassette::CassetteContext, - spec: Spec, - ) -> crate::task::TaskResult> - where - Self: Sized; -} - -#[cfg(feature = "ui")] -pub trait ComponentRendererExt -where - Self: Default + Serialize + DeserializeOwned + ComponentRenderer, - Spec: DeserializeOwned, -{ - fn render_with( - mut ctx: crate::cassette::CassetteContext, - spec: &crate::task::TaskSpec, - ) -> crate::task::TaskResult<()> - where - Self: Sized, - { - use serde_json::Value; - - fn replace_key( - ctx: &crate::cassette::CassetteContext, - spec: &crate::task::TaskSpec, - value: &Value, - ) -> Result { - match value { - Value::Null => Ok(Value::Null), - Value::Bool(data) => Ok(Value::Bool(*data)), - Value::Number(data) => Ok(Value::Number(data.clone())), - Value::String(data) => { - if data.starts_with(":/") { - ctx.get(&data[1..]).cloned() - } else if data.starts_with("~/") { - spec.get(&data[1..]).cloned() - } else if data.starts_with("\\:/") || data.starts_with("\\~/") { - Ok(Value::String(data[1..].into())) - } else { - Ok(Value::String(data.clone())) - } - } - Value::Array(array) => array - .iter() - .map(|value| replace_key(ctx, spec, value)) - .collect::>() - .map(Value::Array), - Value::Object(map) => map - .iter() - .map(|(key, value)| { - replace_key(ctx, spec, value).map(|value| (key.clone(), value)) - }) - .collect::>() - .map(Value::Object), - } - } - - let state = ctx.get_task_state()?.unwrap_or_default(); - - let spec = replace_key(&ctx, spec, &spec.0)?; - let spec = ::serde_json::from_value(spec) - .map_err(|error| format!("Failed to parse task spec: {error}"))?; - - let state = >::render(state, &mut ctx, spec) - .and_then(crate::task::TaskState::try_into_spec)?; - Ok(ctx.set(state)) - } -} - -#[cfg(feature = "ui")] -impl ComponentRendererExt for T -where - Self: Default + Serialize + DeserializeOwned + ComponentRenderer, - Spec: DeserializeOwned, -{ -} diff --git a/crates/cassette-core/src/components/error.rs b/crates/cassette-core/src/components/error.rs new file mode 100644 index 0000000..a3e9c63 --- /dev/null +++ b/crates/cassette-core/src/components/error.rs @@ -0,0 +1,18 @@ +use patternfly_yew::prelude::*; +use yew::prelude::*; + +#[derive(Clone, Debug, PartialEq, Properties)] +pub struct Props { + pub msg: AttrValue, +} + +#[function_component(Error)] +pub fn error(props: &Props) -> Html { + let Props { msg } = props; + + html! { + +

{ msg }

+
+ } +} diff --git a/crates/cassette-core/src/components/loading.rs b/crates/cassette-core/src/components/loading.rs new file mode 100644 index 0000000..4ae892e --- /dev/null +++ b/crates/cassette-core/src/components/loading.rs @@ -0,0 +1,18 @@ +use patternfly_yew::prelude::*; +use yew::prelude::*; + +#[function_component(Loading)] +pub fn loading() -> Html { + html! { + + + + + + +

{ "Loading..." }

+
+
+
+ } +} diff --git a/crates/cassette-core/src/components/mod.rs b/crates/cassette-core/src/components/mod.rs new file mode 100644 index 0000000..ae0b2a1 --- /dev/null +++ b/crates/cassette-core/src/components/mod.rs @@ -0,0 +1,120 @@ +#[cfg(feature = "ui")] +pub mod error; +#[cfg(feature = "ui")] +pub mod loading; + +use kube::CustomResource; +use schemars::JsonSchema; +#[cfg(feature = "ui")] +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::task::CassetteTask; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, CustomResource)] +#[kube( + group = "cassette.ulagbulag.io", + version = "v1alpha1", + kind = "CassetteComponent", + root = "CassetteComponentCrd", + shortname = "casc", + namespaced, + printcolumn = r#"{ + "name": "created-at", + "type": "date", + "description": "created time", + "jsonPath": ".metadata.creationTimestamp" + }"#, + printcolumn = r#"{ + "name": "version", + "type": "integer", + "description": "component version", + "jsonPath": ".metadata.generation" + }"# +)] +#[serde(rename_all = "camelCase")] +pub struct CassetteComponentSpec { + #[serde(default)] + pub tasks: Vec, +} + +#[cfg(feature = "ui")] +pub trait ComponentRenderer { + fn render( + self, + ctx: &mut crate::cassette::CassetteContext, + spec: Spec, + ) -> crate::task::TaskResult> + where + Self: Sized; +} + +#[cfg(feature = "ui")] +pub trait ComponentRendererExt +where + Self: Default + Serialize + DeserializeOwned + ComponentRenderer, + Spec: DeserializeOwned, +{ + fn render_with( + mut ctx: crate::cassette::CassetteContext, + spec: &crate::task::TaskSpec, + ) -> crate::task::TaskResult<()> + where + Self: Sized, + { + use serde_json::Value; + + fn replace_key( + ctx: &crate::cassette::CassetteContext, + spec: &crate::task::TaskSpec, + value: &Value, + ) -> Result { + match value { + Value::Null => Ok(Value::Null), + Value::Bool(data) => Ok(Value::Bool(*data)), + Value::Number(data) => Ok(Value::Number(data.clone())), + Value::String(data) => { + if data.starts_with(":/") { + ctx.get(&data[1..]).cloned() + } else if data.starts_with("~/") { + spec.get(&data[1..]).cloned() + } else if data.starts_with("\\:/") || data.starts_with("\\~/") { + Ok(Value::String(data[1..].into())) + } else { + Ok(Value::String(data.clone())) + } + } + Value::Array(array) => array + .iter() + .map(|value| replace_key(ctx, spec, value)) + .collect::>() + .map(Value::Array), + Value::Object(map) => map + .iter() + .map(|(key, value)| { + replace_key(ctx, spec, value).map(|value| (key.clone(), value)) + }) + .collect::>() + .map(Value::Object), + } + } + + let state = ctx.get_task_state()?.unwrap_or_default(); + + let spec = replace_key(&ctx, spec, &spec.0)?; + let spec = ::serde_json::from_value(spec) + .map_err(|error| format!("Failed to parse task spec: {error}"))?; + + let state = >::render(state, &mut ctx, spec) + .and_then(crate::task::TaskState::try_into_spec)?; + Ok(ctx.set(state)) + } +} + +#[cfg(feature = "ui")] +impl ComponentRendererExt for T +where + Self: Default + Serialize + DeserializeOwned + ComponentRenderer, + Spec: DeserializeOwned, +{ +} diff --git a/crates/cassette-core/src/document.rs b/crates/cassette-core/src/document.rs index 078f744..b2709be 100644 --- a/crates/cassette-core/src/document.rs +++ b/crates/cassette-core/src/document.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize, Serializer}; -use crate::{cassette::CassetteCrd, component::CassetteComponentCrd}; +use crate::{cassette::CassetteCrd, components::CassetteComponentCrd}; #[derive(Clone, Debug, Deserialize)] #[serde(tag = "kind")] diff --git a/crates/cassette-core/src/lib.rs b/crates/cassette-core/src/lib.rs index 88943ab..7133fd0 100644 --- a/crates/cassette-core/src/lib.rs +++ b/crates/cassette-core/src/lib.rs @@ -1,6 +1,11 @@ pub mod cassette; -pub mod component; +pub mod components; pub mod document; pub mod net; pub mod result; pub mod task; + +#[cfg(feature = "ui")] +pub mod prelude { + pub use crate::components::{error::Error, loading::Loading}; +} diff --git a/crates/cassette-gateway/src/db.rs b/crates/cassette-gateway/src/db.rs index 6e86244..182e7ca 100644 --- a/crates/cassette-gateway/src/db.rs +++ b/crates/cassette-gateway/src/db.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use cassette_core::{ cassette::{Cassette, CassetteCrd, CassetteRef}, - component::CassetteComponentCrd, + components::CassetteComponentCrd, }; use cassette_loader_core::CassetteDB as CassetteDBInner; use tokio::sync::RwLock; diff --git a/crates/cassette-gateway/src/reloader.rs b/crates/cassette-gateway/src/reloader.rs index 40ef6cd..143f000 100644 --- a/crates/cassette-gateway/src/reloader.rs +++ b/crates/cassette-gateway/src/reloader.rs @@ -2,7 +2,7 @@ use std::fmt; use anyhow::Result; use ark_core::signal::FunctionSignal; -use cassette_core::{cassette::CassetteCrd, component::CassetteComponentCrd}; +use cassette_core::{cassette::CassetteCrd, components::CassetteComponentCrd}; use futures::{TryFuture, TryStreamExt}; use kube::{ runtime::watcher::{watcher, Config, Error as WatcherError, Event}, diff --git a/crates/cassette-loader-core/src/lib.rs b/crates/cassette-loader-core/src/lib.rs index beb6aaa..90081ac 100644 --- a/crates/cassette-loader-core/src/lib.rs +++ b/crates/cassette-loader-core/src/lib.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use cassette_core::{ cassette::{Cassette, CassetteCrd, CassetteRef}, - component::CassetteComponentCrd, + components::CassetteComponentCrd, net::DEFAULT_NAMESPACE, }; use kube::ResourceExt; diff --git a/crates/cassette-operator/src/ctx/component.rs b/crates/cassette-operator/src/ctx/component.rs index 59bdccd..fe26daa 100644 --- a/crates/cassette-operator/src/ctx/component.rs +++ b/crates/cassette-operator/src/ctx/component.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; use anyhow::Result; use ark_core_k8s::manager::Manager; use async_trait::async_trait; -use cassette_core::component::CassetteComponentCrd; +use cassette_core::components::CassetteComponentCrd; use kube::{runtime::controller::Action, Error, ResourceExt}; use tracing::{instrument, Level}; diff --git a/crates/cassette-plugin-kubernetes-core/src/hooks.rs b/crates/cassette-plugin-kubernetes-core/src/hooks.rs index a5743d7..ef00e00 100644 --- a/crates/cassette-plugin-kubernetes-core/src/hooks.rs +++ b/crates/cassette-plugin-kubernetes-core/src/hooks.rs @@ -1,46 +1,51 @@ use std::{future::Future, rc::Rc}; use anyhow::Result; -use cassette_core::net::fetch::FetchState; +use cassette_core::{ + cassette::{CassetteContext, CassetteTaskHandle}, + net::fetch::{FetchState, FetchStateHandle}, +}; use kube_core::{params::ListParams, ObjectList}; use serde::de::DeserializeOwned; -use yew::{platform::spawn_local, prelude::*}; +use yew::platform::spawn_local; use crate::api::Api; -#[hook] pub fn use_kubernetes_list( + ctx: &mut CassetteContext, api: Api, lp: ListParams, -) -> UseStateHandle>> +) -> CassetteTaskHandle>> where K: 'static + Clone + DeserializeOwned, { - let state = use_state(|| FetchState::Pending); + let handler_name = "kubernetes list".into(); + let state = ctx.use_state(handler_name, || FetchState::Pending); { let state = state.clone(); let f = move || api.list(lp); - use_effect(move || try_fetch(state, f)) + try_fetch(state, f); } state } -fn try_fetch(state: UseStateHandle>, f: F) +fn try_fetch(mut state: State, f: F) where F: 'static + FnOnce() -> Fut, Fut: 'static + Future>, Res: 'static, + State: 'static + FetchStateHandle, { - if matches!(&*state, FetchState::Pending) { + if matches!(state.get(), FetchState::Pending) { state.set(FetchState::Fetching); - let state = state.clone(); + let mut state = state.clone(); spawn_local(async move { let value = match f().await { Ok(data) => FetchState::Completed(Rc::new(data)), Err(error) => FetchState::Error(error.to_string()), }; - if matches!(&*state, FetchState::Pending | FetchState::Fetching) { + if matches!(state.get(), FetchState::Pending | FetchState::Fetching) { state.set(value); } }) diff --git a/crates/cassette-plugin-kubernetes-list/Cargo.toml b/crates/cassette-plugin-kubernetes-list/Cargo.toml index 97aac19..29b1cba 100644 --- a/crates/cassette-plugin-kubernetes-list/Cargo.toml +++ b/crates/cassette-plugin-kubernetes-list/Cargo.toml @@ -24,6 +24,5 @@ cassette-core = { path = "../cassette-core", features = ["ui"] } cassette-plugin-kubernetes-core = { path = "../cassette-plugin-kubernetes-core" } kube-core = { workspace = true } -patternfly-yew = { workspace = true } serde = { workspace = true, features = ["derive"] } yew = { workspace = true } diff --git a/crates/cassette-plugin-kubernetes-list/src/lib.rs b/crates/cassette-plugin-kubernetes-list/src/lib.rs index c98c632..7593294 100644 --- a/crates/cassette-plugin-kubernetes-list/src/lib.rs +++ b/crates/cassette-plugin-kubernetes-list/src/lib.rs @@ -1,14 +1,14 @@ -use std::{fmt, marker::PhantomData}; +use std::marker::PhantomData; use cassette_core::{ cassette::CassetteContext, - component::ComponentRenderer, + components::ComponentRenderer, net::fetch::FetchState, + prelude::*, task::{TaskResult, TaskState}, }; use cassette_plugin_kubernetes_core::{api::Api, hooks::use_kubernetes_list}; use kube_core::{params::ListParams, DynamicObject}; -use patternfly_yew::prelude::*; use serde::{Deserialize, Serialize}; use yew::prelude::*; @@ -21,73 +21,55 @@ pub struct Spec { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct State {} - -impl ComponentRenderer for State { - fn render(self, _ctx: &mut CassetteContext, spec: Spec) -> TaskResult> { - let Self {} = self; - let Spec { api_version, kind } = spec; - - Ok(TaskState::Continue { - body: html! { }, - }) - } +pub struct State { + content: ListOrItem, } -#[function_component(Component)] -fn component(props: &Spec) -> Html { - let Spec { api_version, kind } = props; - - // TODO: to be implemented - let _ = (api_version, kind); - - let api: Api = Api { - api_group: Some("apps".into()), - namespace: Some("default".into()), - plural: "deployments".into(), - version: "v1".into(), - _type: PhantomData, - }; - let lp = ListParams::default(); - let value = use_kubernetes_list(api, lp); +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged, rename_all = "camelCase")] +pub enum ListOrItem { + List(Vec), + Item(Option), +} - match &*value { - FetchState::Pending | FetchState::Fetching => html! { - -

{ "Loading..." }

-
- }, - FetchState::Collecting(data) | FetchState::Completed(data) => html! { - list={ data.items.clone() } /> - }, - FetchState::Error(error) => html! { - - { error.clone() } - - }, +impl Default for ListOrItem { + fn default() -> Self { + Self::Item(None) } } -#[derive(Clone, Debug, PartialEq, Properties)] -struct BodyProps -where - T: PartialEq, -{ - list: Vec, -} +impl ComponentRenderer for State { + fn render(self, ctx: &mut CassetteContext, spec: Spec) -> TaskResult> { + let Spec { api_version, kind } = spec; + + // TODO: to be implemented + let _ = (api_version, kind); -#[function_component(ComponentBody)] -fn component_body(props: &BodyProps) -> Html -where - T: fmt::Debug + PartialEq, -{ - let BodyProps { list } = props; + let api: Api = Api { + api_group: Some("apps".into()), + namespace: Some("default".into()), + plural: "deployments".into(), + version: "v1".into(), + _type: PhantomData, + }; + let lp = ListParams::default(); - html! { - - - { format!("{list:#?}") } - - + match &*use_kubernetes_list(ctx, api, lp) { + FetchState::Pending | FetchState::Fetching => Ok(TaskState::Break { + body: html! { }, + state: None, + }), + FetchState::Collecting(content) | FetchState::Completed(content) => { + Ok(TaskState::Skip { + state: Some(Self { + content: ListOrItem::List(content.items.clone()), + }), + }) + } + FetchState::Error(msg) => Ok(TaskState::Break { + body: html! { }, + state: None, + }), + } } } diff --git a/crates/cassette-plugin-openai-chat/Cargo.toml b/crates/cassette-plugin-openai-chat/Cargo.toml index ccd1731..3d1265c 100644 --- a/crates/cassette-plugin-openai-chat/Cargo.toml +++ b/crates/cassette-plugin-openai-chat/Cargo.toml @@ -26,7 +26,6 @@ anyhow = { workspace = true } futures = { workspace = true } itertools = { workspace = true } js-sys = { workspace = true } -patternfly-yew = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } yew = { workspace = true } diff --git a/crates/cassette-plugin-openai-chat/src/lib.rs b/crates/cassette-plugin-openai-chat/src/lib.rs index ab38ce4..0a79103 100644 --- a/crates/cassette-plugin-openai-chat/src/lib.rs +++ b/crates/cassette-plugin-openai-chat/src/lib.rs @@ -3,11 +3,11 @@ mod schema; use cassette_core::{ cassette::CassetteContext, - component::ComponentRenderer, + components::ComponentRenderer, net::fetch::FetchState, + prelude::*, task::{TaskResult, TaskState}, }; -use patternfly_yew::prelude::*; use serde::{Deserialize, Serialize}; use yew::prelude::*; @@ -50,11 +50,7 @@ impl ComponentRenderer for State { match &*crate::hooks::use_fetch(ctx, &base_url, request) { FetchState::Pending | FetchState::Fetching => Ok(TaskState::Break { - body: html! { - -

{ "Loading..." }

-
- }, + body: html! { }, state: None, }), FetchState::Collecting(content) => Ok(TaskState::Skip { @@ -69,12 +65,8 @@ impl ComponentRenderer for State { progress: false, }), }), - FetchState::Error(error) => Ok(TaskState::Break { - body: html! { - - { error.clone() } - - }, + FetchState::Error(msg) => Ok(TaskState::Break { + body: html! { }, state: None, }), } diff --git a/crates/cassette/Cargo.toml b/crates/cassette/Cargo.toml index c905ffc..3c6611d 100644 --- a/crates/cassette/Cargo.toml +++ b/crates/cassette/Cargo.toml @@ -54,6 +54,7 @@ itertools = { workspace = true } patternfly-yew = { workspace = true } regex = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } tracing-subscriber-wasm = { workspace = true } diff --git a/crates/cassette/src/components/mod.rs b/crates/cassette/src/components/mod.rs index 46f9e45..cac3739 100644 --- a/crates/cassette/src/components/mod.rs +++ b/crates/cassette/src/components/mod.rs @@ -3,7 +3,7 @@ mod variable; use cassette_core::{ cassette::{CassetteContext, CassetteState}, - component::ComponentRendererExt, + components::ComponentRendererExt, task::{CassetteTask, TaskRenderer, TaskResult}, }; diff --git a/crates/cassette/src/components/text.rs b/crates/cassette/src/components/text.rs index 3480e56..312e2cb 100644 --- a/crates/cassette/src/components/text.rs +++ b/crates/cassette/src/components/text.rs @@ -1,17 +1,18 @@ use cassette_core::{ cassette::CassetteContext, - component::ComponentRenderer, + components::ComponentRenderer, task::{TaskResult, TaskState}, }; use patternfly_yew::prelude::*; use serde::{Deserialize, Serialize}; +use serde_json::Value; use yew::prelude::*; use yew_markdown::Markdown; #[derive(Clone, Debug, PartialEq, Deserialize, Properties)] #[serde(rename_all = "camelCase")] pub struct Spec { - msg: String, + msg: Value, #[serde(default)] progress: bool, @@ -26,13 +27,25 @@ impl ComponentRenderer for State { let Self {} = self; let Spec { msg, progress } = spec; + let content = match msg { + Value::String(src) => html! { }, + msg => ::serde_json::to_string_pretty(&msg) + .map(|data| { + html! { + + { data } + + } + }) + .map_err(|error| format!("Failed to encode message: {error}"))?, + }; let style = if progress { "color: #FF3333;" } else { "" }; Ok(TaskState::Continue { body: html! {
- + { content }
}, diff --git a/examples/kubernetes_list.yaml b/examples/kubernetes_list.yaml index ccb5acf..d05c498 100644 --- a/examples/kubernetes_list.yaml +++ b/examples/kubernetes_list.yaml @@ -19,3 +19,8 @@ spec: apiVersion: apps/v1 kind: Deployments namespaced: true + + - name: show + kind: Text + spec: + msg: ":/list/content"