From 79a623dbb251bf5293412258172a1c407efb9555 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Sun, 19 Apr 2026 18:13:30 -0500 Subject: [PATCH] feat: populate RCA and postmortem docs with real timeline data 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. --- src-tauri/src/db/migrations.rs | 4 +- src-tauri/src/db/models.rs | 11 +- src-tauri/src/docs/postmortem.rs | 113 +++++++++++++++- src-tauri/src/docs/rca.rs | 222 ++++++++++++++++++++++++++++++- 4 files changed, 343 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 36bfbdc2..9259ce11 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -778,7 +778,9 @@ mod tests { fn test_timeline_events_indexes() { let conn = setup_test_db(); let mut stmt = conn - .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='timeline_events'") + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='timeline_events'", + ) .unwrap(); let indexes: Vec = stmt .query_map([], |row| row.get(0)) diff --git a/src-tauri/src/db/models.rs b/src-tauri/src/db/models.rs index d118719b..e7b1b2f6 100644 --- a/src-tauri/src/db/models.rs +++ b/src-tauri/src/db/models.rs @@ -130,14 +130,21 @@ pub struct TimelineEvent { } impl TimelineEvent { - pub fn new(issue_id: String, event_type: String, description: String, metadata: String) -> Self { + pub fn new( + issue_id: String, + event_type: String, + description: String, + metadata: String, + ) -> Self { TimelineEvent { id: Uuid::now_v7().to_string(), issue_id, event_type, description, metadata, - created_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), + created_at: chrono::Utc::now() + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(), } } } diff --git a/src-tauri/src/docs/postmortem.rs b/src-tauri/src/docs/postmortem.rs index 7009b55f..8b919b26 100644 --- a/src-tauri/src/docs/postmortem.rs +++ b/src-tauri/src/docs/postmortem.rs @@ -1,4 +1,5 @@ use crate::db::models::IssueDetail; +use crate::docs::rca::{calculate_duration, format_event_type}; pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { let issue = &detail.issue; @@ -51,7 +52,16 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { // Impact md.push_str("## Impact\n\n"); - md.push_str("- **Duration:** _[How long did the incident last?]_\n"); + 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!( + "- **Duration:** {}\n", + calculate_duration(first, last) + )); + } else { + md.push_str("- **Duration:** _[How long did the incident last?]_\n"); + } md.push_str("- **Users Affected:** _[Number/percentage of affected users]_\n"); md.push_str("- **Revenue Impact:** _[Financial impact, if applicable]_\n"); md.push_str("- **SLA Impact:** _[Were any SLAs breached?]_\n\n"); @@ -67,7 +77,19 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { if let Some(ref resolved) = issue.resolved_at { md.push_str(&format!("| {resolved} | Issue resolved |\n")); } - md.push_str("| _HH:MM_ | _[Add additional timeline events]_ |\n\n"); + if detail.timeline_events.is_empty() { + md.push_str("| _HH:MM_ | _[Add additional timeline events]_ |\n"); + } else { + 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'); // Root Cause Analysis md.push_str("## Root Cause Analysis\n\n"); @@ -114,6 +136,19 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { // What Went Well md.push_str("## What Went Well\n\n"); + if !detail.resolution_steps.is_empty() { + md.push_str(&format!( + "- Systematic 5-whys analysis conducted ({} steps completed)\n", + detail.resolution_steps.len() + )); + } + if detail + .timeline_events + .iter() + .any(|e| e.event_type == "root_cause_identified") + { + md.push_str("- Root cause was identified during triage\n"); + } md.push_str("- _[e.g., Quick detection through existing alerts]_\n"); md.push_str("- _[e.g., Effective cross-team collaboration]_\n"); md.push_str("- _[e.g., Smooth communication with stakeholders]_\n\n"); @@ -158,7 +193,7 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { #[cfg(test)] mod tests { use super::*; - use crate::db::models::{Issue, IssueDetail, ResolutionStep}; + use crate::db::models::{Issue, IssueDetail, ResolutionStep, TimelineEvent}; fn make_test_detail() -> IssueDetail { IssueDetail { @@ -247,4 +282,76 @@ mod tests { assert!(md.contains("| Priority | Action | Owner | Due Date | Status |")); assert!(md.contains("| P0 |")); } + + #[test] + fn test_postmortem_timeline_with_real_events() { + let mut detail = make_test_detail(); + detail.timeline_events = vec![ + TimelineEvent { + id: "te-1".to_string(), + issue_id: "pm-456".to_string(), + event_type: "triage_started".to_string(), + description: "Triage initiated".to_string(), + metadata: "{}".to_string(), + created_at: "2025-02-10 08:05:00 UTC".to_string(), + }, + TimelineEvent { + id: "te-2".to_string(), + issue_id: "pm-456".to_string(), + event_type: "root_cause_identified".to_string(), + description: "Certificate expiry confirmed".to_string(), + metadata: "{}".to_string(), + created_at: "2025-02-10 10:30:00 UTC".to_string(), + }, + ]; + let md = generate_postmortem_markdown(&detail); + assert!(md.contains("## Timeline")); + assert!(md.contains("| 2025-02-10 08:05:00 UTC | Triage Started - Triage initiated |")); + assert!(md.contains( + "| 2025-02-10 10:30:00 UTC | Root Cause Identified - Certificate expiry confirmed |" + )); + assert!(!md.contains("_[Add additional timeline events]_")); + } + + #[test] + fn test_postmortem_impact_with_duration() { + let mut detail = make_test_detail(); + detail.timeline_events = vec![ + TimelineEvent { + id: "te-1".to_string(), + issue_id: "pm-456".to_string(), + event_type: "triage_started".to_string(), + description: "Triage initiated".to_string(), + metadata: "{}".to_string(), + created_at: "2025-02-10 08:00:00 UTC".to_string(), + }, + TimelineEvent { + id: "te-2".to_string(), + issue_id: "pm-456".to_string(), + event_type: "root_cause_identified".to_string(), + description: "Found it".to_string(), + metadata: "{}".to_string(), + created_at: "2025-02-10 10:30:00 UTC".to_string(), + }, + ]; + let md = generate_postmortem_markdown(&detail); + assert!(md.contains("**Duration:** 2h 30m")); + assert!(!md.contains("_[How long did the incident last?]_")); + } + + #[test] + fn test_postmortem_what_went_well_with_steps() { + let mut detail = make_test_detail(); + detail.timeline_events = vec![TimelineEvent { + id: "te-1".to_string(), + issue_id: "pm-456".to_string(), + event_type: "root_cause_identified".to_string(), + description: "Root cause found".to_string(), + metadata: "{}".to_string(), + created_at: "2025-02-10 10:00:00 UTC".to_string(), + }]; + let md = generate_postmortem_markdown(&detail); + assert!(md.contains("Systematic 5-whys analysis conducted (1 steps completed)")); + assert!(md.contains("Root cause was identified during triage")); + } } diff --git a/src-tauri/src/docs/rca.rs b/src-tauri/src/docs/rca.rs index 06643f10..c3d2bcd7 100644 --- a/src-tauri/src/docs/rca.rs +++ b/src-tauri/src/docs/rca.rs @@ -1,5 +1,48 @@ 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; @@ -57,6 +100,52 @@ pub fn generate_rca_markdown(detail: &IssueDetail) -> String { 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() { @@ -143,7 +232,7 @@ pub fn generate_rca_markdown(detail: &IssueDetail) -> String { #[cfg(test)] mod tests { use super::*; - use crate::db::models::{Issue, IssueDetail, LogFile, ResolutionStep}; + use crate::db::models::{Issue, IssueDetail, LogFile, ResolutionStep, TimelineEvent}; fn make_test_detail() -> IssueDetail { IssueDetail { @@ -248,4 +337,135 @@ mod tests { 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(""), ""); + } }