elasticctl_api/rules/
preview_execution.rs1use super::BASE;
4use crate::model::Rule;
5use elasticctl_core::{Error, ErrorKind, Result, Transport, urlencode};
6use serde_json::{Value, json};
7
8#[derive(Debug, Clone, Default, PartialEq)]
9pub struct PreviewResult {
10 pub preview_id: Option<String>,
11 pub errors: Vec<String>,
12 pub warnings: Vec<String>,
13}
14
15pub async fn preview(
20 t: &Transport,
21 rule: &Rule,
22 invocation_count: u32,
23 timeframe_end: &str,
24) -> Result<PreviewResult> {
25 let mut body = rule.as_map().clone();
26 for k in [
28 "rule_id",
29 "id",
30 "immutable",
31 "rule_source",
32 "revision",
33 "version",
34 ] {
35 body.remove(k);
36 }
37 body.insert("invocationCount".into(), json!(invocation_count));
38 body.insert("timeframeEnd".into(), json!(timeframe_end));
39
40 let response = t
41 .post(&format!("{BASE}/preview"), Some(&Value::Object(body)))
42 .await?;
43
44 let collect = |key: &str| -> Vec<String> {
45 response["logs"]
46 .as_array()
47 .map(|logs| {
48 logs.iter()
49 .filter_map(|l| l.get(key)?.as_array())
50 .flatten()
51 .filter_map(|v| v.as_str().map(str::to_owned))
52 .collect()
53 })
54 .unwrap_or_default()
55 };
56
57 Ok(PreviewResult {
58 preview_id: response["previewId"].as_str().map(str::to_owned),
59 errors: collect("errors"),
60 warnings: collect("warnings"),
61 })
62}
63
64pub const PREVIEW_ALERTS_INDEX_PREFIX: &str = ".preview.alerts-security.alerts-";
69
70#[derive(Debug, Clone, Default, PartialEq)]
71pub struct PreviewHits {
72 pub total: u64,
73 pub sample: Vec<Value>,
76}
77
78pub async fn preview_hits(
84 t: &Transport,
85 space: &str,
86 preview_id: &str,
87 sample: usize,
88) -> Result<PreviewHits> {
89 let space = if space.is_empty() { "default" } else { space };
90 let index = urlencode(&format!("{PREVIEW_ALERTS_INDEX_PREFIX}{space}"));
91 let body = json!({
92 "size": sample,
93 "track_total_hits": true,
95 "query": {"term": {"kibana.alert.rule.uuid": preview_id}},
96 "sort": [{"@timestamp": {"order": "desc"}}]
97 });
98
99 let response = t
100 .post_absolute_es(&format!("/{index}/_search?ignore_unavailable=true"), &body)
101 .await?;
102
103 decode_preview_hits_checked(&response)
104}
105
106pub fn decode_preview_hits_checked(response: &Value) -> Result<PreviewHits> {
108 let map = response
109 .as_object()
110 .ok_or_else(|| preview_error("response", "must be a JSON object"))?;
111 let hits = map
112 .get("hits")
113 .and_then(Value::as_object)
114 .ok_or_else(|| preview_error("hits", "must be an object"))?;
115 let total = hits
116 .get("total")
117 .and_then(|total| total.get("value"))
118 .and_then(Value::as_u64)
119 .ok_or_else(|| preview_error("hits.total.value", "must be an unsigned integer"))?;
120 let sample = hits
121 .get("hits")
122 .and_then(Value::as_array)
123 .ok_or_else(|| preview_error("hits.hits", "must be an array"))?
124 .iter()
125 .map(|h| {
126 json!({
127 "_id": h.get("_id").cloned().unwrap_or(Value::Null),
128 "_source": h.get("_source").cloned().unwrap_or(Value::Null),
129 })
130 })
131 .collect();
132
133 Ok(PreviewHits { total, sample })
134}
135
136fn preview_error(field: &str, detail: impl std::fmt::Display) -> Error {
137 Error::new(
138 ErrorKind::Http,
139 format!("decoding preview-hits response field {field}: {detail}"),
140 )
141}
142
143pub fn decode_preview_hits(response: &Value) -> PreviewHits {
148 decode_preview_hits_checked(response).unwrap_or_default()
149}