This commit adds template rendering to the `proxmox-notify` crate, based on the `handlebars` crate.
Title and body of a notification are rendered using any `properties` passed along with the notification. There are also a few helpers, allowing to render tables from `serde_json::Value`. 'Value' renderers. These can also be used in table cells using the 'renderer' property in a table schema: - {{human-bytes val}} Render bytes with human-readable units (base 2) - {{duration val}} Render a duration (based on seconds) - {{timestamp val}} Render a unix-epoch (based on seconds) There are also a few 'block-level' helpers. - {{table val}} Render a table from given val (containing a schema for the columns, as well as the table data) - {{object val}} Render a value as a pretty-printed json - {{heading_1 val}} Render a top-level heading - {{heading_2 val}} Render a not-so-top-level heading - {{verbatim val}} or {{/verbatim}}<content>{{#verbatim}} Do not reflow text. NOP for plain text, but for HTML output the text will be contained in a <pre> with a regular font. - {{verbatim-monospaced val}} or {{/verbatim-monospaced}}<content>{{#verbatim-monospaced}} Do not reflow text. NOP for plain text, but for HTML output the text will be contained in a <pre> with a monospaced font. Signed-off-by: Lukas Wagner <l.wag...@proxmox.com> --- Cargo.toml | 1 + proxmox-notify/Cargo.toml | 6 +- proxmox-notify/src/endpoints/gotify.rs | 17 +- proxmox-notify/src/endpoints/sendmail.rs | 26 +- proxmox-notify/src/lib.rs | 6 +- proxmox-notify/src/renderer/html.rs | 100 +++++++ proxmox-notify/src/renderer/mod.rs | 359 +++++++++++++++++++++++ proxmox-notify/src/renderer/plaintext.rs | 141 +++++++++ proxmox-notify/src/renderer/table.rs | 24 ++ 9 files changed, 664 insertions(+), 16 deletions(-) create mode 100644 proxmox-notify/src/renderer/html.rs create mode 100644 proxmox-notify/src/renderer/mod.rs create mode 100644 proxmox-notify/src/renderer/plaintext.rs create mode 100644 proxmox-notify/src/renderer/table.rs diff --git a/Cargo.toml b/Cargo.toml index 1003022e..20b530d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ proxmox-api-macro = { version = "1.0.4", path = "proxmox-api-macro" } proxmox-async = { version = "0.4.1", path = "proxmox-async" } proxmox-compression = { version = "0.2.0", path = "proxmox-compression" } proxmox-http = { version = "0.9.0", path = "proxmox-http" } +proxmox-human-byte = { version = "0.1.0", path = "proxmox-human-byte" } proxmox-io = { version = "1.0.0", path = "proxmox-io" } proxmox-lang = { version = "1.1", path = "proxmox-lang" } proxmox-rest-server = { version = "0.4.0", path = "proxmox-rest-server" } diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml index 738674ae..a635798b 100644 --- a/proxmox-notify/Cargo.toml +++ b/proxmox-notify/Cargo.toml @@ -8,19 +8,21 @@ repository.workspace = true exclude.workspace = true [dependencies] -handlebars = { workspace = true, optional = true } +handlebars = { workspace = true } lazy_static.workspace = true log.workspace = true openssl.workspace = true proxmox-http = { workspace = true, features = ["client-sync"], optional = true } +proxmox-human-byte.workspace = true proxmox-schema = { workspace = true, features = ["api-macro"]} proxmox-section-config = { workspace = true } proxmox-sys = { workspace = true, optional = true } +proxmox-time.workspace = true regex.workspace = true serde = { workspace = true, features = ["derive"]} serde_json.workspace = true [features] default = ["sendmail", "gotify"] -sendmail = ["dep:handlebars", "dep:proxmox-sys"] +sendmail = ["dep:proxmox-sys"] gotify = ["dep:proxmox-http"] diff --git a/proxmox-notify/src/endpoints/gotify.rs b/proxmox-notify/src/endpoints/gotify.rs index 0d306964..504b2f24 100644 --- a/proxmox-notify/src/endpoints/gotify.rs +++ b/proxmox-notify/src/endpoints/gotify.rs @@ -1,7 +1,8 @@ use std::collections::HashMap; +use crate::renderer::TemplateRenderer; use crate::schema::{COMMENT_SCHEMA, ENTITY_NAME_SCHEMA}; -use crate::{Endpoint, Error, Notification, Severity}; +use crate::{renderer, Endpoint, Error, Notification, Severity}; use serde::{Deserialize, Serialize}; @@ -93,9 +94,19 @@ impl Endpoint for GotifyEndpoint { let uri = format!("{}/message", self.config.server); + let properties = notification.properties.as_ref(); + + let title = renderer::render_template( + TemplateRenderer::Plaintext, + ¬ification.title, + properties, + )?; + let message = + renderer::render_template(TemplateRenderer::Plaintext, ¬ification.body, properties)?; + let body = GotifyMessageBody { - title: ¬ification.title, - message: ¬ification.body, + title: &title, + message: &message, priority: severity_to_priority(notification.severity), }; diff --git a/proxmox-notify/src/endpoints/sendmail.rs b/proxmox-notify/src/endpoints/sendmail.rs index ee96c10a..41da0642 100644 --- a/proxmox-notify/src/endpoints/sendmail.rs +++ b/proxmox-notify/src/endpoints/sendmail.rs @@ -1,5 +1,6 @@ +use crate::renderer::TemplateRenderer; use crate::schema::{COMMENT_SCHEMA, EMAIL_SCHEMA, ENTITY_NAME_SCHEMA}; -use crate::{Endpoint, Error, Notification}; +use crate::{renderer, Endpoint, Error, Notification}; use proxmox_schema::{api, Updater}; use serde::{Deserialize, Serialize}; @@ -68,12 +69,17 @@ impl Endpoint for SendmailEndpoint { fn send(&self, notification: &Notification) -> Result<(), Error> { let recipients: Vec<&str> = self.config.recipient.iter().map(String::as_str).collect(); - // Note: OX has serious problems displaying text mails, - // so we include html as well - let html = format!( - "<html><body><pre>\n{}\n<pre>", - handlebars::html_escape(¬ification.body) - ); + let properties = notification.properties.as_ref(); + + let subject = renderer::render_template( + TemplateRenderer::Plaintext, + ¬ification.title, + properties, + )?; + let html_part = + renderer::render_template(TemplateRenderer::Html, ¬ification.body, properties)?; + let text_part = + renderer::render_template(TemplateRenderer::Plaintext, ¬ification.body, properties)?; // proxmox_sys::email::sendmail will set the author to // "Proxmox Backup Server" if it is not set. @@ -81,9 +87,9 @@ impl Endpoint for SendmailEndpoint { proxmox_sys::email::sendmail( &recipients, - ¬ification.title, - Some(¬ification.body), - Some(&html), + &subject, + Some(&text_part), + Some(&html_part), self.config.from_address.as_deref(), author, ) diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs index 33d3dbc7..23bd7342 100644 --- a/proxmox-notify/src/lib.rs +++ b/proxmox-notify/src/lib.rs @@ -14,7 +14,8 @@ pub mod api; pub mod channel; mod config; pub mod endpoints; -mod filter; +pub mod filter; +pub mod renderer; pub mod schema; #[derive(Debug)] @@ -25,6 +26,7 @@ pub enum Error { EndpointDoesNotExist(String), ChannelDoesNotExist(String), FilterFailed(String), + RenderError(Box<dyn StdError + Send + Sync + 'static>), } impl Display for Error { @@ -48,6 +50,7 @@ impl Display for Error { Error::FilterFailed(message) => { write!(f, "could not apply filter: {message}") } + Error::RenderError(err) => write!(f, "could not render notification template: {err}"), } } } @@ -61,6 +64,7 @@ impl StdError for Error { Error::EndpointDoesNotExist(_) => None, Error::ChannelDoesNotExist(_) => None, Error::FilterFailed(_) => None, + Error::RenderError(err) => Some(&**err), } } } diff --git a/proxmox-notify/src/renderer/html.rs b/proxmox-notify/src/renderer/html.rs new file mode 100644 index 00000000..7a41e873 --- /dev/null +++ b/proxmox-notify/src/renderer/html.rs @@ -0,0 +1,100 @@ +use crate::define_helper_with_prefix_and_postfix; +use crate::renderer::BlockRenderFunctions; +use handlebars::{ + Context, Handlebars, Helper, HelperResult, Output, RenderContext, + RenderError as HandlebarsRenderError, +}; +use serde_json::Value; + +use super::{table::Table, value_to_string}; + +fn render_html_table( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let param = h + .param(0) + .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?; + + let value = param.value(); + + let table: Table = serde_json::from_value(value.clone())?; + + out.write("<table style=\"border: 1px solid\";border-style=\"collapse\">\n")?; + + // Write header + out.write(" <tr>\n")?; + for column in &table.schema.columns { + out.write(" <th style=\"border: 1px solid\">")?; + out.write(&handlebars::html_escape(&column.label))?; + out.write("</th>\n")?; + } + out.write(" </tr>\n")?; + + // Write individual rows + for row in &table.data { + out.write(" <tr>\n")?; + + for column in &table.schema.columns { + let entry = row.get(&column.id).unwrap_or(&Value::Null); + + let text = if let Some(renderer) = &column.renderer { + renderer.render(entry)? + } else { + value_to_string(entry) + }; + + out.write(" <td style=\"border: 1px solid\">")?; + out.write(&handlebars::html_escape(&text))?; + out.write("</td>\n")?; + } + out.write(" </tr>\n")?; + } + + out.write("</table>\n")?; + + Ok(()) +} + +fn render_object( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let param = h + .param(0) + .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?; + + let value = param.value(); + + out.write("\n<pre>")?; + out.write(&serde_json::to_string_pretty(&value)?)?; + out.write("\n</pre>\n")?; + + Ok(()) +} + +define_helper_with_prefix_and_postfix!(verbatim_monospaced, "<pre>", "</pre>"); +define_helper_with_prefix_and_postfix!(heading_1, "<h1 style=\"font-size: 1.2em\">", "</h1>"); +define_helper_with_prefix_and_postfix!(heading_2, "<h2 style=\"font-size: 1em\">", "</h2>"); +define_helper_with_prefix_and_postfix!( + verbatim, + "<pre style=\"font-family: sans-serif\">", + "</pre>" +); + +pub(super) fn block_render_functions() -> BlockRenderFunctions { + BlockRenderFunctions { + table: Box::new(render_html_table), + verbatim_monospaced: Box::new(verbatim_monospaced), + object: Box::new(render_object), + heading_1: Box::new(heading_1), + heading_2: Box::new(heading_2), + verbatim: Box::new(verbatim), + } +} diff --git a/proxmox-notify/src/renderer/mod.rs b/proxmox-notify/src/renderer/mod.rs new file mode 100644 index 00000000..452b26a9 --- /dev/null +++ b/proxmox-notify/src/renderer/mod.rs @@ -0,0 +1,359 @@ +//! Module for rendering notification templates. + +use handlebars::{ + Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext, + RenderError as HandlebarsRenderError, +}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::Error; +use proxmox_human_byte::HumanByte; +use proxmox_time::TimeSpan; + +mod html; +mod plaintext; +mod table; + +/// Convert a serde_json::Value to a String. +/// +/// The main difference between this and simply calling Value::to_string is that +/// this will print strings without double quotes +fn value_to_string(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + v => v.to_string(), + } +} + +/// Render a serde_json::Value as a byte size with proper units (IEC, base 2) +/// +/// Will return `None` if `val` does not contain a number. +fn value_to_byte_size(val: &Value) -> Option<String> { + let size = val.as_f64()?; + Some(format!("{}", HumanByte::new_binary(size))) +} + +/// Render a serde_json::Value as a duration. +/// The value is expected to contain the duration in seconds. +/// +/// Will return `None` if `val` does not contain a number. +fn value_to_duration(val: &Value) -> Option<String> { + let duration = val.as_u64()?; + let time_span = TimeSpan::from(Duration::from_secs(duration)); + + Some(format!("{time_span}")) +} + +/// Render as serde_json::Value as a timestamp. +/// The value is expected to contain the timestamp as a unix epoch. +/// +/// Will return `None` if `val` does not contain a number. +fn value_to_timestamp(val: &Value) -> Option<String> { + let timestamp = val.as_i64()?; + proxmox_time::strftime_local("%F %H:%M:%S", timestamp).ok() +} + +/// Available render functions for `serde_json::Values`` +/// +/// May be used as a handlebars helper, e.g. +/// ```text +/// {{human-bytes 1024}} +/// ``` +/// +/// Value renderer can also be used for rendering values in table columns: +/// ```text +/// let properties = json!({ +/// "table": { +/// "schema": { +/// "columns": [ +/// { +/// "label": "Size", +/// "id": "size", +/// "renderer": "human-bytes" +/// } +/// ], +/// }, +/// "data" : [ +/// { +/// "size": 1024 * 1024, +/// }, +/// ] +/// } +/// }); +/// ``` +/// +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ValueRenderFunction { + HumanBytes, + Duration, + Timestamp, +} + +impl ValueRenderFunction { + fn render(&self, value: &Value) -> Result<String, HandlebarsRenderError> { + match self { + ValueRenderFunction::HumanBytes => value_to_byte_size(value), + ValueRenderFunction::Duration => value_to_duration(value), + ValueRenderFunction::Timestamp => value_to_timestamp(value), + } + .ok_or_else(|| { + HandlebarsRenderError::new(format!( + "could not render value {value} with renderer {self:?}" + )) + }) + } + + fn register_helpers(handlebars: &mut Handlebars) { + ValueRenderFunction::HumanBytes.register_handlebars_helper(handlebars); + ValueRenderFunction::Duration.register_handlebars_helper(handlebars); + ValueRenderFunction::Timestamp.register_handlebars_helper(handlebars); + } + + fn register_handlebars_helper(&'static self, handlebars: &mut Handlebars) { + // Use serde to get own kebab-case representation that is later used + // to register the helper, e.g. HumanBytes -> human-bytes + let tag = serde_json::to_string(self) + .expect("serde failed to serialize ValueRenderFunction enum"); + + // But as it's a string value, the generated string is quoted, + // so remove leading/trailing double quotes + let tag = tag + .strip_prefix('\"') + .and_then(|t| t.strip_suffix('\"')) + .expect("serde serialized string representation was not contained in double quotes"); + + handlebars.register_helper( + tag, + Box::new( + |h: &Helper, + _r: &Handlebars, + _: &Context, + _rc: &mut RenderContext, + out: &mut dyn Output| + -> HelperResult { + let param = h + .param(0) + .ok_or(HandlebarsRenderError::new("parameter not found"))?; + + let value = param.value(); + out.write(&self.render(value)?)?; + + Ok(()) + }, + ), + ); + } +} + +/// Available renderers for notification templates. +pub enum TemplateRenderer { + /// Render to HTML code + Html, + /// Render to plain text + Plaintext, +} + +impl TemplateRenderer { + fn prefix(&self) -> &str { + match self { + TemplateRenderer::Html => "<html>\n<body>\n", + TemplateRenderer::Plaintext => "", + } + } + + fn postfix(&self) -> &str { + match self { + TemplateRenderer::Html => "\n</body>\n</html>", + TemplateRenderer::Plaintext => "", + } + } + + fn block_render_fns(&self) -> BlockRenderFunctions { + match self { + TemplateRenderer::Html => html::block_render_functions(), + TemplateRenderer::Plaintext => plaintext::block_render_functions(), + } + } +} + +type HelperFn = dyn HelperDef + Send + Sync; + +struct BlockRenderFunctions { + table: Box<HelperFn>, + verbatim_monospaced: Box<HelperFn>, + object: Box<HelperFn>, + heading_1: Box<HelperFn>, + heading_2: Box<HelperFn>, + verbatim: Box<HelperFn>, +} + +impl BlockRenderFunctions { + fn register_helpers(self, handlebars: &mut Handlebars) { + handlebars.register_helper("table", self.table); + handlebars.register_helper("verbatim", self.verbatim); + handlebars.register_helper("verbatim-monospaced", self.verbatim_monospaced); + handlebars.register_helper("object", self.object); + handlebars.register_helper("heading-1", self.heading_1); + handlebars.register_helper("heading-2", self.heading_2); + } +} + +fn render_template_impl( + template: &str, + properties: Option<&Value>, + block_render_fns: BlockRenderFunctions, +) -> Result<String, Error> { + let properties = properties.unwrap_or(&Value::Null); + + let mut handlebars = Handlebars::new(); + block_render_fns.register_helpers(&mut handlebars); + + ValueRenderFunction::register_helpers(&mut handlebars); + + let rendered_template = handlebars + .render_template(template, properties) + .map_err(|err| Error::RenderError(err.into()))?; + + Ok(rendered_template) +} + +/// Render a template string. +/// +/// The output format can be chosen via the `renderer` parameter (see [TemplateRenderer] +/// for available options). +pub fn render_template( + renderer: TemplateRenderer, + template: &str, + properties: Option<&Value>, +) -> Result<String, Error> { + let mut rendered_template = String::from(renderer.prefix()); + + let block_helpers = renderer.block_render_fns(); + rendered_template.push_str(&render_template_impl(template, properties, block_helpers)?); + rendered_template.push_str(renderer.postfix()); + + Ok(rendered_template) +} + +#[macro_export] +macro_rules! define_helper_with_prefix_and_postfix { + ($name:ident, $pre:expr, $post:expr) => { + fn $name<'reg, 'rc>( + h: &Helper<'reg, 'rc>, + handlebars: &'reg Handlebars, + context: &'rc Context, + render_context: &mut RenderContext<'reg, 'rc>, + out: &mut dyn Output, + ) -> HelperResult { + use handlebars::Renderable; + + let block_text = h.template(); + let param = h.param(0); + + out.write($pre)?; + match (param, block_text) { + (None, Some(block_text)) => { + block_text.render(handlebars, context, render_context, out) + } + (Some(param), None) => { + let value = param.value(); + let text = value.as_str().ok_or_else(|| { + HandlebarsRenderError::new(format!("value {value} is not a string")) + })?; + + out.write(text)?; + Ok(()) + } + (Some(_), Some(_)) => Err(HandlebarsRenderError::new( + "Cannot use parameter and template at the same time", + )), + (None, None) => Err(HandlebarsRenderError::new( + "Neither parameter nor template was provided", + )), + }?; + out.write($post)?; + Ok(()) + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_render_template() -> Result<(), Error> { + let properties = json!({ + "ts": 1684333717, + "dur": 12345, + "size": 1024 * 15, + + "table": { + "schema": { + "columns": [ + { + "id": "col1", + "label": "Column 1" + }, + { + "id": "col2", + "label": "Column 2" + } + ] + }, + "data": [ + { + "col1": "val1", + "col2": "val2" + }, + { + "col1": "val3", + "col2": "val4" + }, + ] + } + + }); + + let template = r#" +{{heading-1 "Hello World"}} + +{{heading-2 "Hello World"}} + +{{human-bytes size}} +{{duration dur}} +{{timestamp ts}} + +{{table table}}"#; + + let expected_plaintext = r#" +Hello World +=========== + +Hello World +----------- + +15 KiB +3h 25min 45s +2023-05-17 16:28:37 + +Column 1 Column 2 +val1 val2 +val3 val4 +"#; + + let rendered_plaintext = + render_template(TemplateRenderer::Plaintext, template, Some(&properties))?; + + // Let's not bother about testing the HTML output, too fragile. + + assert_eq!(rendered_plaintext, expected_plaintext); + + Ok(()) + } +} diff --git a/proxmox-notify/src/renderer/plaintext.rs b/proxmox-notify/src/renderer/plaintext.rs new file mode 100644 index 00000000..58c51599 --- /dev/null +++ b/proxmox-notify/src/renderer/plaintext.rs @@ -0,0 +1,141 @@ +use crate::define_helper_with_prefix_and_postfix; +use crate::renderer::BlockRenderFunctions; +use handlebars::{ + Context, Handlebars, Helper, HelperResult, Output, RenderContext, + RenderError as HandlebarsRenderError, +}; +use serde_json::Value; +use std::collections::HashMap; + +use super::{table::Table, value_to_string}; + +fn optimal_column_widths(table: &Table) -> HashMap<&str, usize> { + let mut widths = HashMap::new(); + + for column in &table.schema.columns { + let mut min_width = column.label.len(); + + for row in &table.data { + let entry = row.get(&column.id).unwrap_or(&Value::Null); + + let text = if let Some(renderer) = &column.renderer { + renderer.render(entry).unwrap_or_default() + } else { + value_to_string(entry) + }; + + min_width = std::cmp::max(text.len(), min_width); + } + + widths.insert(column.label.as_str(), min_width + 4); + } + + widths +} + +fn render_plaintext_table( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let param = h + .param(0) + .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?; + let value = param.value(); + let table: Table = serde_json::from_value(value.clone())?; + let widths = optimal_column_widths(&table); + + // Write header + for column in &table.schema.columns { + let width = widths.get(column.label.as_str()).unwrap_or(&0); + out.write(&format!("{label:width$}", label = column.label))?; + } + + out.write("\n")?; + + // Write individual rows + for row in &table.data { + for column in &table.schema.columns { + let entry = row.get(&column.id).unwrap_or(&Value::Null); + let width = widths.get(column.label.as_str()).unwrap_or(&0); + + let text = if let Some(renderer) = &column.renderer { + renderer.render(entry)? + } else { + value_to_string(entry) + }; + + out.write(&format!("{text:width$}",))?; + } + out.write("\n")?; + } + + Ok(()) +} + +macro_rules! define_underlining_heading_fn { + ($name:ident, $underline:expr) => { + fn $name<'reg, 'rc>( + h: &Helper<'reg, 'rc>, + _handlebars: &'reg Handlebars, + _context: &'rc Context, + _render_context: &mut RenderContext<'reg, 'rc>, + out: &mut dyn Output, + ) -> HelperResult { + let param = h + .param(0) + .ok_or_else(|| HandlebarsRenderError::new("No parameter provided"))?; + + let value = param.value(); + let text = value.as_str().ok_or_else(|| { + HandlebarsRenderError::new(format!("value {value} is not a string")) + })?; + + out.write(text)?; + out.write("\n")?; + + for _ in 0..text.len() { + out.write($underline)?; + } + Ok(()) + } + }; +} + +define_helper_with_prefix_and_postfix!(verbatim_monospaced, "", ""); +define_underlining_heading_fn!(heading_1, "="); +define_underlining_heading_fn!(heading_2, "-"); +define_helper_with_prefix_and_postfix!(verbatim, "", ""); + +fn render_object( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let param = h + .param(0) + .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?; + + let value = param.value(); + + out.write("\n")?; + out.write(&serde_json::to_string_pretty(&value)?)?; + out.write("\n")?; + + Ok(()) +} + +pub(super) fn block_render_functions() -> BlockRenderFunctions { + BlockRenderFunctions { + table: Box::new(render_plaintext_table), + verbatim_monospaced: Box::new(verbatim_monospaced), + verbatim: Box::new(verbatim), + object: Box::new(render_object), + heading_1: Box::new(heading_1), + heading_2: Box::new(heading_2), + } +} diff --git a/proxmox-notify/src/renderer/table.rs b/proxmox-notify/src/renderer/table.rs new file mode 100644 index 00000000..74f68482 --- /dev/null +++ b/proxmox-notify/src/renderer/table.rs @@ -0,0 +1,24 @@ +use std::collections::HashMap; + +use serde::Deserialize; +use serde_json::Value; + +use super::ValueRenderFunction; + +#[derive(Debug, Deserialize)] +pub struct ColumnSchema { + pub label: String, + pub id: String, + pub renderer: Option<ValueRenderFunction>, +} + +#[derive(Debug, Deserialize)] +pub struct TableSchema { + pub columns: Vec<ColumnSchema>, +} + +#[derive(Debug, Deserialize)] +pub struct Table { + pub schema: TableSchema, + pub data: Vec<HashMap<String, Value>>, +} -- 2.30.2 _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel