Add format_event_type() and calculate_duration() helpers to convert raw timeline events into human-readable tables and metrics. RCA now includes an Incident Timeline section and Incident Metrics (event count, duration, time-to-root-cause). Postmortem replaces placeholder timeline rows with real events, calculates impact duration, and auto-populates What Went Well from evidence. 10 new Rust tests covering timeline rendering, duration calculation, and event type formatting.
472 lines
16 KiB
Rust
472 lines
16 KiB
Rust
use crate::db::models::IssueDetail;
|
|
|
|
pub fn format_event_type(event_type: &str) -> &str {
|
|
match event_type {
|
|
"triage_started" => "Triage Started",
|
|
"log_uploaded" => "Log File Uploaded",
|
|
"why_level_advanced" => "Why Level Advanced",
|
|
"root_cause_identified" => "Root Cause Identified",
|
|
"rca_generated" => "RCA Document Generated",
|
|
"postmortem_generated" => "Post-Mortem Generated",
|
|
"document_exported" => "Document Exported",
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
pub fn calculate_duration(start: &str, end: &str) -> String {
|
|
let fmt = "%Y-%m-%d %H:%M:%S UTC";
|
|
let start_dt = match chrono::NaiveDateTime::parse_from_str(start, fmt) {
|
|
Ok(dt) => dt,
|
|
Err(_) => return "N/A".to_string(),
|
|
};
|
|
let end_dt = match chrono::NaiveDateTime::parse_from_str(end, fmt) {
|
|
Ok(dt) => dt,
|
|
Err(_) => return "N/A".to_string(),
|
|
};
|
|
|
|
let duration = end_dt.signed_duration_since(start_dt);
|
|
let total_minutes = duration.num_minutes();
|
|
if total_minutes < 0 {
|
|
return "N/A".to_string();
|
|
}
|
|
|
|
let days = total_minutes / (24 * 60);
|
|
let hours = (total_minutes % (24 * 60)) / 60;
|
|
let minutes = total_minutes % 60;
|
|
|
|
if days > 0 {
|
|
format!("{days}d {hours}h")
|
|
} else if hours > 0 {
|
|
format!("{hours}h {minutes}m")
|
|
} else {
|
|
format!("{minutes}m")
|
|
}
|
|
}
|
|
|
|
pub fn generate_rca_markdown(detail: &IssueDetail) -> String {
|
|
let issue = &detail.issue;
|
|
|
|
let mut md = String::new();
|
|
|
|
md.push_str(&format!(
|
|
"# Root Cause Analysis: {title}\n\n",
|
|
title = issue.title
|
|
));
|
|
|
|
md.push_str("## Issue Summary\n\n");
|
|
md.push_str("| Field | Value |\n");
|
|
md.push_str("|-------|-------|\n");
|
|
md.push_str(&format!("| **Issue ID** | {id} |\n", id = issue.id));
|
|
md.push_str(&format!(
|
|
"| **Category** | {category} |\n",
|
|
category = issue.category
|
|
));
|
|
md.push_str(&format!(
|
|
"| **Status** | {status} |\n",
|
|
status = issue.status
|
|
));
|
|
md.push_str(&format!(
|
|
"| **Severity** | {severity} |\n",
|
|
severity = issue.severity
|
|
));
|
|
md.push_str(&format!(
|
|
"| **Source** | {source} |\n",
|
|
source = issue.source
|
|
));
|
|
md.push_str(&format!(
|
|
"| **Assigned To** | {} |\n",
|
|
if issue.assigned_to.is_empty() {
|
|
"Unassigned"
|
|
} else {
|
|
&issue.assigned_to
|
|
}
|
|
));
|
|
md.push_str(&format!(
|
|
"| **Created** | {created_at} |\n",
|
|
created_at = issue.created_at
|
|
));
|
|
md.push_str(&format!(
|
|
"| **Last Updated** | {updated_at} |\n",
|
|
updated_at = issue.updated_at
|
|
));
|
|
if let Some(ref resolved) = issue.resolved_at {
|
|
md.push_str(&format!("| **Resolved** | {resolved} |\n"));
|
|
}
|
|
md.push('\n');
|
|
|
|
if !issue.description.is_empty() {
|
|
md.push_str("## Description\n\n");
|
|
md.push_str(&issue.description);
|
|
md.push_str("\n\n");
|
|
}
|
|
|
|
// Incident Timeline
|
|
md.push_str("## Incident Timeline\n\n");
|
|
if detail.timeline_events.is_empty() {
|
|
md.push_str("_No timeline events recorded._\n\n");
|
|
} else {
|
|
md.push_str("| Time (UTC) | Event | Description |\n");
|
|
md.push_str("|------------|-------|-------------|\n");
|
|
for event in &detail.timeline_events {
|
|
md.push_str(&format!(
|
|
"| {} | {} | {} |\n",
|
|
event.created_at,
|
|
format_event_type(&event.event_type),
|
|
event.description
|
|
));
|
|
}
|
|
md.push('\n');
|
|
}
|
|
|
|
// Incident Metrics
|
|
md.push_str("## Incident Metrics\n\n");
|
|
md.push_str(&format!(
|
|
"- **Total Events:** {}\n",
|
|
detail.timeline_events.len()
|
|
));
|
|
if detail.timeline_events.len() >= 2 {
|
|
let first = &detail.timeline_events[0].created_at;
|
|
let last = &detail.timeline_events[detail.timeline_events.len() - 1].created_at;
|
|
md.push_str(&format!(
|
|
"- **Incident Duration:** {}\n",
|
|
calculate_duration(first, last)
|
|
));
|
|
} else {
|
|
md.push_str("- **Incident Duration:** N/A\n");
|
|
}
|
|
let root_cause_event = detail
|
|
.timeline_events
|
|
.iter()
|
|
.find(|e| e.event_type == "root_cause_identified");
|
|
if let (Some(first), Some(rc)) = (detail.timeline_events.first(), root_cause_event) {
|
|
md.push_str(&format!(
|
|
"- **Time to Root Cause:** {}\n",
|
|
calculate_duration(&first.created_at, &rc.created_at)
|
|
));
|
|
}
|
|
md.push('\n');
|
|
|
|
// 5 Whys Analysis
|
|
md.push_str("## 5 Whys Analysis\n\n");
|
|
if detail.resolution_steps.is_empty() {
|
|
md.push_str("_No 5-whys analysis has been performed yet._\n\n");
|
|
} else {
|
|
for step in &detail.resolution_steps {
|
|
md.push_str(&format!(
|
|
"### Why #{}: {}\n\n",
|
|
step.step_order, step.why_question
|
|
));
|
|
if !step.answer.is_empty() {
|
|
md.push_str(&format!("**Answer:** {answer}\n\n", answer = step.answer));
|
|
} else {
|
|
md.push_str("_Awaiting answer._\n\n");
|
|
}
|
|
if !step.evidence.is_empty() {
|
|
md.push_str(&format!(
|
|
"**Evidence:** {evidence}\n\n",
|
|
evidence = step.evidence
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Root Cause
|
|
md.push_str("## Root Cause\n\n");
|
|
if let Some(last_step) = detail.resolution_steps.last() {
|
|
if !last_step.answer.is_empty() {
|
|
md.push_str(&format!(
|
|
"Based on the 5-whys analysis, the root cause is:\n\n> {}\n\n",
|
|
last_step.answer
|
|
));
|
|
} else {
|
|
md.push_str(
|
|
"_The 5-whys analysis is incomplete. Complete it to identify the root cause._\n\n",
|
|
);
|
|
}
|
|
} else {
|
|
md.push_str("_Perform the 5-whys analysis to identify the root cause._\n\n");
|
|
}
|
|
|
|
// Log Files
|
|
md.push_str("## Log Files Analyzed\n\n");
|
|
if detail.log_files.is_empty() {
|
|
md.push_str("_No log files attached._\n\n");
|
|
} else {
|
|
md.push_str("| File | Size | Redacted | Hash |\n");
|
|
md.push_str("|------|------|----------|------|\n");
|
|
for lf in &detail.log_files {
|
|
md.push_str(&format!(
|
|
"| {} | {} bytes | {} | {}... |\n",
|
|
lf.file_name,
|
|
lf.file_size,
|
|
if lf.redacted { "Yes" } else { "No" },
|
|
&lf.content_hash[..8.min(lf.content_hash.len())],
|
|
));
|
|
}
|
|
md.push('\n');
|
|
}
|
|
|
|
// Corrective Actions
|
|
md.push_str("## Corrective Actions\n\n");
|
|
md.push_str("### Immediate Actions\n\n");
|
|
md.push_str("- [ ] _Document immediate mitigations taken_\n\n");
|
|
md.push_str("### Long-Term Actions\n\n");
|
|
md.push_str("- [ ] _Document preventive measures_\n");
|
|
md.push_str("- [ ] _Document monitoring improvements_\n\n");
|
|
|
|
// Lessons Learned
|
|
md.push_str("## Lessons Learned\n\n");
|
|
md.push_str("- _What went well during the response?_\n");
|
|
md.push_str("- _What could be improved?_\n");
|
|
md.push_str("- _What processes need updating?_\n\n");
|
|
|
|
md.push_str("---\n\n");
|
|
md.push_str(&format!(
|
|
"_Generated by Troubleshooting and RCA Assistant on {}_\n",
|
|
chrono::Utc::now().format("%Y-%m-%d %H:%M UTC")
|
|
));
|
|
|
|
md
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db::models::{Issue, IssueDetail, LogFile, ResolutionStep, TimelineEvent};
|
|
|
|
fn make_test_detail() -> IssueDetail {
|
|
IssueDetail {
|
|
issue: Issue {
|
|
id: "test-123".to_string(),
|
|
title: "Database connection timeout".to_string(),
|
|
description: "Users report 500 errors on login.".to_string(),
|
|
severity: "high".to_string(),
|
|
status: "investigating".to_string(),
|
|
category: "database".to_string(),
|
|
source: "manual".to_string(),
|
|
created_at: "2025-01-15 10:00:00".to_string(),
|
|
updated_at: "2025-01-15 12:00:00".to_string(),
|
|
resolved_at: None,
|
|
assigned_to: "oncall-eng".to_string(),
|
|
tags: "[]".to_string(),
|
|
},
|
|
log_files: vec![LogFile {
|
|
id: "lf-1".to_string(),
|
|
issue_id: "test-123".to_string(),
|
|
file_name: "app.log".to_string(),
|
|
file_path: "/tmp/app.log".to_string(),
|
|
file_size: 2048,
|
|
mime_type: "text/plain".to_string(),
|
|
content_hash: "abc123def456".to_string(),
|
|
uploaded_at: "2025-01-15 10:30:00".to_string(),
|
|
redacted: false,
|
|
}],
|
|
image_attachments: vec![],
|
|
resolution_steps: vec![
|
|
ResolutionStep {
|
|
id: "rs-1".to_string(),
|
|
issue_id: "test-123".to_string(),
|
|
step_order: 1,
|
|
why_question: "Why are users getting 500 errors?".to_string(),
|
|
answer: "The database connection pool is exhausted.".to_string(),
|
|
evidence: "Connection pool metrics show 100/100 used.".to_string(),
|
|
created_at: "2025-01-15 11:00:00".to_string(),
|
|
},
|
|
ResolutionStep {
|
|
id: "rs-2".to_string(),
|
|
issue_id: "test-123".to_string(),
|
|
step_order: 2,
|
|
why_question: "Why is the connection pool exhausted?".to_string(),
|
|
answer: "Queries are not being released after completion.".to_string(),
|
|
evidence: "".to_string(),
|
|
created_at: "2025-01-15 11:15:00".to_string(),
|
|
},
|
|
],
|
|
conversations: vec![],
|
|
timeline_events: vec![],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_rca_contains_title() {
|
|
let md = generate_rca_markdown(&make_test_detail());
|
|
assert!(md.contains("# Root Cause Analysis: Database connection timeout"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rca_contains_issue_summary_table() {
|
|
let md = generate_rca_markdown(&make_test_detail());
|
|
assert!(md.contains("| **Issue ID** | test-123 |"));
|
|
assert!(md.contains("| **Severity** | high |"));
|
|
assert!(md.contains("| **Assigned To** | oncall-eng |"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rca_contains_five_whys() {
|
|
let md = generate_rca_markdown(&make_test_detail());
|
|
assert!(md.contains("### Why #1: Why are users getting 500 errors?"));
|
|
assert!(md.contains("**Answer:** The database connection pool is exhausted."));
|
|
assert!(md.contains("**Evidence:** Connection pool metrics"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rca_contains_root_cause() {
|
|
let md = generate_rca_markdown(&make_test_detail());
|
|
assert!(md.contains("Queries are not being released after completion."));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rca_contains_log_files() {
|
|
let md = generate_rca_markdown(&make_test_detail());
|
|
assert!(md.contains("app.log"));
|
|
assert!(md.contains("2048 bytes"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rca_empty_steps_shows_placeholder() {
|
|
let mut detail = make_test_detail();
|
|
detail.resolution_steps.clear();
|
|
let md = generate_rca_markdown(&detail);
|
|
assert!(md.contains("No 5-whys analysis has been performed yet."));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rca_unassigned_shows_unassigned() {
|
|
let mut detail = make_test_detail();
|
|
detail.issue.assigned_to = String::new();
|
|
let md = generate_rca_markdown(&detail);
|
|
assert!(md.contains("Unassigned"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rca_timeline_section_with_events() {
|
|
let mut detail = make_test_detail();
|
|
detail.timeline_events = vec![
|
|
TimelineEvent {
|
|
id: "te-1".to_string(),
|
|
issue_id: "test-123".to_string(),
|
|
event_type: "triage_started".to_string(),
|
|
description: "Triage initiated by oncall".to_string(),
|
|
metadata: "{}".to_string(),
|
|
created_at: "2025-01-15 10:00:00 UTC".to_string(),
|
|
},
|
|
TimelineEvent {
|
|
id: "te-2".to_string(),
|
|
issue_id: "test-123".to_string(),
|
|
event_type: "log_uploaded".to_string(),
|
|
description: "app.log uploaded".to_string(),
|
|
metadata: "{}".to_string(),
|
|
created_at: "2025-01-15 10:30:00 UTC".to_string(),
|
|
},
|
|
TimelineEvent {
|
|
id: "te-3".to_string(),
|
|
issue_id: "test-123".to_string(),
|
|
event_type: "root_cause_identified".to_string(),
|
|
description: "Connection pool leak found".to_string(),
|
|
metadata: "{}".to_string(),
|
|
created_at: "2025-01-15 12:15:00 UTC".to_string(),
|
|
},
|
|
];
|
|
let md = generate_rca_markdown(&detail);
|
|
assert!(md.contains("## Incident Timeline"));
|
|
assert!(md.contains("| Time (UTC) | Event | Description |"));
|
|
assert!(md
|
|
.contains("| 2025-01-15 10:00:00 UTC | Triage Started | Triage initiated by oncall |"));
|
|
assert!(md.contains("| 2025-01-15 10:30:00 UTC | Log File Uploaded | app.log uploaded |"));
|
|
assert!(md.contains(
|
|
"| 2025-01-15 12:15:00 UTC | Root Cause Identified | Connection pool leak found |"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rca_timeline_section_empty() {
|
|
let detail = make_test_detail();
|
|
let md = generate_rca_markdown(&detail);
|
|
assert!(md.contains("## Incident Timeline"));
|
|
assert!(md.contains("_No timeline events recorded._"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rca_metrics_section() {
|
|
let mut detail = make_test_detail();
|
|
detail.timeline_events = vec![
|
|
TimelineEvent {
|
|
id: "te-1".to_string(),
|
|
issue_id: "test-123".to_string(),
|
|
event_type: "triage_started".to_string(),
|
|
description: "Triage started".to_string(),
|
|
metadata: "{}".to_string(),
|
|
created_at: "2025-01-15 10:00:00 UTC".to_string(),
|
|
},
|
|
TimelineEvent {
|
|
id: "te-2".to_string(),
|
|
issue_id: "test-123".to_string(),
|
|
event_type: "root_cause_identified".to_string(),
|
|
description: "Root cause found".to_string(),
|
|
metadata: "{}".to_string(),
|
|
created_at: "2025-01-15 12:15:00 UTC".to_string(),
|
|
},
|
|
];
|
|
let md = generate_rca_markdown(&detail);
|
|
assert!(md.contains("## Incident Metrics"));
|
|
assert!(md.contains("**Total Events:** 2"));
|
|
assert!(md.contains("**Incident Duration:** 2h 15m"));
|
|
assert!(md.contains("**Time to Root Cause:** 2h 15m"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_calculate_duration_hours_minutes() {
|
|
assert_eq!(
|
|
calculate_duration("2025-01-15 10:00:00 UTC", "2025-01-15 12:15:00 UTC"),
|
|
"2h 15m"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_calculate_duration_days() {
|
|
assert_eq!(
|
|
calculate_duration("2025-01-15 10:00:00 UTC", "2025-01-18 11:00:00 UTC"),
|
|
"3d 1h"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_calculate_duration_minutes_only() {
|
|
assert_eq!(
|
|
calculate_duration("2025-01-15 10:00:00 UTC", "2025-01-15 10:45:00 UTC"),
|
|
"45m"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_calculate_duration_invalid() {
|
|
assert_eq!(calculate_duration("bad-date", "also-bad"), "N/A");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_event_type_known() {
|
|
assert_eq!(format_event_type("triage_started"), "Triage Started");
|
|
assert_eq!(format_event_type("log_uploaded"), "Log File Uploaded");
|
|
assert_eq!(
|
|
format_event_type("why_level_advanced"),
|
|
"Why Level Advanced"
|
|
);
|
|
assert_eq!(
|
|
format_event_type("root_cause_identified"),
|
|
"Root Cause Identified"
|
|
);
|
|
assert_eq!(format_event_type("rca_generated"), "RCA Document Generated");
|
|
assert_eq!(
|
|
format_event_type("postmortem_generated"),
|
|
"Post-Mortem Generated"
|
|
);
|
|
assert_eq!(format_event_type("document_exported"), "Document Exported");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_event_type_unknown() {
|
|
assert_eq!(format_event_type("custom_event"), "custom_event");
|
|
assert_eq!(format_event_type(""), "");
|
|
}
|
|
}
|