forked from Proxmox/proxmox
notify: add template rendering
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.wagner@proxmox.com>
This commit is contained in:
parent
109a936b6b
commit
4865711339
@ -88,6 +88,7 @@ proxmox-api-macro = { version = "1.0.4", path = "proxmox-api-macro" }
|
|||||||
proxmox-async = { version = "0.4.1", path = "proxmox-async" }
|
proxmox-async = { version = "0.4.1", path = "proxmox-async" }
|
||||||
proxmox-compression = { version = "0.2.0", path = "proxmox-compression" }
|
proxmox-compression = { version = "0.2.0", path = "proxmox-compression" }
|
||||||
proxmox-http = { version = "0.9.0", path = "proxmox-http" }
|
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-io = { version = "1.0.0", path = "proxmox-io" }
|
||||||
proxmox-lang = { version = "1.1", path = "proxmox-lang" }
|
proxmox-lang = { version = "1.1", path = "proxmox-lang" }
|
||||||
proxmox-rest-server = { version = "0.4.0", path = "proxmox-rest-server" }
|
proxmox-rest-server = { version = "0.4.0", path = "proxmox-rest-server" }
|
||||||
|
@ -8,19 +8,21 @@ repository.workspace = true
|
|||||||
exclude.workspace = true
|
exclude.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
handlebars = { workspace = true, optional = true }
|
handlebars = { workspace = true }
|
||||||
lazy_static.workspace = true
|
lazy_static.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
openssl.workspace = true
|
openssl.workspace = true
|
||||||
proxmox-http = { workspace = true, features = ["client-sync"], optional = true }
|
proxmox-http = { workspace = true, features = ["client-sync"], optional = true }
|
||||||
|
proxmox-human-byte.workspace = true
|
||||||
proxmox-schema = { workspace = true, features = ["api-macro", "api-types"]}
|
proxmox-schema = { workspace = true, features = ["api-macro", "api-types"]}
|
||||||
proxmox-section-config = { workspace = true }
|
proxmox-section-config = { workspace = true }
|
||||||
proxmox-sys = { workspace = true, optional = true }
|
proxmox-sys = { workspace = true, optional = true }
|
||||||
|
proxmox-time.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"]}
|
serde = { workspace = true, features = ["derive"]}
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["sendmail", "gotify"]
|
default = ["sendmail", "gotify"]
|
||||||
sendmail = ["dep:handlebars", "dep:proxmox-sys"]
|
sendmail = ["dep:proxmox-sys"]
|
||||||
gotify = ["dep:proxmox-http"]
|
gotify = ["dep:proxmox-http"]
|
||||||
|
@ -1,22 +1,17 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::renderer::TemplateRenderer;
|
||||||
use crate::schema::ENTITY_NAME_SCHEMA;
|
use crate::schema::ENTITY_NAME_SCHEMA;
|
||||||
use crate::{Endpoint, Error, Notification, Severity};
|
use crate::{renderer, Endpoint, Error, Notification, Severity};
|
||||||
|
|
||||||
use proxmox_schema::api_types::COMMENT_SCHEMA;
|
use proxmox_schema::api_types::COMMENT_SCHEMA;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use proxmox_http::client::sync::Client;
|
use proxmox_http::client::sync::Client;
|
||||||
use proxmox_http::{HttpClient, HttpOptions};
|
use proxmox_http::{HttpClient, HttpOptions};
|
||||||
use proxmox_schema::{api, Updater};
|
use proxmox_schema::{api, Updater};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct GotifyMessageBody<'a> {
|
|
||||||
title: &'a str,
|
|
||||||
message: &'a str,
|
|
||||||
priority: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn severity_to_priority(level: Severity) -> u32 {
|
fn severity_to_priority(level: Severity) -> u32 {
|
||||||
match level {
|
match level {
|
||||||
Severity::Info => 1,
|
Severity::Info => 1,
|
||||||
@ -94,11 +89,30 @@ impl Endpoint for GotifyEndpoint {
|
|||||||
|
|
||||||
let uri = format!("{}/message", self.config.server);
|
let uri = format!("{}/message", self.config.server);
|
||||||
|
|
||||||
let body = GotifyMessageBody {
|
let properties = notification.properties.as_ref();
|
||||||
title: ¬ification.title,
|
|
||||||
message: ¬ification.body,
|
let title = renderer::render_template(
|
||||||
priority: severity_to_priority(notification.severity),
|
TemplateRenderer::Plaintext,
|
||||||
};
|
¬ification.title,
|
||||||
|
properties,
|
||||||
|
)?;
|
||||||
|
let message =
|
||||||
|
renderer::render_template(TemplateRenderer::Plaintext, ¬ification.body, properties)?;
|
||||||
|
|
||||||
|
// We don't have a TemplateRenderer::Markdown yet, so simply put everything
|
||||||
|
// in code tags. Otherwise tables etc. are not formatted properly
|
||||||
|
let message = format!("```\n{message}\n```");
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"title": &title,
|
||||||
|
"message": &message,
|
||||||
|
"priority": severity_to_priority(notification.severity),
|
||||||
|
"extras": {
|
||||||
|
"client::display": {
|
||||||
|
"contentType": "text/markdown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let body = serde_json::to_vec(&body)
|
let body = serde_json::to_vec(&body)
|
||||||
.map_err(|err| Error::NotifyFailed(self.name().to_string(), err.into()))?;
|
.map_err(|err| Error::NotifyFailed(self.name().to_string(), err.into()))?;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA};
|
use crate::renderer::TemplateRenderer;
|
||||||
use crate::{Endpoint, Error, Notification};
|
use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA};
|
||||||
|
use crate::{renderer, Endpoint, Error, Notification};
|
||||||
|
|
||||||
use proxmox_schema::api_types::COMMENT_SCHEMA;
|
use proxmox_schema::api_types::COMMENT_SCHEMA;
|
||||||
use proxmox_schema::{api, Updater};
|
use proxmox_schema::{api, Updater};
|
||||||
@ -69,12 +70,17 @@ impl Endpoint for SendmailEndpoint {
|
|||||||
fn send(&self, notification: &Notification) -> Result<(), Error> {
|
fn send(&self, notification: &Notification) -> Result<(), Error> {
|
||||||
let recipients: Vec<&str> = self.config.mailto.iter().map(String::as_str).collect();
|
let recipients: Vec<&str> = self.config.mailto.iter().map(String::as_str).collect();
|
||||||
|
|
||||||
// Note: OX has serious problems displaying text mails,
|
let properties = notification.properties.as_ref();
|
||||||
// so we include html as well
|
|
||||||
let html = format!(
|
let subject = renderer::render_template(
|
||||||
"<html><body><pre>\n{}\n<pre>",
|
TemplateRenderer::Plaintext,
|
||||||
handlebars::html_escape(¬ification.body)
|
¬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_sys::email::sendmail will set the author to
|
||||||
// "Proxmox Backup Server" if it is not set.
|
// "Proxmox Backup Server" if it is not set.
|
||||||
@ -82,9 +88,9 @@ impl Endpoint for SendmailEndpoint {
|
|||||||
|
|
||||||
proxmox_sys::email::sendmail(
|
proxmox_sys::email::sendmail(
|
||||||
&recipients,
|
&recipients,
|
||||||
¬ification.title,
|
&subject,
|
||||||
Some(¬ification.body),
|
Some(&text_part),
|
||||||
Some(&html),
|
Some(&html_part),
|
||||||
self.config.from_address.as_deref(),
|
self.config.from_address.as_deref(),
|
||||||
author,
|
author,
|
||||||
)
|
)
|
||||||
|
@ -14,8 +14,9 @@ use std::error::Error as StdError;
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
mod config;
|
mod config;
|
||||||
pub mod endpoints;
|
pub mod endpoints;
|
||||||
mod filter;
|
pub mod filter;
|
||||||
pub mod group;
|
pub mod group;
|
||||||
|
pub mod renderer;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@ -26,6 +27,7 @@ pub enum Error {
|
|||||||
TargetDoesNotExist(String),
|
TargetDoesNotExist(String),
|
||||||
TargetTestFailed(Vec<Box<dyn StdError + Send + Sync>>),
|
TargetTestFailed(Vec<Box<dyn StdError + Send + Sync>>),
|
||||||
FilterFailed(String),
|
FilterFailed(String),
|
||||||
|
RenderError(Box<dyn StdError + Send + Sync>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for Error {
|
impl Display for Error {
|
||||||
@ -53,6 +55,7 @@ impl Display for Error {
|
|||||||
Error::FilterFailed(message) => {
|
Error::FilterFailed(message) => {
|
||||||
write!(f, "could not apply filter: {message}")
|
write!(f, "could not apply filter: {message}")
|
||||||
}
|
}
|
||||||
|
Error::RenderError(err) => write!(f, "could not render notification template: {err}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -66,6 +69,7 @@ impl StdError for Error {
|
|||||||
Error::TargetDoesNotExist(_) => None,
|
Error::TargetDoesNotExist(_) => None,
|
||||||
Error::TargetTestFailed(errs) => Some(&*errs[0]),
|
Error::TargetTestFailed(errs) => Some(&*errs[0]),
|
||||||
Error::FilterFailed(_) => None,
|
Error::FilterFailed(_) => None,
|
||||||
|
Error::RenderError(err) => Some(&**err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
100
proxmox-notify/src/renderer/html.rs
Normal file
100
proxmox-notify/src/renderer/html.rs
Normal file
@ -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),
|
||||||
|
}
|
||||||
|
}
|
366
proxmox-notify/src/renderer/mod.rs
Normal file
366
proxmox-notify/src/renderer/mod.rs
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
//! 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.
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_fn(&self) -> fn(&str) -> String {
|
||||||
|
match self {
|
||||||
|
TemplateRenderer::Html => handlebars::html_escape,
|
||||||
|
TemplateRenderer::Plaintext => handlebars::no_escape,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>,
|
||||||
|
renderer: TemplateRenderer,
|
||||||
|
) -> Result<String, Error> {
|
||||||
|
let properties = properties.unwrap_or(&Value::Null);
|
||||||
|
|
||||||
|
let mut handlebars = Handlebars::new();
|
||||||
|
handlebars.register_escape_fn(renderer.escape_fn());
|
||||||
|
|
||||||
|
let block_render_fns = renderer.block_render_fns();
|
||||||
|
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());
|
||||||
|
|
||||||
|
rendered_template.push_str(&render_template_impl(template, properties, renderer)?);
|
||||||
|
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!({
|
||||||
|
"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}}
|
||||||
|
|
||||||
|
{{table table}}"#;
|
||||||
|
|
||||||
|
let expected_plaintext = r#"
|
||||||
|
Hello World
|
||||||
|
===========
|
||||||
|
|
||||||
|
Hello World
|
||||||
|
-----------
|
||||||
|
|
||||||
|
15 KiB
|
||||||
|
3h 25min 45s
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
141
proxmox-notify/src/renderer/plaintext.rs
Normal file
141
proxmox-notify/src/renderer/plaintext.rs
Normal file
@ -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),
|
||||||
|
}
|
||||||
|
}
|
24
proxmox-notify/src/renderer/table.rs
Normal file
24
proxmox-notify/src/renderer/table.rs
Normal file
@ -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>>,
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user