use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NamedDashboard {
pub id: String,
pub title: String,
pub description: Option<String>,
pub default_range_seconds: Option<u64>,
pub sections: Vec<DashboardSection>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DashboardSection {
pub title: Option<String>,
pub description: Option<String>,
pub items: Vec<DashboardItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum DashboardItem {
Stat {
query_id: String,
label: Option<String>,
unit: Option<String>,
},
TimeSeries {
query_id: String,
label: Option<String>,
unit: Option<String>,
preferred_bucket_seconds: Option<u64>,
},
Table {
query_id: String,
label: Option<String>,
unit: Option<String>,
},
HealthSummary {
label: Option<String>,
},
Links {
links: Vec<DashboardLink>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DashboardLink {
pub label: String,
pub href: String,
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id.trim() == id
&& id.is_ascii()
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._:-".contains(&b))
}
fn valid_href(href: &str) -> bool {
!href.is_empty()
&& href.chars().count() <= 512
&& !href.chars().any(|c| c.is_ascii_control())
&& !href.contains('\\')
&& (href.starts_with("http://")
|| href.starts_with("https://")
|| (href.starts_with('/') && !href.starts_with("//")))
}
impl NamedDashboard {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Result<Self, String> {
let id = id.into();
if !valid_id(&id) {
return Err(format!(
"invalid dashboard id {id:?}: must be non-empty, trimmed, ASCII, at most 128 bytes, chars [A-Za-z0-9._:-]"
));
}
let title = title.into();
if title.trim().is_empty() || title.chars().count() > 256 {
return Err(format!(
"invalid dashboard title {title:?}: must be non-empty after trim and at most 256 characters"
));
}
Ok(Self {
id,
title,
description: None,
default_range_seconds: None,
sections: Vec::new(),
})
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn default_range_seconds(mut self, seconds: u64) -> Self {
self.default_range_seconds = Some(seconds);
self
}
pub fn section(mut self, section: DashboardSection) -> Self {
self.sections.push(section);
self
}
}
impl DashboardSection {
pub fn new() -> Self {
Self {
title: None,
description: None,
items: Vec::new(),
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn item(mut self, item: DashboardItem) -> Self {
self.items.push(item);
self
}
}
impl Default for DashboardSection {
fn default() -> Self {
Self::new()
}
}
impl DashboardItem {
pub fn stat(query_id: impl Into<String>) -> Self {
Self::Stat {
query_id: query_id.into(),
label: None,
unit: None,
}
}
pub fn time_series(query_id: impl Into<String>) -> Self {
Self::TimeSeries {
query_id: query_id.into(),
label: None,
unit: None,
preferred_bucket_seconds: None,
}
}
pub fn table(query_id: impl Into<String>) -> Self {
Self::Table {
query_id: query_id.into(),
label: None,
unit: None,
}
}
pub fn health_summary() -> Self {
Self::HealthSummary { label: None }
}
pub fn links(links: Vec<DashboardLink>) -> Self {
Self::Links { links }
}
pub fn label(mut self, label: impl Into<String>) -> Self {
let new_label = Some(label.into());
match &mut self {
Self::Stat { label, .. }
| Self::TimeSeries { label, .. }
| Self::Table { label, .. }
| Self::HealthSummary { label } => *label = new_label,
Self::Links { .. } => {}
}
self
}
pub fn unit(mut self, unit: impl Into<String>) -> Self {
let new_unit = Some(unit.into());
match &mut self {
Self::Stat { unit, .. } | Self::TimeSeries { unit, .. } | Self::Table { unit, .. } => {
*unit = new_unit
}
Self::HealthSummary { .. } | Self::Links { .. } => {}
}
self
}
pub fn preferred_bucket_seconds(mut self, seconds: u64) -> Self {
if let Self::TimeSeries {
preferred_bucket_seconds,
..
} = &mut self
{
*preferred_bucket_seconds = Some(seconds);
}
self
}
}
impl DashboardLink {
pub fn new(label: impl Into<String>, href: impl Into<String>) -> Result<Self, String> {
let label = label.into();
if label.trim().is_empty() || label.chars().count() > 256 {
return Err(format!(
"invalid dashboard link label {label:?}: must be non-empty after trim and at most 256 characters"
));
}
let href = href.into();
if !valid_href(&href) {
return Err(format!(
"invalid dashboard link href {href:?}: must be a root-relative path (not //), an http(s) URL, at most 512 characters, with no backslash or control characters"
));
}
Ok(Self { label, href })
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn named_dashboard_new_validates_id_and_title() {
for bad_id in [
"",
" leading",
"trailing ",
"id with spaces",
"non-ascii-é",
&"x".repeat(129),
"bad!char",
] {
let error = NamedDashboard::new(bad_id, "Title").unwrap_err();
assert!(error.contains("invalid dashboard id"), "{error}");
}
for bad_title in ["", " ", &"x".repeat(257)] {
let error = NamedDashboard::new("api-overview", bad_title).unwrap_err();
assert!(error.contains("invalid dashboard title"), "{error}");
}
let dashboard = NamedDashboard::new("api-overview", "API Overview")
.unwrap()
.description("Front-door health")
.default_range_seconds(3600)
.section(DashboardSection::new().title("Traffic"));
assert_eq!(
serde_json::to_value(&dashboard).unwrap(),
json!({
"id": "api-overview",
"title": "API Overview",
"description": "Front-door health",
"default_range_seconds": 3600,
"sections": [{ "title": "Traffic", "description": null, "items": [] }],
})
);
}
#[test]
fn dashboard_link_new_validates_href_grammar() {
for bad_href in [
"foo/bar", "//host/path", "\\host", "/\\host", "ftp://host/x", "/a\u{0000}b", &format!("/{}", "x".repeat(512)), ] {
let error = DashboardLink::new("Label", bad_href).unwrap_err();
assert!(error.contains("invalid dashboard link href"), "{error}");
}
for bad_label in ["", " ", &"x".repeat(257)] {
let error = DashboardLink::new(bad_label, "/requests").unwrap_err();
assert!(error.contains("invalid dashboard link label"), "{error}");
}
let root_relative =
DashboardLink::new("Requests view", "/orgs/00000000-0000-0000-0000-000000000000/apps/11111111-1111-1111-1111-111111111111/requests")
.unwrap();
assert_eq!(
serde_json::to_value(&root_relative).unwrap(),
json!({ "label": "Requests view", "href": "/orgs/00000000-0000-0000-0000-000000000000/apps/11111111-1111-1111-1111-111111111111/requests" })
);
let absolute = DashboardLink::new("Docs", "https://example.com/x").unwrap();
assert_eq!(
serde_json::to_value(&absolute).unwrap(),
json!({ "label": "Docs", "href": "https://example.com/x" })
);
assert!(DashboardLink::new("L", "/orgs/\\host").is_err());
}
#[test]
fn item_kinds_serialize_the_wire_tagging() {
let stat = DashboardItem::stat("http.request_count.total").label("Requests");
assert_eq!(
serde_json::to_value(&stat).unwrap(),
json!({ "kind": "stat", "query_id": "http.request_count.total", "label": "Requests", "unit": null })
);
let time_series = DashboardItem::time_series("http.request_count")
.unit("requests")
.preferred_bucket_seconds(300);
assert_eq!(
serde_json::to_value(&time_series).unwrap(),
json!({ "kind": "time_series", "query_id": "http.request_count", "label": null, "unit": "requests", "preferred_bucket_seconds": 300 })
);
let table = DashboardItem::table("http.top_routes");
assert_eq!(
serde_json::to_value(&table).unwrap(),
json!({ "kind": "table", "query_id": "http.top_routes", "label": null, "unit": null })
);
let health = DashboardItem::health_summary().label("Service health");
assert_eq!(
serde_json::to_value(&health).unwrap(),
json!({ "kind": "health_summary", "label": "Service health" })
);
let links = DashboardItem::links(vec![DashboardLink::new("Home", "/").unwrap()]);
assert_eq!(
serde_json::to_value(&links).unwrap(),
json!({ "kind": "links", "links": [{ "label": "Home", "href": "/" }] })
);
}
#[test]
fn setters_are_documented_no_ops_on_kinds_without_the_field() {
let health = DashboardItem::health_summary().unit("requests");
assert_eq!(health, DashboardItem::HealthSummary { label: None });
let stat = DashboardItem::stat("m").preferred_bucket_seconds(300);
assert_eq!(
stat,
DashboardItem::Stat {
query_id: "m".into(),
label: None,
unit: None,
}
);
let links = DashboardItem::links(vec![]).label("Nope");
assert_eq!(links, DashboardItem::Links { links: vec![] });
}
}