1use crate::alerts;
4use crate::alerts_ops::source_str;
5use crate::cases::{self, Case, CaseStatus, NewCase};
6use crate::profiles;
7use elasticctl_core::{Error, ErrorKind, Result, Transport, urlencode};
8use serde_json::Value;
9use std::collections::{BTreeMap, BTreeSet};
10
11pub const PAGE_SIZE: u32 = 100;
13
14#[derive(Debug, Clone, Default)]
15pub struct CaseFilter {
16 pub status: Option<CaseStatus>,
17 pub severity: Option<String>,
18 pub tag: Option<String>,
19 pub search: Option<String>,
21}
22
23pub fn find_query(f: &CaseFilter, page: u32, per_page: u32) -> String {
26 let mut q = format!("page={page}&perPage={per_page}&sortField=createdAt&sortOrder=desc");
27 if let Some(status) = f.status {
28 q.push_str(&format!("&status={}", status.as_str()));
29 }
30 if let Some(severity) = &f.severity {
31 q.push_str(&format!("&severity={}", urlencode(severity)));
32 }
33 if let Some(tag) = &f.tag {
34 q.push_str(&format!("&tags={}", urlencode(tag)));
35 }
36 if let Some(search) = &f.search {
37 q.push_str(&format!(
38 "&search={}&searchFields=title&searchFields=description",
39 urlencode(search)
40 ));
41 }
42 q
43}
44
45#[derive(Debug, Clone, PartialEq)]
46pub struct CaseList {
47 pub cases: Vec<Case>,
48 pub total: u64,
49 pub truncated: bool,
50}
51
52pub async fn list(t: &Transport, f: &CaseFilter, limit: usize) -> Result<CaseList> {
55 let mut cases = Vec::new();
56 let mut total = 0;
57 let mut page = 1;
58 while cases.len() <= limit {
59 let (batch, batch_total) = cases::find_page(t, &find_query(f, page, PAGE_SIZE)).await?;
60 total = batch_total;
61 let got = batch.len();
62 cases.extend(batch);
63 if got < PAGE_SIZE as usize {
64 break;
65 }
66 page += 1;
67 }
68 let truncated = cases.len() > limit;
69 cases.truncate(limit);
70 Ok(CaseList {
71 cases,
72 total,
73 truncated,
74 })
75}
76
77pub async fn export(t: &Transport, f: &CaseFilter, limit: Option<usize>) -> Result<Vec<Case>> {
79 export_with_page_size(t, f, PAGE_SIZE, limit).await
80}
81
82pub async fn export_with_page_size(
86 t: &Transport,
87 f: &CaseFilter,
88 per_page: u32,
89 limit: Option<usize>,
90) -> Result<Vec<Case>> {
91 let mut all = Vec::new();
92 let mut page = 1;
93 loop {
94 let (batch, _) = cases::find_page(t, &find_query(f, page, per_page)).await?;
95 let got = batch.len();
96 all.extend(batch);
97 if let Some(limit) = limit
98 && all.len() >= limit
99 {
100 all.truncate(limit);
101 return Ok(all);
102 }
103 if got < per_page as usize {
104 return Ok(all);
105 }
106 page += 1;
107 }
108}
109
110pub async fn get_one(t: &Transport, id: &str) -> Result<Case> {
111 cases::get(t, id).await
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ResolvedCase {
116 pub id: String,
117 pub version: String,
118 pub title: String,
119 pub status: String,
120}
121
122fn case_noun(n: usize) -> &'static str {
123 if n == 1 { "case" } else { "cases" }
124}
125
126async fn resolve_cases(t: &Transport, ids: &[String]) -> Result<Vec<ResolvedCase>> {
129 let mut unique: Vec<String> = Vec::with_capacity(ids.len());
130 for id in ids {
131 if !unique.contains(id) {
132 unique.push(id.clone());
133 }
134 }
135 let mut resolved = Vec::with_capacity(unique.len());
136 let mut missing = Vec::new();
137 for id in &unique {
138 match cases::get(t, id).await {
139 Ok(case) => resolved.push(ResolvedCase {
140 id: case.id,
141 version: case.version,
142 title: case.title,
143 status: case.status,
144 }),
145 Err(e) if e.kind == ErrorKind::NotFound => missing.push(id.clone()),
146 Err(e) => return Err(e),
147 }
148 }
149 if !missing.is_empty() {
150 return Err(Error::new(
151 ErrorKind::NotFound,
152 format!("No case with id: {}", missing.join(", ")),
153 ));
154 }
155 Ok(resolved)
156}
157
158pub fn case_row(case: &Case) -> Value {
161 let mut row = serde_json::Map::new();
162 row.insert("id".into(), Value::String(case.id.clone()));
163 row.insert("title".into(), Value::String(case.title.clone()));
164 row.insert("status".into(), Value::String(case.status.clone()));
165 if let Some(severity) = &case.severity {
166 row.insert("severity".into(), Value::String(severity.clone()));
167 }
168 row.insert("tags".into(), serde_json::json!(case.tags));
169 if let Some(n) = case.total_comment {
170 row.insert("comments".into(), serde_json::json!(n));
171 }
172 if let Some(at) = &case.created_at {
173 row.insert("created_at".into(), Value::String(at.clone()));
174 }
175 if let Some(at) = &case.updated_at {
176 row.insert("updated_at".into(), Value::String(at.clone()));
177 }
178 Value::Object(row)
179}
180
181#[derive(Debug, Clone, PartialEq, serde::Serialize)]
182pub struct CaseEditReport {
183 pub applied: bool,
184 pub total: u64,
185 pub updated: u64,
186 pub failed: u64,
189 pub failures: Vec<String>,
194}
195
196#[derive(Debug, Clone, PartialEq)]
197pub struct CreatePlan {
198 pub new: NewCase,
199 pub preview_action: String,
200 pub preview_details: Vec<String>,
201}
202
203pub async fn plan_create(
204 t: &Transport,
205 title: &str,
206 description: Option<String>,
207 tags: Vec<String>,
208 severity: Option<String>,
209 assignees: &[String],
210) -> Result<CreatePlan> {
211 if title.trim().is_empty() {
212 return Err(Error::new(
213 ErrorKind::Error,
214 "a case needs a non-empty --title",
215 ));
216 }
217 let mut details = Vec::new();
218 if let Some(severity) = &severity {
219 details.push(format!("severity: {severity}"));
220 }
221 if !tags.is_empty() {
222 details.push(format!("tags: {}", tags.join(", ")));
223 }
224 let mut assignee_uids = Vec::with_capacity(assignees.len());
225 for user in assignees {
226 let uid = profiles::resolve_assignee(t, user).await?;
227 details.push(format!("assign {user} -> {uid}"));
228 assignee_uids.push(uid);
229 }
230 Ok(CreatePlan {
231 preview_action: format!("Create case '{title}'"),
232 new: NewCase {
233 title: title.to_string(),
234 description,
235 tags,
236 severity,
237 assignee_uids,
238 },
239 preview_details: details,
240 })
241}
242
243pub async fn apply_create(t: &Transport, plan: &CreatePlan) -> Result<Value> {
244 let case = cases::create(t, &plan.new).await?;
245 let mut row = case_row(&case);
246 if let Some(obj) = row.as_object_mut() {
247 obj.insert("applied".into(), Value::Bool(true));
248 }
249 Ok(row)
250}
251
252#[derive(Debug, Clone, PartialEq)]
253pub struct StatusPlan {
254 pub target: CaseStatus,
255 pub updates: Vec<(String, String, CaseStatus)>,
257 pub resolved: usize,
260 pub preview_action: String,
261 pub preview_details: Vec<String>,
262}
263
264pub async fn plan_status(t: &Transport, ids: &[String], target: CaseStatus) -> Result<StatusPlan> {
267 let resolved = resolve_cases(t, ids).await?;
268 let mut updates = Vec::new();
269 let mut details = Vec::new();
270 for case in &resolved {
271 if case.status == target.as_str() {
272 details.push(format!(
273 "{} {} already {}",
274 case.id, case.title, case.status
275 ));
276 } else {
277 details.push(format!(
278 "{} {} {} -> {}",
279 case.id,
280 case.title,
281 case.status,
282 target.as_str()
283 ));
284 updates.push((case.id.clone(), case.version.clone(), target));
285 }
286 }
287 Ok(StatusPlan {
288 preview_action: format!(
289 "{} {} {}",
290 target.verb(),
291 resolved.len(),
292 case_noun(resolved.len())
293 ),
294 target,
295 updates,
296 resolved: resolved.len(),
297 preview_details: details,
298 })
299}
300
301pub async fn apply_status(t: &Transport, plan: &StatusPlan) -> Result<CaseEditReport> {
302 if plan.updates.is_empty() {
303 return Ok(CaseEditReport {
304 applied: true,
305 total: 0,
306 updated: 0,
307 failed: 0,
308 failures: Vec::new(),
309 });
310 }
311 let mut expected = BTreeMap::new();
312 for (id, _, status) in &plan.updates {
313 if *status != plan.target {
314 return Err(Error::new(
315 ErrorKind::Error,
316 format!(
317 "case '{id}' status '{}' does not match plan target '{}'",
318 status.as_str(),
319 plan.target.as_str()
320 ),
321 ));
322 }
323 if expected.insert(id.as_str(), *status).is_some() {
324 return Err(Error::new(
325 ErrorKind::Error,
326 format!("duplicate case id in status plan: '{id}'"),
327 ));
328 }
329 }
330 let updated = cases::patch_status(t, &plan.updates).await.map_err(|e| {
331 if e.kind == ErrorKind::Conflict {
332 Error::new(
333 ErrorKind::Conflict,
334 format!(
335 "a case changed since the preview ({}); re-run the command",
336 e.message
337 ),
338 )
339 } else {
340 e
341 }
342 })?;
343 if updated.len() != expected.len() {
344 return Err(Error::new(
345 ErrorKind::Http,
346 format!(
347 "decoding case status response: expected {} cases, got {}",
348 expected.len(),
349 updated.len()
350 ),
351 ));
352 }
353 let mut returned_ids = BTreeSet::new();
354 for case in &updated {
355 if !returned_ids.insert(case.id.as_str()) {
356 return Err(Error::new(
357 ErrorKind::Http,
358 format!(
359 "decoding case status response: duplicate case id '{}'",
360 case.id
361 ),
362 ));
363 }
364 let requested_status = expected.get(case.id.as_str()).ok_or_else(|| {
365 Error::new(
366 ErrorKind::Http,
367 format!(
368 "decoding case status response: unexpected case id '{}'",
369 case.id
370 ),
371 )
372 })?;
373 if case.status != requested_status.as_str() {
374 return Err(Error::new(
375 ErrorKind::Http,
376 format!(
377 "decoding case status response: case '{}' has status '{}', expected '{}'",
378 case.id,
379 case.status,
380 requested_status.as_str()
381 ),
382 ));
383 }
384 }
385 let total = expected.len() as u64;
386 Ok(CaseEditReport {
387 applied: true,
388 total,
389 updated: total,
390 failed: 0,
391 failures: Vec::new(),
392 })
393}
394
395#[derive(Debug, Clone, PartialEq)]
396pub struct DeletePlan {
397 pub targets: Vec<String>,
398 pub preview_action: String,
399 pub preview_details: Vec<String>,
400}
401
402pub async fn plan_delete(t: &Transport, ids: &[String]) -> Result<DeletePlan> {
404 let resolved = resolve_cases(t, ids).await?;
405 Ok(DeletePlan {
406 preview_action: format!(
407 "Delete {} {} permanently",
408 resolved.len(),
409 case_noun(resolved.len())
410 ),
411 preview_details: resolved
412 .iter()
413 .map(|c| format!("{} {} ({})", c.id, c.title, c.status))
414 .collect(),
415 targets: resolved.into_iter().map(|c| c.id).collect(),
416 })
417}
418
419pub async fn apply_delete(t: &Transport, plan: &DeletePlan) -> Result<CaseEditReport> {
420 cases::delete(t, &plan.targets).await?;
421 Ok(CaseEditReport {
422 applied: true,
423 total: plan.targets.len() as u64,
424 updated: plan.targets.len() as u64,
425 failed: 0,
426 failures: Vec::new(),
427 })
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct AttachGroup {
432 pub rule_id: String,
433 pub rule_name: String,
434 pub alert_ids: Vec<String>,
435 pub indices: Vec<String>,
436}
437
438#[derive(Debug, Clone, PartialEq)]
439pub struct AttachPlan {
440 pub case_id: String,
441 pub groups: Vec<AttachGroup>,
442 pub resolved: usize,
445 pub preview_action: String,
446 pub preview_details: Vec<String>,
447}
448
449pub async fn plan_attach(t: &Transport, case_id: &str, alert_ids: &[String]) -> Result<AttachPlan> {
453 if alert_ids.is_empty() {
454 return Err(Error::new(ErrorKind::Error, "pass at least one --alert id"));
455 }
456 let case = cases::get(t, case_id).await?;
457 let mut unique: Vec<String> = Vec::with_capacity(alert_ids.len());
458 for id in alert_ids {
459 if !unique.contains(id) {
460 unique.push(id.clone());
461 }
462 }
463 let body = serde_json::json!({
464 "query": {"ids": {"values": unique}},
465 "size": unique.len(),
466 "_source": ["kibana.alert.rule.name", "kibana.alert.rule.uuid", "kibana.alert.workflow_status"],
467 });
468 let page = alerts::search(t, &body).await?;
469 let missing: Vec<&str> = unique
470 .iter()
471 .filter(|id| !page.hits.iter().any(|h| &h.id == *id))
472 .map(String::as_str)
473 .collect();
474 if !missing.is_empty() {
475 return Err(Error::new(
476 ErrorKind::NotFound,
477 format!("No alert with id: {}", missing.join(", ")),
478 ));
479 }
480 let mut groups: Vec<AttachGroup> = Vec::new();
481 let mut details = Vec::new();
482 for id in &unique {
483 let hit = page
484 .hits
485 .iter()
486 .find(|h| &h.id == id)
487 .expect("checked above");
488 let rule_id = source_str(&hit.source, "kibana.alert.rule.uuid")
489 .ok_or_else(|| {
490 Error::new(
491 ErrorKind::Http,
492 "decoding alert field `kibana.alert.rule.uuid`",
493 )
494 })?
495 .to_string();
496 let rule_name = source_str(&hit.source, "kibana.alert.rule.name")
497 .unwrap_or("(unnamed rule)")
498 .to_string();
499 let index = hit.index.clone().ok_or_else(|| {
500 Error::new(
501 ErrorKind::Http,
502 "decoding alert field `_index` (needed to attach)",
503 )
504 })?;
505 details.push(format!("{} {}", id, rule_name));
506 match groups.iter_mut().find(|g| g.rule_id == rule_id) {
507 Some(group) => {
508 group.alert_ids.push(id.clone());
509 group.indices.push(index);
510 }
511 None => groups.push(AttachGroup {
512 rule_id,
513 rule_name,
514 alert_ids: vec![id.clone()],
515 indices: vec![index],
516 }),
517 }
518 }
519 Ok(AttachPlan {
520 preview_action: format!(
521 "Attach {} {} to case '{}'",
522 unique.len(),
523 if unique.len() == 1 { "alert" } else { "alerts" },
524 case.title
525 ),
526 case_id: case.id,
527 groups,
528 resolved: unique.len(),
529 preview_details: details,
530 })
531}
532
533pub async fn apply_attach(t: &Transport, plan: &AttachPlan) -> Result<CaseEditReport> {
540 let total = plan.resolved as u64;
541 let mut attached = 0u64;
542 let mut failures = Vec::new();
543 for group in &plan.groups {
544 match cases::attach_alerts(
545 t,
546 &plan.case_id,
547 &group.alert_ids,
548 &group.indices,
549 &group.rule_id,
550 &group.rule_name,
551 )
552 .await
553 {
554 Ok(_) => attached += group.alert_ids.len() as u64,
555 Err(e) => failures.push(format!("{}: {}", group.rule_name, e.message)),
556 }
557 }
558 Ok(CaseEditReport {
559 applied: true,
560 total,
561 updated: attached,
562 failed: total.abs_diff(attached),
566 failures,
567 })
568}
569
570#[derive(Debug, Clone, PartialEq)]
571pub struct CommentPlan {
572 pub case_id: String,
573 pub message: String,
574 pub preview_action: String,
575 pub preview_details: Vec<String>,
576}
577
578pub async fn plan_comment(t: &Transport, case_id: &str, message: &str) -> Result<CommentPlan> {
579 if message.trim().is_empty() {
580 return Err(Error::new(ErrorKind::Error, "pass a non-empty --message"));
581 }
582 let case = cases::get(t, case_id).await?;
583 Ok(CommentPlan {
584 preview_action: format!("Comment on case '{}'", case.title),
585 preview_details: vec![message.to_string()],
586 case_id: case.id,
587 message: message.to_string(),
588 })
589}
590
591pub async fn apply_comment(t: &Transport, plan: &CommentPlan) -> Result<CaseEditReport> {
592 cases::add_comment(t, &plan.case_id, &plan.message).await?;
593 Ok(CaseEditReport {
594 applied: true,
595 total: 1,
596 updated: 1,
597 failed: 0,
598 failures: Vec::new(),
599 })
600}